🐛 fix(storage): reclaim ingress intents no upload can finalize - #2013
Merged
Conversation
An upload staged a durable ingress intent before it stored anything, and every exit that never reached the advance to admitted left that intent pending for good. Nothing settled it afterwards: no production code wrote IntentPhase::Expired, pruning only removed settled rows, and the recovery sweep returned early on the missing upload row those failures leave. The record and its bytes stayed charged against the authority's 65,536 record and 64 GiB ceilings, so a flaky publisher CI turned leaked intents into 503 ingress admission retention is full for the whole project. A head of such intents also refilled the sweep's 256-row batch on every pass and starved the recoverable intents behind it. The synchronous failures release their intent as they return, since a write that stored nothing can name its own intent. Only the request that staged the intent may release it, so a concurrent identical resend that deduplicated onto it cannot strip the record from an upload still storing its bytes. A client that hangs up mid-store drops the request future outright and leaves no code to run that release, so those need a reaper. Expiring on age alone would also drop intents a slow home datacenter can still finalize, so expiry waits on the sweep's own evidence: the sweep counts each pass that finds no stored rows, stops offering the intent after three, and only then may the reaper expire it past its staging deadline. The count is what keeps the two directions apart, and it is the same state that keeps an unfinalizable head out of the batch.
Merging this PR will not alter performance
Comparing Footnotes
|
gaborbernat
added a commit
that referenced
this pull request
Aug 31, 2026
INGRESS_INTENT_ORDER was written when an intent was staged and cleared only when its record was released or pruned, so an admitted or expired intent kept its entry for a full retention window. list_pending_intents walks that table from sequence zero and reads the primary row before it can tell a settled intent from a pending one, which made every finalize sweep cost the authority's whole staged history to fill a 256-slot batch. One authority may retain 65,536 intents, and the expiry path added in #2013 holds a given-up intent for a staging deadline plus a retention window before pruning frees its entry. Every transition out of Pending now drops the order entry in the write transaction that settles the record: advance_intent, expire_stale_intents, and the finalize commit. The index therefore describes exactly the pending set, so the phase filter on the read path is gone and a sweep reads only rows it can return. The refusal ceiling stays, because a refused intent is still pending and must keep being offered until it expires. Pruning no longer opens the order table. A settled intent left the index when it settled, and a stale entry whose row is gone is invisible to a pass that iterates the record table, so the removal protected nothing. Nothing is released, so no repair pass is needed for entries earlier versions left behind.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A hosted PyPI upload stages a durable ingress intent before it stores anything, and every exit from
admit_and_storethat never reached the advance toadmittedleft that intentPendingfor good. Nothing settled it afterwards: no production code wroteIntentPhase::Expired,prune_ingress_intentsdeleted onlyAdmittedandExpiredrows, and the crash-recovery sweep returned early on the missing upload row that a failed store leaves behind.The cost decides the severity. A leaked intent holds its record and its bytes against the authority's 65,536 record and 64 GiB ceilings, so a publisher CI retrying on a bad link turns leaked intents into
503 ingress admission retention is fullfor every further upload of that project, with the disk space long since freed. A retry of the same upload still goes through, since it deduplicates onto the pending record and republishes. The leak accumulates instead, and until now the operator-run authority drain was the only thing that cleared it. A head of leaked intents also refilled the sweep's 256-row batch on every pass and starved the recoverable intents behind it, against the module's own claim that one unfinalizable intent never blocks the backlog.The synchronous failures release their intent as they return. A replayed operation, an unreadable claim, an unavailable authority home, a quota block, and a store fault all leave nothing durable, so
store_admittedreturns them asErrand the caller reclaims the record and its capacity before the client reads the error. Only the request that staged the intent may release it. An admission that deduplicated onto an intent a concurrent identical resend staged carriesfresh: false, so the record stays with the upload still storing its bytes.A client that hangs up mid-store drops the request future outright, which leaves no code to run that release, so only a reaper reaches it. Expiring on elapsed time alone would also drop intents a slow home datacenter can still finalize, so the reaper acts on the sweep's evidence instead.
finalize_onerecords a refusal on the one arm that proves no upload can ever finalize the intent here,locatefinding no stored rows, and leaves the transient skips uncounted: a fence rejection and an ACL with no live write token both clear on their own. AfterMAX_INTENT_REFUSALSthe sweep stops offering the intent, andexpire_stale_intentsmay then advance it toExpiredpast its staging deadline, where pruning returns the record and the bytes. One counter serves both ends, keeping an unfinalizable head out of the batch and supplying the evidence expiry requires.flowchart LR Stage[stage intent Pending] --> Store[store_upload] Store -- ok --> Admit[advance to Admitted] Store -- fault or quota block --> Release[release_intent now] Store -- client hangs up --> Sweep{sweep finds stored rows?} Sweep -- yes --> Admit Sweep -- no, 3 passes --> Expire[Expired past the deadline] Expire --> Prune[pruned, capacity returned] Release --> Prune classDef accent fill:#cfe4ff,stroke:#1f6feb,color:#0b1f3a; classDef good fill:#c9e7d4,stroke:#2f855a,color:#0f2f1e; classDef warn fill:#ffe3a3,stroke:#d29200,color:#3a2c00; class Stage,Store accent; class Admit,Prune good; class Sweep,Release,Expire warn;list_pending_intentsnow takes the refusal ceiling its caller wants, so the operator drain walks every pending intent while the sweep skips the ones it has given up on.peryx-storagestays ecosystem-neutral: it counts refusals and enforces the deadline without interpreting either, andperyx-driverowns the two policy constants the sweep and the reaper share. This change owns only thePendinglifetime and leaves the pending-order index rewrite alone.Closes #1526