feat: add automated SP dataset cleanup jobs - #691
Conversation
Adds two global pg-boss jobs that stop dealbot's wallet from accumulating leftover data sets that cost money without providing value: - sp_dataset_pruning: terminates active data sets for blocked SPs down to 0, and prunes any SP (trickle or full-rate) whose active count exceeds its tier's target by more than a configurable buffer — a safety net against unbounded accumulation independent of root cause. - abandoned_dataset_sweep: for data sets abandoned past PDPVerifier's ~30-day inactivity window, deletes them directly and permissionlessly (no signature required) and reclaims the cleanup deposit; for terminated-but-unsettled rails past their lockup, first attempts the permissionless settleRail automatically, only surfacing genuinely stuck cases to a human via a structured log with a ready-to-use Safe Transaction Builder batch. Closes #681, #689 Addresses #605
There was a problem hiding this comment.
Pull request overview
Adds automated, network-aware cleanup of excess and abandoned SP datasets, including rail settlement recovery and operational monitoring.
Changes:
- Adds scheduled dataset pruning and abandonment sweep jobs.
- Adds cleanup metrics, configuration, and queue integration.
- Documents configuration and operator recovery procedures.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
docs/runbooks/wallet-and-session-keys.md |
Adds cleanup runbook. |
docs/environment-variables.md |
Documents cleanup settings. |
apps/backend/.env.example |
Adds example variables. |
apps/backend/src/config/constants.ts |
Defines defaults. |
apps/backend/src/config/env.schema.ts |
Validates variables. |
apps/backend/src/config/loader.ts |
Loads cleanup configuration. |
apps/backend/src/config/network-fields.ts |
Registers network variables. |
apps/backend/src/config/types.ts |
Adds configuration types. |
apps/backend/src/data-set-lifecycle/data-set-lifecycle.service.ts |
Exports relay termination helper. |
apps/backend/src/database/entities/job-schedule-state.entity.ts |
Adds job types. |
apps/backend/src/jobs/job-queues.ts |
Defines cleanup queues. |
apps/backend/src/jobs/jobs.module.ts |
Imports cleanup module. |
apps/backend/src/jobs/jobs.service.ts |
Schedules and runs cleanup jobs. |
apps/backend/src/jobs/jobs.service.spec.ts |
Updates job-service fixtures. |
apps/backend/src/jobs/repositories/job-schedule.repository.ts |
Adds queue metric mappings. |
apps/backend/src/metrics-prometheus/metrics-prometheus.module.ts |
Registers cleanup metrics. |
apps/backend/src/sp-cleanup/sp-cleanup.module.ts |
Defines cleanup module. |
apps/backend/src/sp-cleanup/sp-cleanup.service.ts |
Implements pruning and sweeping. |
apps/backend/src/sp-cleanup/sp-cleanup.service.spec.ts |
Tests cleanup behavior. |
apps/backend/src/wallet-sdk/wallet-sdk.service.spec.ts |
Updates network fixture. |
Suppressed comments (2)
apps/backend/src/sp-cleanup/sp-cleanup.service.ts:343
- Important: verify that the mined deletion transaction succeeded before recording a successful attempt. Viem's
waitForTransactionReceiptresolves for receipts whose status isreverted; this path would increment the success metric, log the data set as deleted, and enter cleanup even though no state changed.
const hash = await writeContract(writeClient, request);
await waitForTransactionReceipt(writeClient, { hash });
this.recordAttempt(network, "abandonment", "success");
apps/backend/src/sp-cleanup/sp-cleanup.service.ts:393
- Blocker: make piece-cleanup failures verifiable and retriable. A mined reverted receipt is currently treated as success here, while the catch below converts every RPC, submission, or receipt failure into “cleanup not needed” and returns. After
deleteDataSethas entered cleanup mode, later sweeps have no path that resumescleanupPieces, so remaining pieces and the FIL deposit can be stranded permanently. Checkreceipt.status, suppress only the specifically decoded already-finalized/not-in-cleanup condition, and ensure cleanup-mode data sets are rediscovered and resumed after transient failures.
const hash = await writeContract(writeClient, request);
await waitForTransactionReceipt(writeClient, { hash });
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- handleAbandonmentCandidate: check receipt.status after deleteDataSet so a reverted-but-mined tx is treated as a failure, not a success - runDatasetPruning: scope the non-blocked prune loop to findAllByNetwork instead of findActiveAddresses, so registry-inactive providers aren't silently excluded from the excess-dataset safety net - runDatasetPruning / runAbandonedDatasetSweep: accept an AbortSignal, bounded by a new SP_CLEANUP_JOB_TIMEOUT_SECONDS (default 20m), wired into onApplicationShutdown's drain timeout like every other job
There was a problem hiding this comment.
🟡 Changes recommended
Cleanup recovery, rail finalization, provider coverage, and timeout enforcement contain correctness gaps that could leave resources permanently stranded.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
apps/backend/src/sp-cleanup/sp-cleanup.service.ts:552
- Important: this is not the Safe Transaction Builder checksum algorithm, so every generated “ready-to-use” batch is flagged as modified on import. Safe canonicalizes recursively with sorted keys, removes the checksum, and sets
meta.nametonullbefore hashing; hashing plainJSON.stringify(batch)produces a different value. Use Safe’s canonical checksum implementation (or intentionally omit the optional checksum) and add a compatibility test against a known batch.
docs/environment-variables.md:709
- Important: this description contradicts the implementation and runbook by saying stuck rails can only be surfaced to a human; the job first resolves them automatically with permissionless
settleRailand escalates only reverts. Update this operational documentation so responders know which cases require the Safe batch.
**Role**: How often the `abandoned_dataset_sweep` job runs for this network. This global job scans dealbot's
entire wallet (not scoped to the blocklist) for data sets outside the PDPVerifier activity window and deletes
them directly and permissionlessly (no signature needed — see the runbook), and for terminated data sets whose
rail settlement is stuck past `endEpoch`, which it can only ever surface to a human operator via a structured
log (`stuck_terminations_detected`).
- Files reviewed: 20/20 changed files
- Comments generated: 5
- Review effort level: Balanced
- settleOrFlagStuck: always attempt settleRail once getRail succeeds, instead of skipping when settledUpTo >= endEpoch. A terminated rail in that state is fully settled but not yet finalized (lockup still held) — settleRail's finalize-only path was being silently skipped, leaking the lockup forever. Switched from settleRailSync (which requires a RailSettled event that this path never emits) to a plain settleRail + receipt.status check. - finishCleanupPieces: retry transient failures (RPC/receipt issues) up to 5 times in a row instead of giving up on the first error; only treat the specific DataSetNotInCleanupMode revert as "done". deleteDataSet already removes the data set from FWSS's clientDataSets, so a future sweep can never rediscover it to retry — this call is the only chance. - Propagate the AbortSignal into terminateExcessDataSets and the abandonment/cleanup helpers, checked once per data set/iteration rather than only once per provider. Moved the finishCleanupPieces call outside deleteDataSet's try/catch so an abort raised inside it doesn't get relabeled as a deleteDataSet failure. - docs: correct the abandoned_dataset_sweep description — stuck rails are resolved automatically via permissionless settleRail first, and only escalated to a human when settlement genuinely reverts.
There was a problem hiding this comment.
🟡 Changes recommended
Dataset selection, post-delete recovery, timeout enforcement, and reverted-settlement classification have unresolved correctness risks.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
apps/backend/src/sp-cleanup/sp-cleanup.service.ts:408
- Blocker: Make post-delete piece cleanup recoverable. These lines confirm that a successful
deleteDataSetremoves the ID from the only discovery path, yet the method can subsequently exit on the 20-minute abort, a process restart, five transient RPC failures, or the iteration cap. Any such exit permanently strands the remaining pieces and cleanup deposit, which is especially likely during the documented large historical sweep. Persist or derive cleanup-mode data-set IDs independently oflistDataSetsand retry them across runs before considering deletion complete.
// `deleteDataSet` already removed this data set from FWSS's `clientDataSets`, so a future
// sweep's `listDataSets` can never rediscover it to retry a failed batch — every reasonable
// effort has to happen right here, in this one call.
const MAX_CONSECUTIVE_FAILURES = 5;
- Files reviewed: 20/20 changed files
- Comments generated: 3
- Review effort level: Balanced
- terminateExcessDataSets: sort leaked data_set_lifecycle_check sets first regardless of age, before falling back to oldest-first. Pure count-based oldest-first pruning could otherwise terminate a real dealbotDS-tagged slot while a newer leaked throwaway set (which survives "keep the newest N") crowds it out. - terminateExcessDataSets: wrap the provider-relay wait in awaitWithAbort, matching the existing pattern in data-set-lifecycle.service.ts, so an unresponsive SP status endpoint can't hold the sweep past its configured timeout. - settleOrFlagStuck: a mined-but-reverted settleRail receipt is now returned as a StuckRailItem directly instead of being thrown as a plain Error — the latter failed isContractRevert's check and misclassified a genuine on-chain revert as a transient RPC failure.
|
@beck-8 : thanks for posting. I would like to take a look (I've assigned myself as a reviewer) but don't block on me. |
|
I can't meaningfully review this PR this week. Even after that, I have almost no knowledge of the dealbot codebase. If I review this PR, it is going to be strongly LLM-guided. |
silent-cipher
left a comment
There was a problem hiding this comment.
Also another nit: can we use dataSet instead of dataset?
Pick survivors by provisioning slot instead of by age (#691 review). `terminateExcessDataSets` kept the newest `target` data sets, which is unrelated to keeping one set per required slot: the newest N can all belong to a single slot, leaving the only copies of the baseline and the other `dealbotDS` slots to be terminated and immediately re-provisioned, looping forever. Worse, `StorageContext.resolveByProviderId` in the SDK resolves a slot to its *lowest* data-set id (preferring one that holds pieces), so keeping the newest inverted the SDK's own choice and terminated exactly the set deal jobs were writing to. Pruning now reconstructs the required slots, keeps the set the SDK would resolve each one to, and treats the rest as surplus. `excessDatasetBuffer` now gates the surplus count rather than the total. Pruning runs outside `SP_WORK_QUEUE`, so it holds no per-provider lock. Rather than route the work through that queue — the plan is computed from a wallet-wide snapshot taken outside the lock, and re-validating under it needs a per-provider listing the SDK does not offer — the remaining races are closed directly: the slot rule never targets a set a deal job could resolve to, a lifecycle-check set is spared while younger than its job's timeout (its `dealbotLifecycleCheck` tag is the creating job's `Date.now()`), and every set is re-read on-chain immediately before its terminate call. Also: - Queue both cleanup jobs with an explicit `expireInSeconds` past their own timeout. pg-boss expires and fails a job after 15 minutes by default, short of the 1200s cleanup timeout. - Honour pg-boss's per-job `AbortSignal`, so an expiration or a graceful shutdown actually stops cleanup instead of leaving it running behind a job the queue has already failed. - Rename the leftover `SpBlocklistCleanup*` identifiers and log text to match `sp_dataset_pruning`. - Log `synapse_init_failed` on init failure, as deal and piece-cleanup do. - Add `tryGetSynapseClient`: `getSynapseClient` throws when a network has no wallet-sdk state, which made the on-demand Synapse fallback in `SpCleanupService` unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pick survivors by provisioning slot instead of by age (#691 review). `terminateExcessDataSets` kept the newest `target` data sets, which is unrelated to keeping one set per required slot: the newest N can all belong to a single slot, leaving the only copies of the baseline and the other `dealbotDS` slots to be terminated and immediately re-provisioned, looping forever. Worse, `StorageContext.resolveByProviderId` in the SDK resolves a slot to its *lowest* data-set id (preferring one that holds pieces), so keeping the newest inverted the SDK's own choice and terminated exactly the set deal jobs were writing to. Pruning now reconstructs the required slots, keeps the set the SDK would resolve each one to, and treats the rest as surplus. `excessDatasetBuffer` now gates the surplus count rather than the total. Pruning runs outside `SP_WORK_QUEUE`, so it holds no per-provider lock. Rather than route the work through that queue — the plan is computed from a wallet-wide snapshot taken outside the lock, and re-validating under it needs a per-provider listing the SDK does not offer — the remaining races are closed directly: the slot rule never targets a set a deal job could resolve to, a lifecycle-check set is spared while younger than its job's timeout (its `dealbotLifecycleCheck` tag is the creating job's `Date.now()`), and every set is re-read on-chain immediately before its terminate call. Also: - Queue both cleanup jobs with an explicit `expireInSeconds` past their own timeout. pg-boss expires and fails a job after 15 minutes by default, short of the 1200s cleanup timeout. - Honour pg-boss's per-job `AbortSignal`, so an expiration or a graceful shutdown actually stops cleanup instead of leaving it running behind a job the queue has already failed. - Rename the leftover `SpBlocklistCleanup*` identifiers and log text to match `sp_dataset_pruning`. - Log `synapse_init_failed` on init failure, as deal and piece-cleanup do. - Add `tryGetSynapseClient`: `getSynapseClient` throws when a network has no wallet-sdk state, which made the on-demand Synapse fallback in `SpCleanupService` unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eiTbbcRdxL5PgLtDzUTwK
`keccak256(JSON.stringify(batch))` is not Safe's algorithm. Safe canonicalizes recursively with sorted keys and nulls `meta.name` before hashing, so the checksum this produced never matched and every generated batch imported into the Transaction Builder with a "changed properties" warning. Extract Safe's actual algorithm into a shared helper, pinned by a vector derived from Safe's own implementation, so the SP cleanup jobs can reuse it rather than copying the wrong one a third time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both jobs walk every data set the wallet holds. Measured against dealbot's multisig on calibration (9,666 undeleted data sets), neither could finish. `listDataSets` enriches every data set in one unbounded `Promise.all`, which does not run slowly but fails outright — "fetch failed" at roughly the 7,582nd concurrent request. Same bug upstream (filecoin-pin#362, PR #708, still open). Switch to synapse-core's `getPdpDataSets`, which pages the listing and enriches through a bounded queue: 536s on calibration, 17s on mainnet. The sweep then issued one sequential eth_call per data set — at 0.353 s/call, ~36 minutes for calibration's 6,147 candidates against a 20-minute timeout, so the tail was never reached. Terminated data sets stay in `clientDataSets` until deleted, so each run re-read the same prefix forever. Batch both read phases through Multicall3; the read floor drops from 45 to 11 minutes. Exceeding the node's eth_call gas limit does not throw: with `allowFailure` every item returns `status: "failure"`, indistinguishable from every call legitimately reverting, which would make the sweep silently skip everything on every run. `readInBatches` stays far below the measured ceiling and splits and retries any batch whose failures carry the `SysErrOutOfGas` signature. `getPdpDataSets` resolves each provider from the SP registry, so pruning now takes the provider — relay service URL included — off the data set. The storage_provider table is consulted only for `isApproved`, which has no on-chain equivalent, and a provider missing from the registry is pruned rather than skipped. Both jobs move onto one `sp.cleanup` queue with pg-boss's singleton policy and a shared per-network key. They are seeded with the same `next_run_at`, so they previously ran concurrently, each walking the whole wallet; serialising also means the sweep observes pruning's terminations. Also from this review round: - Rename the `Dataset` identifiers this PR introduced to `DataSet`. Environment variables keep `DATASET_`, the existing majority spelling. - Adopt the shared Safe checksum helper for the stuck-rail batch. - Re-throw on abort in four catch blocks that swallowed it as a per-item failure, which recorded a timed-out run as successful. - Trim duplicated explanation from the previous round's comments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pruning matches a data set to a provisioning slot by exact metadata equality,
and reconstructed that metadata from `getBaseDataSetMetadata`. That is what our
callers pass *in*; the SDK adds `source` from the Synapse instance before both
storing and matching (`combineMetadata`, applied in `StorageManager.createContext`).
Read back from mainnet, all 236 of dealbot's data sets carry
`{source, withIPFSIndexing}` or those plus `dealbotDS` — so the reconstruction
matched none of them. Since an unmatched data set counts as surplus, pruning
would have terminated every data set the wallet holds.
The unit tests missed it because the fixture invented its own metadata shape
rather than the one on chain, so they validated the code against that invention.
Pin the real key set instead, and stop pruning a provider whose active sets
match no slot at all: that is not a state normal operation reaches, so it now
logs and skips rather than treating the whole holding as surplus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…enting it The `source` bug was not an isolated slip: pruning had hand-written copies of three pieces of SDK logic, and any drift in any of them terminates data sets the deal jobs are using. Two of the three did not need to exist. `findMatchingDataSets` is exported from @filoz/synapse-core and already applies exact metadata equality and the same ordering `resolveByProviderId` selects with — piece-bearing sets first, then lowest id — so its first entry *is* the set `createContext` resolves a slot to. Use it, and drop the local `metadataMatchesExactly` and `pickSlotSurvivor`. It also filters on `live` and `managed`, which the local copy did not, so a set the SDK would never resolve to is no longer treated as a slot's survivor. The unit tests now import the real matcher rather than mocking it, so a change in what the SDK considers a match fails the suite instead of passing against our own copy of the rule. What remains derived by hand is the slot metadata itself, because the `source` the SDK stamps on comes from a private field. That one is pinned against real mainnet metadata and guarded at runtime: a provider whose sets match no slot at all is skipped, not emptied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8baf251 to
1ff4005
Compare
|
At this point, I’m feeling a bit overwhelmed—the number of changes has far exceeded my expectations, and I can’t guarantee that the AI will execute exactly as I envision. From what I’ve seen so far, there are still many omissions. When we’re cleaning up, would it be better to add a persistent data table to track these states? That way, we could reduce RPC calls, continue tracking even if a task fails, and handle edge cases during cleanup. I’d like to ask someone else to take over this task or replace it outright. |
There was a problem hiding this comment.
I asked the agent to prepare a Q&A from the review discussion, and I'm pasting it here to make reasoning easier for everyone:
SP cleanup jobs: consolidated rationale
The conclusions below were checked against the deployed contract behavior and the live calibration wallet—not inferred only from this repository.
Summary
- Keep
sp_data_set_pruningandabandoned_data_set_sweepas separate global jobs. - Pruning handles cooperative SPs through the provider relay.
- The sweep handles abandoned datasets and delayed rail settlement through permissionless on-chain calls.
- Neither job belongs on
SP_WORK_QUEUE. - The sweep addresses an existing calibration backlog, not a theoretical problem.
- Incomplete
cleanupPieceswork is acceptable because stopping proofs and releasing dealbot’s lockup happen earlier.
Why two separate global jobs?
| Job | Purpose | Authorization | Why global |
|---|---|---|---|
sp_data_set_pruning |
Remove blocked or excess datasets | Requires a reachable, cooperative SP through the relay | Dataset listing is wallet-wide. Running per SP would repeatedly fetch the same wallet data. |
abandoned_data_set_sweep |
Delete abandoned datasets and settle terminated rails | Uses permissionless on-chain paths | It must include datasets whose SP has disappeared from the registry, and it never contacts the SP server. |
The sweep is not merely a fallback for pruning. Most terminated datasets come from the existing lifecycle-check job, while abandoned datasets can belong to SPs that pruning would never target.
Why pruning does not use SP_WORK_QUEUE
The existing per-SP queue is already close to full worst-case occupancy: approximately 3,492.5 seconds per hour for a full-rate SP.
Adding another consumer would most affect busy or degraded SPs—the same ones most likely to need pruning. Blocked SPs already have no collision risk because they are excluded from normal SP scheduling. For non-blocked SPs, pruning follows the existing maintenance-window deferral and performs only brief relay requests.
Contract behavior and ownership of funds
The deleteDataSet path does not have the session-key problem seen in #546:
FWSS.terminateServicerequires the caller to be the payer or SP, so dealbot’s session key cannot call it directly.PDPVerifier.deleteDataSetrequires the SP only during the approximately 30-day activity window. After that, it is permissionless.- FWSS independently checks the same inactivity condition when processing
dataSetDeleted.
One correction from the earlier discussion: the PDP cleanup deposit is funded by the SP, not dealbot. Whoever completes cleanup receives that deposit. It is therefore a keeper bounty, not dealbot recovering its own funds.
The payment-rail lockup is different: that is dealbot’s money. Settling the rail releases funds belonging to dealbot.
Why Curio cleanup does not make the sweep redundant
Curio cleanup helps, but dealbot should not depend on it exclusively:
- A dead SP cannot submit cleanup transactions.
- SP cleanup recovers the SP-funded deposit, not dealbot’s funds.
- Curio cleanup is rate-limited, so rail settlement may be delayed indefinitely from dealbot’s perspective.
A cooperative SP may eventually settle the rail itself. The reason for dealbot to do it is control over when its own lockup is released—not because settlement is otherwise impossible.
Current calibration evidence
At approximately block 4,077,068 on 2026-09-17:
- 22 datasets were active but never terminated.
- 15 of those were already outside the inactivity window, some stale for 101–233 days.
- 49 terminated datasets were past their payment end epoch.
- 35 had been cleaned up.
- 14 remained stuck, including 10 for roughly 228 days.
That is a 29% stuck rate among eligible terminated datasets. The sweep is addressing a real backlog. This check has not yet been repeated against mainnet.
Accepted limitation
If the job times out after deleteDataSet succeeds but before cleanupPieces finishes:
- proof obligations have already stopped;
dataSetDeletedhas already abandoned the rails and released dealbot’s lockup;- only the SP-funded cleanup bounty remains unclaimed.
That outcome satisfies dealbot’s goals, so no additional recovery mechanism is planned.
|
Found a throughput problem with both cleanup jobs: they process data sets strictly in sequence, and each termination/deletion needs an on-chain confirmation (~30s block time). With a 20-minute job window, that caps us at roughly 40 data sets cleaned per run in ideal case. On calibration, some SPs have ~500 data sets to terminate, at this rate that's weeks to clear a single SP's backlog, and other SPs would sit starved behind it in the meantime. Holding off on merging until this is resolved. |
https://telemetry.betterstack.com/team/t468215/dashboards/618457/charts/18254157065?rf=now-7d&rt=now

Situation where the testnet dataset is exceeded
Summary
sp_dataset_pruning: prunes blocked SPs to 0 activedata sets, and prunes any other SP (trickle-tier or full-rate)
whose active data-set count exceeds its tier's target by more than
<NET>_EXCESS_DATASET_BUFFER(default 5) — a safety net againstunbounded accumulation regardless of cause.
abandoned_dataset_sweep: cleans up data setsbelonging to permanently unreachable SPs via PDPVerifier's
permissionless
deleteDataSetpath (no signature needed, reclaimsthe cleanup deposit via
cleanupPieces), and auto-resolves stuckrail settlements via the permissionless
settleRailwherepossible — only escalating genuinely stuck cases (validator issues)
to a human, surfaced via a structured log with a ready-to-use Safe
Transaction Builder batch.
sp_termination_attempts_total,sp_termination_stuck_gauge.Cleanup section).
Follow-ups (not in this PR)
sp_termination_stuck_gauge.over-accumulate active data sets. Confirmed at least one distinct
mechanism for 23:
data_set_lifecycle_checkthrowaway sets whoseimmediate termination keeps failing while proving stays healthy —
so
abandoned_dataset_sweep's inactivity-based cleanup nevertriggers for them (root cause likely on that SP's own RPC/relay
path, not a dealbot bug). 2/4/9 haven't been traced yet — could
be the same termination-failure pattern, a data-set-reuse issue in
provisionNextMissingDataSet, or something else. This PR'sexcessDatasetBufferpruning retries provider-relay terminationdaily regardless of cause, which helps if failures are intermittent
but can't resolve a persistently broken termination path.
Test plan
pnpm --filter dealbot-backend test— 708 passingpnpm --filter dealbot-backend typecheckabandoned_dataset_sweeprun (large one-time historical backlog, will take a while and use
real testnet FIL for gas) and the session key's FIL balance
(
wallet_balance{currency="FIL"})related #605 #681 #689