Skip to content

Commit 24ed2b6

Browse files
feat: add full-rate and trickle SP testing tiers (#682)
* feat: introduce trickle sp tier * docs: clarify documentation and comments * test(jobs): add providerId to mock provider registry responses * chore: address review comments
1 parent 7f6bf11 commit 24ed2b6

17 files changed

Lines changed: 290 additions & 44 deletions

apps/backend/.env.example

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@
2222
# Chain-specific variables never inherit the unprefixed slot and MUST be
2323
# prefixed: WALLET_ADDRESS, WALLET_PRIVATE_KEY, SESSION_KEY_PRIVATE_KEY, RPC_URL,
2424
# CLICKHOUSE_URL, PDP_SUBGRAPH_ENDPOINT, SUBGRAPH_ENDPOINT, DEALBOT_DATASET_VERSION,
25-
# BLOCKED_SP_IDS, BLOCKED_SP_ADDRESSES, DATASET_LIFECYCLE_CHECK_ENABLED
25+
# BLOCKED_SP_IDS, BLOCKED_SP_ADDRESSES, FULL_RATE_SP_IDS,
26+
# FULL_RATE_SP_ADDRESSES, DATASET_LIFECYCLE_CHECK_ENABLED
2627
# (network-dependent default: off on mainnet, so a shared =true must not enable
2728
# the canary there).
2829
#
30+
# Full-rate/trickle tiering is applied after provider eligibility. See
31+
# docs/environment-variables.md#full-rate-vs-trickle-tier.
32+
#
2933
# Process globals (database, HTTP ports, ClickHouse batching, pg-boss) have no
3034
# per-network form. Variables for INACTIVE networks are ignored; only active
3135
# networks are validated at startup.
@@ -140,6 +144,11 @@ CALIBRATION_PULL_PIECE_CLEANUP_INTERVAL_SECONDS=604800
140144
# CALIBRATION_BLOCKED_SP_IDS=1234,5678
141145
# CALIBRATION_BLOCKED_SP_ADDRESSES=0xAbCd...,0x1234...
142146

147+
# Optional full-rate overrides for unapproved providers. Effective only when
148+
# CALIBRATION_USE_ONLY_APPROVED_PROVIDERS=false.
149+
# CALIBRATION_FULL_RATE_SP_IDS=1234,5678
150+
# CALIBRATION_FULL_RATE_SP_ADDRESSES=0xAbCd...,0x1234...
151+
143152
# -----------------------------------------------------------------------------
144153
# Per-network configuration — MAINNET (uncomment when adding to NETWORKS)
145154
# -----------------------------------------------------------------------------
@@ -174,6 +183,12 @@ MAINNET_MAINTENANCE_WINDOWS_UTC=07:00,22:00
174183
MAINNET_MAINTENANCE_WINDOW_MINUTES=20
175184
# MAINNET_BLOCKED_SP_IDS=1234,5678
176185
# MAINNET_BLOCKED_SP_ADDRESSES=0xAbCd...,0x1234...
186+
187+
# Optional full-rate overrides for unapproved providers. Effective only when
188+
# MAINNET_USE_ONLY_APPROVED_PROVIDERS=false.
189+
# MAINNET_FULL_RATE_SP_IDS=1234,5678
190+
# MAINNET_FULL_RATE_SP_ADDRESSES=0xAbCd...,0x1234...
191+
177192
MAINNET_MAX_DATASET_STORAGE_SIZE_BYTES=25769803776
178193
MAINNET_TARGET_DATASET_STORAGE_SIZE_BYTES=21474836480
179194
MAINNET_MAX_PIECE_CLEANUP_RUNTIME_SECONDS=3000

apps/backend/src/common/sp-tier.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { INetworkConfig } from "src/config/types.js";
2+
3+
/**
4+
* Returns whether a provider qualifies for full-rate testing.
5+
*
6+
* @see [Provider eligibility and testing tiers](../../../../docs/jobs.md#provider-eligibility-and-testing-tiers)
7+
*/
8+
export function isFullRateTier(
9+
cfg: Pick<INetworkConfig, "fullRateSpAddresses" | "fullRateSpIds">,
10+
address: string,
11+
isApproved: boolean,
12+
id?: bigint | null,
13+
): boolean {
14+
if (isApproved) return true;
15+
if (cfg.fullRateSpAddresses.has(address.toLowerCase())) return true;
16+
if (id != null && cfg.fullRateSpIds.has(String(id))) return true;
17+
return false;
18+
}

apps/backend/src/config/constants.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ export const networkDefaults = {
3636
pullPieceCleanupIntervalSeconds: 7 * 24 * 3600, // 7 days
3737
} satisfies NetworkDefaults;
3838

39+
/**
40+
* Fixed rates and dataset target for eligible providers outside the full-rate
41+
* tier. Kept non-configurable to limit wallet-spend exposure.
42+
*/
43+
export const trickleTierRates = {
44+
dealsPerSpPerHour: 1 / 4, // 1 data-storage check every 4 hours
45+
dataSetCreationsPerSpPerHour: 1 / 4, // 1 data-set-creation tick every 4 hours
46+
minNumDataSetsForChecks: 1,
47+
} as const;
48+
3949
/**
4050
* Uppercase env-var prefixes for every supported network, e.g.
4151
* `["CALIBRATION", "MAINNET"]`

apps/backend/src/config/env.schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ const perNetworkFieldRules = (): Record<PerNetworkVar, Joi.Schema> => ({
189189
MAINTENANCE_WINDOW_MINUTES: Joi.number().min(20).max(360).optional(),
190190
BLOCKED_SP_IDS: Joi.string().optional().allow(""),
191191
BLOCKED_SP_ADDRESSES: Joi.string().optional().allow(""),
192+
FULL_RATE_SP_IDS: Joi.string().optional().allow(""),
193+
FULL_RATE_SP_ADDRESSES: Joi.string().optional().allow(""),
192194
PIECE_CLEANUP_PER_SP_PER_HOUR: Joi.number().min(0.001).max(20).optional(),
193195
MAX_PIECE_CLEANUP_RUNTIME_SECONDS: Joi.number().min(60).optional(),
194196
MAX_DATASET_STORAGE_SIZE_BYTES: Joi.number().integer().min(1).optional(),

apps/backend/src/config/loader.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,9 @@ function loadNetworkEnvPrefix(
234234
blockedSpIds: parseIdList(resolve("BLOCKED_SP_IDS")),
235235
blockedSpAddresses: parseAddressList(resolve("BLOCKED_SP_ADDRESSES")),
236236

237+
fullRateSpIds: parseIdList(resolve("FULL_RATE_SP_IDS")),
238+
fullRateSpAddresses: parseAddressList(resolve("FULL_RATE_SP_ADDRESSES")),
239+
237240
pullChecksPerSpPerHour: coerceFloat(resolve("PULL_CHECKS_PER_SP_PER_HOUR"), networkDefaults.pullChecksPerSpPerHour),
238241
pullCheckJobTimeoutSeconds: coerceNumber(
239242
resolve("PULL_CHECK_JOB_TIMEOUT_SECONDS"),

apps/backend/src/config/network-fields.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export const PER_NETWORK_VARS = [
4747
"MAINTENANCE_WINDOW_MINUTES",
4848
"BLOCKED_SP_IDS",
4949
"BLOCKED_SP_ADDRESSES",
50+
"FULL_RATE_SP_IDS",
51+
"FULL_RATE_SP_ADDRESSES",
5052
"PIECE_CLEANUP_PER_SP_PER_HOUR",
5153
"MAX_PIECE_CLEANUP_RUNTIME_SECONDS",
5254
"MAX_DATASET_STORAGE_SIZE_BYTES",
@@ -76,6 +78,8 @@ export const CHAIN_SPECIFIC_NETWORK_VARS = new Set<PerNetworkVar>([
7678
"DEALBOT_DATASET_VERSION",
7779
"BLOCKED_SP_IDS",
7880
"BLOCKED_SP_ADDRESSES",
81+
"FULL_RATE_SP_IDS",
82+
"FULL_RATE_SP_ADDRESSES",
7983
// Kept chain-specific so a shared `=true` can't accidentally enable it on mainnet.
8084
"DATASET_LIFECYCLE_CHECK_ENABLED",
8185
]);

apps/backend/src/config/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ export type BaseNetworkConfig = {
141141
blockedSpIds: Set<string>;
142142
blockedSpAddresses: Set<string>;
143143

144+
/** Provider IDs and addresses granted full-rate testing. */
145+
fullRateSpIds: Set<string>;
146+
fullRateSpAddresses: Set<string>;
147+
144148
/** Piece Cleanup Config */
145149
maxDatasetStorageSizeBytes: number;
146150
targetDatasetStorageSizeBytes: number;

apps/backend/src/deal/deal.service.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,7 +1365,7 @@ describe("DealService", () => {
13651365
it("returns undefined when minDataSets=1 and baseline is live", async () => {
13661366
probeSpy.mockResolvedValueOnce({ status: "live", dataSetId: 1n });
13671367

1368-
const result = await service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK);
1368+
const result = await service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK, 1);
13691369
expect(result).toBeUndefined();
13701370
expect(probeSpy).toHaveBeenCalledTimes(1);
13711371
const [, metadata] = probeSpy.mock.calls[0] ?? [];
@@ -1375,7 +1375,7 @@ describe("DealService", () => {
13751375
it("throws DealJobTerminatedDataSetError when baseline is terminated", async () => {
13761376
probeSpy.mockResolvedValueOnce({ status: "terminated", dataSetId: 42n });
13771377

1378-
await expect(service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK)).rejects.toBeInstanceOf(
1378+
await expect(service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK, 1)).rejects.toBeInstanceOf(
13791379
DealJobTerminatedDataSetError,
13801380
);
13811381
});
@@ -1390,7 +1390,7 @@ describe("DealService", () => {
13901390
vi.spyOn(Math, "random").mockReturnValue(0.5); // → index 1
13911391
probeSpy.mockResolvedValueOnce({ status: "live", dataSetId: 7n });
13921392

1393-
const result = await service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK);
1393+
const result = await service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK, 3);
13941394
expect(result).toEqual({ dealbotDS: "1" });
13951395
expect(probeSpy).toHaveBeenCalledTimes(1);
13961396
const [, metadata] = probeSpy.mock.calls[0] ?? [];
@@ -1412,7 +1412,7 @@ describe("DealService", () => {
14121412
setupIndexedProbe();
14131413
probeSpy.mockResolvedValueOnce({ status: "live", dataSetId: 1n });
14141414

1415-
const result = await service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK);
1415+
const result = await service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK, 3);
14161416
expect(result).toBeUndefined();
14171417
});
14181418

@@ -1431,7 +1431,7 @@ describe("DealService", () => {
14311431
});
14321432

14331433
await expect(
1434-
service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK, controller.signal),
1434+
service.resolveDataSetMetadataForDeal("0xprovider", DEFAULT_NETWORK, 3, controller.signal),
14351435
).rejects.toThrow();
14361436
expect(probeSpy).toHaveBeenCalledTimes(1);
14371437
});

