feat(server,cli): replace the oldest slot when a period is full - #378
Merged
Conversation
A full statement-store period was a dead end: the scan reported `Full` and registration failed with `NoFreeStatementStoreSlot`, even though the runtime allows taking over a slot once it has aged past `Resources.StmtStoreReplacementCooldown` (60s on paseo-next-v2). The scan now carries each occupied slot's `(seq, account_id, since)` instead of discarding two of the three, and `slot::replaceable_slot` picks the oldest slot that is neither the target's own nor still inside the cooldown. Registration replaces that slot and only reports `NoFreeStatementStoreSlot` when nothing is replaceable. The rule is a pure function over a supplied candidate list rather than a scan of its own, so widening the pool later (pooling across membership collections) changes the candidates without touching the rule. Ties on `since` break to the lowest `seq`, so a retry does not oscillate between two slots of equal age. `RegistrationParams` carries `now_seconds`, matching how `current_period` and `next_tick_delay` already take their clock, which keeps the choice deterministic under test. `alloc-check` prints each occupied slot's age, whether it is replaceable, and which slot it would take. Verified against paseo-next-v2: filled all ten slots, watched the diagnostic hold back slots aged 23-59s while offering those past 60s, replaced the oldest (18551s), and confirmed the slot's account and `since` both moved and the next candidate advanced. Exhaustion is covered offline only: filling ten slots takes about 120s at ~12s per block, so a sequential host cannot get every slot inside a 60s cooldown.
Imod7
reviewed
Aug 13, 2026
…ndary `since` is a chain timestamp and the runtime re-checks the replacement cooldown against its own clock when it validates the extrinsic. Ages were computed from the host clock, which on paseo-next-v2 runs 10-18s ahead of `Timestamp.Now` because the chain advances it once per 12s block. A slot whose host age was just past the 60s cooldown could still be inside it from the chain's view. The runtime also requires `now > since + cooldown` rather than `>=`, so a slot at exactly the cooldown is refused. Either mistake offers a slot the chain rejects, and it rejects at submit with `Invalid Transaction (1010)`, which `duplicate_submit_error` does not match, so the registration aborts with no retry and no exhaustion signal. Ages are now read from `Timestamp.Now` at the point of choosing, and the comparison is strict. Reading the clock where it is used also removes the `now_seconds` field that was threaded through `RegistrationParams` and its five call sites.
…ssion `skipped_duplicate_slots` was serving two purposes. It records slots this call already submitted for, so the scan stops offering them, and it was also handed to the replacement rule. If the only free slot was one of those, the scan reported the period full and registration replaced a live slot while an empty one sat there, waiting for a submission that was about to resolve. The scan now separates the two cases: no free slot at all is `Full` and may be replaced into, whereas free-but-excluded is `FreeSlotsExcluded` and returns `SlotError::FreeSlotsAwaitingSubmission` so the caller retries once the earlier submission settles.
The duplicate-submit retry rescans and picks another slot. That was written for free slots, where a second attempt costs nothing. On a full period every attempt is a revocation, and the submission that looked like a duplicate can still land, so a retry could leave two allowances revoked to place one. A registration now performs at most one takeover: once a slot has been replaced, a retry that finds the period full reports `NoFreeStatementStoreSlot` instead of replacing again.
The renewal pass registers targets one after another. On a full period each one replaced the oldest slot, and after enough registrations the oldest slot was one this same pass had just claimed, so the run took allowances back off targets it had only just renewed. With more targets than the period has slots that never settles, and the pass runs again every hour. `RegistrationParams::protected` lists slots the caller has already claimed and must not lose, and the pass accumulates every seq it registers or finds already allocated. Once every replaceable slot belongs to the pass, registration reports slot exhaustion, which is the honest answer when the ledger wants more slots than the period has: it surfaces the capacity problem instead of churning.
The runtime re-checks the replacement cooldown against its own clock and rejects at validation, so a takeover that looked eligible can still be refused: the chain answers `Invalid Transaction (1010)` before the extrinsic reaches a block. `duplicate_submit_error` does not match that, so the registration surfaced a raw RPC failure carrying no indication of what happened or whether retrying helps. A refusal after a takeover now returns `SlotError::ReplacementRefused` naming the period and slot. Nothing was revoked, since the rejection happens before inclusion, so the caller can retry on the next tick.
filvecchiato
approved these changes
Aug 13, 2026
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 full statement-store period is no longer a dead end. The runtime allows taking over a slot once it has aged past
Resources.StmtStoreReplacementCooldown(60s on paseo-next-v2), so registration replaces the oldest slot that is neither the target's own nor still inside that cooldown, and reportsNoFreeStatementStoreSlotonly when nothing is replaceable.The scan carries each occupied slot's
(seq, account_id, since)rather than discarding two of the three.slot::replaceable_slotis a pure function over a supplied candidate list, so widening the pool later — pooling across membership collections — changes the candidates without touching the rule. Ties onsincebreak to the lowestseq, so a retry cannot oscillate between two slots of equal age.RegistrationParamscarriesnow_seconds, matching howcurrent_periodandnext_tick_delayalready take their clock, which keeps the choice deterministic under test.alloc-checkprints each occupied slot's age, whether it is replaceable, and which slot it would take.Part of #334, covering the item "Statement store allowances: when no slot are available - Native replace the oldest one that can be replaced and replace it, rust does not."
Every caller of
register_statement_accountinherits this, so the renewal pass added in #308 now replaces a slot instead of reporting the period exhausted.Verification
Offline: the rule itself (oldest wins, cooldown respected, the target's own slot never taken, excluded slots skipped, ties deterministic, cooldown read from metadata), plus scripted registration for a full table that replaces and a full table inside the cooldown that still fails.
Live against paseo-next-v2: filled all ten slots, watched the diagnostic hold back slots aged 23-59s while offering those past 60s, replaced the oldest at 18551s, and confirmed the slot's account and
sinceboth moved and the next candidate advanced.Exhaustion is covered offline only. Filling ten slots takes about 120s at ~12s per block, so a sequential host cannot get every slot inside a 60s cooldown.