Repository navigation
fix(worker): never requeue or re-pay a live upload (V2-1773) - #170
Conversation
The stuck-upload sweep reset any upload "processing" for over 60 minutes back to "queued", and with max_concurrent_uploads > 1 a second goroutine then prepared and paid for it again while the first was still working. Multi-batch merkle uploads make an hour-long upload likely. - In-flight guard: the worker tracks the uploads it is processing and the sweep never requeues them; one past the window is logged once instead. - Phase heartbeats: processing_at moves forward after prepare, after each payment and before finalize, so the window measures time without progress. - Pre-pay check: a payment recorded since the upload was last queued blocks another automatic payment (sweep/restart requeue, in-attempt retry, gas backoff). The upload fails as paid_unfinalized with its source kept; an admin Retry moves queued_at forward and authorizes a new payment. - Operator control: GET/PUT/DELETE /admin/upload-requeue-window and an Upload queue card on the System page change the window (default 60 min, 1 min to 7 days). In memory only, so a restart restores the default. Audited; refused on reader instances. Behaviour change: a wave-batch finalize failure no longer re-pays for unstored chunks on its automatic retry; it goes to paid_unfinalized. Closes V2-1773 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES
Findings:
-
Retry can be blocked immediately after a paid attempt.
internal/services/transaction.go:70-75treatscreated_at == queued_atas already paid. SQLite stores both timestamps at one-second precision (internal/database/migrations/sqlite/001_initial.sql:245;uploads.RetrywritesCURRENT_TIMESTAMPatinternal/services/upload.go:565). If an admin retries apaid_unfinalizedupload in the same second as its previous payment record, the old transaction still compares equal to the newqueued_at;refuseRepaymentthen refuses the authorized retry. The test backdates the transaction (internal/services/upload_requeue_guard_test.go:138) and misses this boundary. Use a distinct retry/payment-attempt marker or sufficient timestamp precision, and add a same-second retry test. -
The no-repayment guard can miss a successful payment when ledger recording fails.
recordPaymentignores errors fromtxnSvc.Record(internal/worker/upload.go:727-734), whilerefuseRepaymentrelies only on that row (internal/worker/upload_inflight.go:93-104). If payment succeeds, the insert fails, and a later finalize/retry path runs after DB recovery, the guard sees no payment and may pay again. Return and handle the record error as a paid/no-retry outcome before another payment can be attempted; cover the failure path. The disclosed crash-before-record gap remains a separate residual unless external idempotency or durable intent is added.
Coverage: 2/2 seats reviewed (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3); both seats agree (two model families) on CHANGES_REQUIRED. The reported every-minute discrepancy was checked against reconcileLoop and is not a defect (ticker is one minute). CI checks are green; local Go tests could not run here because Go is not installed.
…s paid/no-retry Two gaps in the no-repayment guard (Hermes review on ee96c9a): 1. A Retry in the same second as the payment before it was refused. HasPaymentSinceQueued compared transactions.created_at with uploads.queued_at, both second-precision on SQLite, so the old payment still counted and refuseRepayment blocked the payment the Retry authorized. Migration 015 adds uploads.payment_floor_txn_id: 0 at creation, raised by Retry to the upload's latest transaction id. A payment counts when its id is above the floor; ids only grow (AUTOINCREMENT / BIGSERIAL), so the order no longer depends on the clock. 2. A failed ledger write disarmed the guard. recordPayment ignored the Record error, and refuseRepayment reads only the ledger, so a wave-batch finalize failure after an unrecorded payment retried, re-prepared and could pay again. recordPayment now returns the error (one attempt: Record inserts then reads back, so a retry could double-record), and processUpload makes any failure after an unrecorded payment paid/no-retry (paidNoRetryIfUnrecorded): the upload stops as paid_unfinalized with its source kept. A merkle plan still pays its remaining batches, which finalize needs. The crash-between-payment-and-record residual is unchanged. V2-1773 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES
The two prior findings are fixed in this revision: retries now use a monotonic transaction-ID floor, and a failed ledger write prevents a later failure from triggering another payment attempt.
Finding — migration blocks uploads whose retry was already authorized before upgrade. Both internal/database/migrations/{sqlite,postgres}/015_upload_payment_floor.sql add payment_floor_txn_id with default 0, without backfilling existing uploads. If an upload was paid, then an admin retried it before this upgrade, Retry had advanced queued_at but the payment transaction ID remains above the new floor 0. Before this change, created_at < queued_at made that queued retry eligible; after migration, t.id > payment_floor_txn_id reports a payment and refuseRepayment rejects it. I reproduced the old predicate returning false and the new one true for that state using SQLite. Backfill the floor to the latest transaction ID whose created_at < queued_at (preserving the previous authorization semantics), and add an upgrade test with a paid, pre-upgrade retried upload.
The restart window between on-chain confirmation and recording remains the disclosed residual; I did not treat it as a new finding in this re-review. CI checks are green. Local Go tests remain unavailable here because Go is not installed.
Coverage: 2/2 seats reviewed at 3d8c10e (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). The seats disagreed on the migration behavior; I verified the regression against the old/new predicates and the stated pre-upgrade Retry semantics. The GLM seat approved the incremental change; sol-1 flagged the migration and restart residual. This request is based on the confirmed migration regression, not the disclosed restart residual.
…ion 015 Migration 015 started every upload at payment_floor_txn_id 0. An upload that had paid and then been retried by an operator before the upgrade would count that old payment, and refuseRepayment would block the payment the Retry authorized (Hermes review on 3d8c10e). Backfill the floor to the upload's latest payment recorded before queued_at: exactly what the created_at/queued_at comparison allowed (Retry has always set queued_at). A payment in the same second as queued_at stays above the floor and counts, as before. TestPaymentFloorBackfillOnUpgrade rolls 015 back, writes a retried, a never-retried and a same-second upload, re-applies 015 and checks each; it fails without the backfill. V2-1773 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES
The earlier findings are addressed: the transaction-ID floor fixes same-second retries, the worker now handles ledger-write errors, and migration 015 backfills pre-upgrade retries.
Finding — migration can turn a previously blocked payment into an allowed re-payment when transaction IDs and timestamps are not in the same order. In both internal/database/migrations/{sqlite,postgres}/015_upload_payment_floor.sql, the backfill sets the floor to MAX(t.id) among transactions with created_at < queued_at. The replacement guard blocks payment only when some transaction ID is above that floor. If an upload has a row at or after queued_at with a lower ID than another row before queued_at, the backfill puts the floor above the qualifying row and the new guard reports unpaid. The old timestamp guard reported paid. I reproduced this with IDs 100 (at queued_at) and 101 (one second before): old guard true, new guard false. This can authorize a repeat payment during upgrade. Preserve the old blocked/allowed result for all existing rows (for example, backfill a state that blocks whenever the old EXISTS(created_at >= queued_at) was true, and otherwise sets the floor above all existing transaction IDs), or prove and enforce a per-upload ID/timestamp ordering invariant; add a migration test with mixed ID/timestamp order.
CI checks are green. Local Go tests could not run here because Go is not installed.
Coverage: 2/2 seats reviewed at 38f9380 (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). The seats disagreed: sol-1 flagged this migration edge; glm-1 approved. I verified the failing migration state with SQLite. This request is based on the potential re-payment path, not the remaining disclosed crash-before-ledger-record residual.
The 015 backfill put the floor at the latest payment before queued_at. That matches the created_at/queued_at check only while a payment's id order matches its timestamp order. With a lower-id payment at or after queued_at and a higher-id one before it, the floor landed above the payment the old check counted, and the upload read as unpaid (Hermes review on 38f9380). Backfill from the old predicate itself: when a payment is at or after queued_at, the floor goes just below the lowest such id (paid); when none is, it goes to the highest id (not paid); with no payments, 0. Every existing upload keeps the old answer whatever the ordering. The upgrade test adds an out-of-order case (fails on the previous backfill), two payments before a Retry, and an upload with none. V2-1773 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
APPROVE
The migration now preserves the old paid/unpaid result for existing uploads even when transaction IDs and timestamps are out of order. The upgrade test covers that regression, prior retries, same-second payments, multiple pre-retry payments, and uploads with no payments. The earlier review findings are resolved.
Coverage: 2/2 seats reviewed at 3524b6e (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3); both seats approve. CI checks are green, including SQLite and PostgreSQL tests. I could not run local Go tests because Go is not installed here. The documented crash-between-payment-and-record residual remains acknowledged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # FEATURES.md # internal/worker/upload.go
The 0.13.0 notes described automatic re-payment as prevented outright. #170 leaves one gap: a crash after a payment confirms but before it is recorded leaves no ledger row, so the restarted worker can pay again for chunks not yet stored. Say so in the CHANGELOG and UPGRADING, with the reconciliation step, and drop the unqualified headlines (Hermes review on 0538f24). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(release): v0.13.0 — bundle antd v0.17.0 Changelog and upgrading notes for the 0.13.0 cut. The antd pins already moved on master (#166, #167, #169); this is the docs half: - upload payments: multi-batch merkle (#162), no requeue or second payment of a live upload (#170), partial-finalize resume (#171), and all-already-stored finalize (#172); - the requeue-window admin API, the behaviour changes (paid_unfinalized instead of an automatic second payment; 404 for not-found downloads), migration 015, licence files in releases (#165), Go 1.26.10, web advisories (#168), and the V2-1778 known issue. Also adds the missing [0.12.0] release link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(release): state the crash-before-record gap in the re-payment guard The 0.13.0 notes described automatic re-payment as prevented outright. #170 leaves one gap: a crash after a payment confirms but before it is recorded leaves no ledger row, so the restarted worker can pay again for chunks not yet stored. Say so in the CHANGELOG and UPGRADING, with the reconciliation step, and drop the unqualified headlines (Hermes review on 0538f24). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes V2-1773
Problem
The stuck-upload sweep can put a live upload back in the queue, and with more than one worker slot it then gets paid for twice:
DequeueNextsetsprocessing_atonce and never moves it.RequeueStuck(60), resetting any upload "processing" for over 60 minutes back to "queued". Nothing told it which uploads this process was still working on.max_concurrent_uploadsof 4, the next 2-second poll dequeued that row into a second goroutine, which prepared and paid again while the first was still paying or storing.Any upload that takes over an hour hits this. Multi-batch merkle (#162) makes it likely: each batch can wait up to 5 minutes for confirmation, and finalizing ~10 GiB stores ~2,500 chunks.
Startup has a related gap:
RequeueStuck(0)requeues everything left "processing", and the next attempt paid again for chunks a crashed attempt had already paid for.Fix
processOnereturns.RequeueStuckExcept) never requeues those uploads.processing_atmoves forward at each phase boundary (TouchProcessing): after prepare, after each payment (every merkle batch, and the wave-batch payment), and before finalize. The window now measures time without progress, not time since dequeue.payForQuotes),refuseRepaymentchecks for a payment recorded since the upload was created or last retried by an admin: a transaction for the upload withtransactions.id > uploads.payment_floor_txn_id(migration 015).paid_unfinalized, with its source kept and nothing paid.paid_unfinalized.recordPaymentreturns the error and the rest of that attempt is paid/no-retry (paidNoRetryIfUnrecorded). Any later failure ends aspaid_unfinalized, so no retry runs without the row the check needs. A merkle plan still pays its remaining batches, because finalize needs them.GET/PUT/DELETE /api/v2/admin/upload-requeue-windowand an "Upload queue" card on the admin System page.upload_requeue_window_changed, warn).workers_enabled=false) report the window but refuse changes with 409.Behaviour change
A wave-batch finalize failure used to retry by preparing again, and that paid again for any chunks still unstored. That second payment is now refused, and the upload goes to
paid_unfinalized. A re-prepare that finds nothing left to pay completes as before.V2-1774 (stacked PR, next) restores automatic recovery without paying again: it retries the finalize against the payment antd keeps (antd ≥ 0.14).
Known residual
A crash after a payment confirms but before it is recorded (
recordPayment) leaves no row for the check to see. That gap is unchanged from today. A failed ledger write without a crash is now handled (above).Files
internal/services:RequeueStuckExcept,TouchProcessing,HasPaymentSinceQueued;Retryraises the payment floor.RequeueStuckkeeps its signature.internal/database/migrations/{sqlite,postgres}/015_upload_payment_floor.sql:uploads.payment_floor_txn_id, backfilled so every existing upload keeps the answer the timestamp comparison gave: just below the lowest payment at or afterqueued_atif there is one (paid), otherwise the highest payment id (not paid), whatever the order of ids and timestamps.internal/worker:requeue_window.go(the override) andupload_inflight.go(the in-flight set, heartbeat, sweep and check), wired intoupload.go.internal/handlers:admin_upload_requeue.goand the routes. Swagger is regenerated with swag v1.16.6 (additions only).web: the System page card.FEATURES.md§5.3. The CHANGELOG entry comes with the release.Verified
On dev1 (Linux, Go 1.26.10), at 3524b6e:
go vet ./...;go test ./...on sqlite and on postgres 16;go test -raceon worker, services and handlers;npm ci, the web build (vue-tsc + vite) and web unit tests;New tests:
Devnet (2026-10-29, ant-node v0.23.1, antd 0.17.0, at ee96c9a + #171): the guard fired in every run with a 1-minute window, and no live upload was requeued or paid twice. A SIGKILL after payment followed by a restart refused the second payment and ended
paid_unfinalizedwith one transaction. An admin Retry paid only for the unstored remainder. (Report on V2-1775 / V2-1778.) The review fixes in 3d8c10e are covered by the tests above, not by a devnet run.Review round 1 (Hermes, ee96c9a): both findings are fixed in 3d8c10e (review range
ee96c9a..3d8c10e).Review round 2 (Hermes, 3d8c10e): the upgrade-path finding (no backfill in 015) is fixed in 38f9380 (review range
3d8c10e..38f9380).Review round 3 (Hermes, 38f9380): the id/timestamp-order finding is fixed in 3524b6e (review range
38f9380..3524b6e). The backfill now reproduces the old predicate exactly.🤖 Generated with Claude Code