apps/backend/src/deal/deal.service.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ import {
1717
redactSensitiveText,
1818
toStructuredError,
1919
} from "../common/logging.js";
20+
import { isFullRateTier } from "../common/sp-tier.js";
2021
import { createSynapseFromConfig } from "../common/synapse-factory.js";
2122
import type { DataFile, Hex, Network } from "../common/types.js";
23+
import { trickleTierRates } from "../config/constants.js";
2224
import type { IConfig, INetworkConfig } from "../config/types.js";
2325
import { Deal } from "../database/entities/deal.entity.js";
2426
import { DealStatus, IpniStatus, ServiceType } from "../database/types.js";
@@ -84,9 +86,14 @@ export class DealService {
8486
): Promise<Deal> {
8587
options.signal?.throwIfAborted();
8688

89+
const networkCfg = this.getNetworkConfig(options.network);
90+
const isFullTier = isFullRateTier(networkCfg, pdpProvider.serviceProvider, pdpProvider.isApproved, pdpProvider.id);
91+
const minDataSets = isFullTier ? networkCfg.minNumDataSetsForChecks : trickleTierRates.minNumDataSetsForChecks;
92+
8793
const extraDataSetMetadata = await this.resolveDataSetMetadataForDeal(
8894
pdpProvider.serviceProvider,
8995
options.network,
96+
minDataSets,
9097
options.signal,
9198
options.logContext,
9299
);
@@ -117,9 +124,9 @@ export class DealService {
117124
* Pick which data-set slot this deal will target.
118125
*
119126
* Policy:
120-
* - If `minNumDataSetsForChecks > 1` and a random index > 0 is selected,
121-
* probe that slot first. If live, use it. If missing or terminated,
122-
* fall through to baseline (data_set_creation owns repair/provisioning).
127+
* - If `minDataSets > 1` and a random index > 0 is selected, probe that
128+
* slot first. If live, use it. If missing or terminated, fall through
129+
* to baseline (data_set_creation owns repair/provisioning).
123130
* - Probe baseline. If terminated, throw `DealJobTerminatedDataSetError`
124131
* (baseline is the fallback target; nothing else to try).
125132
* - Live or missing baseline → return `undefined` (use baseline slot).
@@ -131,6 +138,7 @@ export class DealService {
131138
async resolveDataSetMetadataForDeal(
132139
providerAddress: string,
133140
network: Network,
141+
minDataSets: number,
134142
signal?: AbortSignal,
135143
logContext?: ProviderJobContext,
136144
): Promise<Record<string, string> | undefined> {
@@ -141,6 +149,7 @@ export class DealService {
141149
providerAddress,
142150
baseDataSetMetadata,
143151
network,
152+
minDataSets,
144153
signal,
145154
logContext,
146155
);
@@ -174,10 +183,10 @@ export class DealService {
174183
providerAddress: string,
175184
baseDataSetMetadata: Record<string, string>,
176185
network: Network,
186+
minDataSets: number,
177187
signal: AbortSignal | undefined,
178188
logContext: ProviderJobContext | undefined,
179189
): Promise<Record<string, string> | undefined> {
180-
const minDataSets = this.getNetworkConfig(network).minNumDataSetsForChecks;
181190
if (minDataSets <= 1) return undefined;
182191
const dsIndex = Math.floor(Math.random() * minDataSets);
183192
if (dsIndex === 0) return undefined;

apps/backend/src/jobs/jobs.service.spec.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ describe("JobsService schedule rows", () => {
2424
upsertSchedule: ReturnType<typeof vi.fn>;
2525
deleteSchedulesForInactiveProviders: ReturnType<typeof vi.fn>;
2626
deleteSchedulesByJobType: ReturnType<typeof vi.fn>;
27+
deleteSchedulesForAddresses: ReturnType<typeof vi.fn>;
2728
countPausedSchedules: ReturnType<typeof vi.fn>;
2829
findDueSchedulesWithManager: ReturnType<typeof vi.fn>;
2930
runTransaction: ReturnType<typeof vi.fn>;
@@ -90,6 +91,7 @@ describe("JobsService schedule rows", () => {
9091
upsertSchedule: vi.fn(),
9192
deleteSchedulesForInactiveProviders: vi.fn(async () => []),
9293
deleteSchedulesByJobType: vi.fn(async () => 0),
94+
deleteSchedulesForAddresses: vi.fn(async () => []),
9395
countPausedSchedules: vi.fn(async () => []),
9496
findDueSchedulesWithManager: vi.fn(),
9597
runTransaction: vi.fn(async (callback: (manager: unknown) => Promise<void>) => {
@@ -151,6 +153,8 @@ describe("JobsService schedule rows", () => {
151153
maintenanceWindowMinutes: 20,
152154
blockedSpIds: new Set(),
153155
blockedSpAddresses: new Set(),
156+
fullRateSpIds: new Set(),
157+
fullRateSpAddresses: new Set(),
154158
pieceCleanupPerSpPerHour: 1,
155159
maxPieceCleanupRuntimeSeconds: 300,
156160
maxDatasetStorageSizeBytes: 24 * 1024 * 1024 * 1024,
@@ -1337,7 +1341,7 @@ describe("JobsService schedule rows", () => {
13371341
};
13381342

13391343
const providerRegistryRepository = {
1340-
findByAddress: vi.fn(() => ({ id: 1, name: "test-provider" })),
1344+
findByAddress: vi.fn(() => ({ id: 1, name: "test-provider", isApproved: true })),
13411345
};
13421346

13431347
service = buildService({
@@ -1474,7 +1478,9 @@ describe("JobsService schedule rows", () => {
14741478
} as unknown as JobsServiceDeps[0];
14751479

14761480
const dataSetLifecycleService = { runLifecycleCheck: vi.fn(async () => undefined) };
1477-
const providerRegistryRepository = { findByAddress: vi.fn(() => ({ id: 1, name: "test-provider" })) };
1481+
const providerRegistryRepository = {
1482+
findByAddress: vi.fn(() => ({ id: 1, name: "test-provider", isApproved: true })),
1483+
};
14781484

14791485
service = buildService({
14801486
configService,
@@ -1519,7 +1525,9 @@ describe("JobsService schedule rows", () => {
15191525
} as unknown as JobsServiceDeps[0];
15201526
service = buildService({ configService });
15211527

1522-
providerRegistryRepositoryMock.findActiveAddresses.mockResolvedValueOnce([{ address: "0xaaa" }]);
1528+
providerRegistryRepositoryMock.findActiveAddresses.mockResolvedValueOnce([
1529+
{ address: "0xaaa", providerId: 1n, isApproved: true },
1530+
]);
15231531

15241532
await callPrivate(service, "ensureScheduleRows", DEFAULT_NETWORK);
15251533

@@ -1529,11 +1537,14 @@ describe("JobsService schedule rows", () => {
15291537
expect(lifecycleUpserts).toHaveLength(1);
15301538
expect(lifecycleUpserts[0][1]).toBe("0xaaa");
15311539
expect(jobScheduleRepositoryMock.deleteSchedulesByJobType).not.toHaveBeenCalled();
1540+
expect(jobScheduleRepositoryMock.deleteSchedulesForAddresses).not.toHaveBeenCalled();
15321541
});
15331542

15341543
it("removes data_set_lifecycle_check schedules when disabled", async () => {
15351544
// base config has dataSetLifecycleCheckEnabled=false
1536-
providerRegistryRepositoryMock.findActiveAddresses.mockResolvedValueOnce([{ address: "0xaaa" }]);
1545+
providerRegistryRepositoryMock.findActiveAddresses.mockResolvedValueOnce([
1546+
{ address: "0xaaa", providerId: 1n, isApproved: true },
1547+
]);
15371548

15381549
await callPrivate(service, "ensureScheduleRows", DEFAULT_NETWORK);
15391550

@@ -1547,6 +1558,36 @@ describe("JobsService schedule rows", () => {
15471558
);
15481559
});
15491560

1561+
it("does not schedule data_set_lifecycle_check for trickle-tier providers, and clears stale rows", async () => {
1562+
baseConfigValues = {
1563+
...baseConfigValues,
1564+
networks: {
1565+
calibration: { ...(baseConfigValues.networks as any).calibration, dataSetLifecycleCheckEnabled: true },
1566+
} as unknown as IConfig["networks"],
1567+
};
1568+
configService = {
1569+
get: vi.fn((key: keyof IConfig) => baseConfigValues[key]),
1570+
} as unknown as JobsServiceDeps[0];
1571+
service = buildService({ configService });
1572+
1573+
providerRegistryRepositoryMock.findActiveAddresses.mockResolvedValueOnce([
1574+
{ address: "0xaaa", providerId: 1n, isApproved: false },
1575+
]);
1576+
1577+
await callPrivate(service, "ensureScheduleRows", DEFAULT_NETWORK);
1578+
1579+
const lifecycleUpserts = jobScheduleRepositoryMock.upsertSchedule.mock.calls.filter(
1580+
(call) => call[0] === "data_set_lifecycle_check",
1581+
);
1582+
expect(lifecycleUpserts).toHaveLength(0);
1583+
expect(jobScheduleRepositoryMock.deleteSchedulesByJobType).not.toHaveBeenCalled();
1584+
expect(jobScheduleRepositoryMock.deleteSchedulesForAddresses).toHaveBeenCalledWith(
1585+
"data_set_lifecycle_check",
1586+
["0xaaa"],
1587+
DEFAULT_NETWORK,
1588+
);
1589+
});
1590+
15501591
it("sets active, inactive, and tested provider gauge values after refresh", async () => {
15511592
providerRegistryRepositoryMock.countByNetwork.mockResolvedValueOnce(10);
15521593
providerRegistryRepositoryMock.countActiveByNetwork.mockResolvedValueOnce(7);

0 commit comments

Comments
 (0)