Skip to content

feat(token-capture): retire and delete complete-record captures - #3939

Merged
ananthsub merged 12 commits into
mainfrom
ananthsub/token-capture-store-retire
Oct 10, 2026
Merged

ananthsub merged 12 commits into
mainfrom
ananthsub/token-capture-store-retire

Conversation

@ananthsub

@ananthsub ananthsub commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Branch ananthsub/token-capture-store-retire, on #3938 in the partial-rollout checkpoint stack, so this diff shows only the complete-record store change. It doesn't need #3938: the same commit applied to main alone is branch ananthsub/token-capture-store-retire-independent, which also carries identical copies of the two helpers #3938 adds (validate_rollout_ids and RolloutRemoval), and passes the unit suite on its own. Related and independent: #3937 (striped locks; a small conflict around the removed sweeps) and #4268 (bounded ledger cache).

What changed and why

The complete-record token store (TokenCaptureStore, behind TokenSource) could retire a rollout only through the conditional TokenSource.drop, which needs the consumed snapshot. That leaves three gaps:

  • Masked and failed captures are never retired: rollout_collection keeps them for diagnosis, and a trainer had no operation to remove them later.
  • Abandoned attempts have no snapshot, so drop can't retire them at all.
  • Fences (the retired state drop leaves behind, which the store's docs called a tombstone) were only removable by sweep_retired, an age-based sweep that nothing called, which is a timer in disguise.
  • Abandoned captures were only removable by sweep_stale (feat(token-capture): sweep abandoned captures safely #3969), the same kind of age-based sweep. It guesses that a capture is abandoned once all its files are older than a cutoff, which the caller must keep above the longest possible session. Nothing in Gym called it either.

This PR gives the store the same two batched operations #3938 gives the capture ledger, with the same names and meaning:

Operation Capture ledger (#3938) Complete-record token store (this PR)
Delete: remove the data and the fence CaptureLedger.delete(rollout_ids) TokenSource.delete(rollout_ids)
Retire: remove the data, keep a fence CaptureLedger.retire(rollout_ids) TokenSource.retire(rollout_ids): drop without the snapshot check; keeps the retired fence
  • TokenCaptureStore.retire and delete are async like the rest of TokenSource, with retire_now and delete_now as synchronous forms, following freeze and freeze_now.
  • drop and retire share one retire step, so both leave the same fence, which refuses later writes, begin_call, mark_incomplete, and freeze.
  • A retired rollout refuses them with TokenCaptureRetiredError, a new subclass of TokenCaptureFrozenError, instead of a plain RuntimeError. The sink already drops a late write to a frozen rollout quietly. Before this, a late call of an abandoned attempt, which is exactly what retire is for, was logged with a traceback, counted as a capture failure, and the sink then tried to mark the retired rollout incomplete. Now it is dropped like a late write after a freeze. A late call that failed to build, produced no token ids, or arrived under a mismatched identity is dropped quietly the same way: the sink tries to mark the rollout first, and when the rollout turns out to be frozen or retired it skips its warning and the capture-failure counter.
  • Fewer fsyncs. retire syncs the directory once per batch instead of once per rollout, and drop syncs it once instead of twice: the directory fsync after the state file is replaced already covers the deletions before it. Retiring takes one file fsync per rollout instead of three. delete syncs every non-empty batch, even when it finds nothing to remove, for the same reason as the capture ledger's delete: an overlapping delete may not have synced yet, and a reused rollout ID must not see the old fence or records come back after a crash.
  • Retire works in two synced phases. retire_now and drop first write and sync the fences, then remove the records and sync again. A rollout is therefore never left without records and without a fence, and an overlapping retire of the same rollout cannot return before its fence is durable: the first sync also runs when only records are left to remove. A retry removes records left behind by a retire that failed part way or by a crash. A batch that fails part way still syncs what it changed; a failing sync there is logged, not raised, so it never hides the original error. Every non-empty batch syncs its fences before returning, even when it wrote none, so an overlapping retire can never report success before the fence is durable. The second phase re-checks each fence under the rollout's lock, so a rollout ID that a delete cleared and a new attempt reused between the phases keeps its new records.
  • The store's docstrings and the external agent harness page now call that retired state a fence, the capture ledger's word for it, instead of a tombstone.
  • delete is now batched; it was single-ID and synchronous. Its one caller, clear_token_captures_for_rollouts, now makes one batched call.
  • sweep_retired and sweep_stale are removed. A capture's end of life is an explicit call from its owner, not its age: retire when the training framework is done with a rollout, including an abandoned attempt, and delete once nothing can still write.
  • Both operations validate the whole batch first, reject a bare string, and report removed and absent like the ledger's, typed as the same RolloutRemovalPayload TypedDict.
  • The conformance suite checks both operations, so an external TokenSource has to implement them. Its retirement check requires a late put, mark_incomplete, and begin_call to fail with TokenCaptureRetiredError, as TokenSource.retire documents, rather than accepting any exception, and lets any other freeze error fail the check.
  • rollout_collection behavior is unchanged: it still keeps masked and failed captures. Callers can now remove them.
  • Docs: the external agent harness page describes when to retire and delete.

How it works

flowchart TD
  A["Absent<br/>no records, no fence"] -- "first put, begin_call, or mark_incomplete" --> L["Live<br/>records on disk"]
  L -- "freeze" --> FZ["Frozen<br/>snapshot taken"]
  FZ -- "drop (exact snapshot)" --> R["Retired<br/>records gone, fence kept"]
  L -- "retire" --> R
  FZ -- "retire" --> R
  A -- "retire" --> R
  R -- "late put, begin_call, or mark_incomplete<br/>(TokenCaptureRetiredError)" --> R
  R -- "delete" --> A
  L -- "delete" --> A
  FZ -- "delete" --> A
Loading

drop stays the way to retire a consumed snapshot, because it refuses when the rollout changed after the snapshot. retire is for everything else the consumer is done with; delete removes fences once nothing can still write, and resets an ID before reuse.

What the caller must do

Situation Call
Built and durably delivered retire_rollout_token_capture (unchanged: conditional drop)
Masked or failed, evidence no longer needed retire([ids])
Abandoned attempt retire([ids]); its agent may still be running, and the fence makes its later writes fail
Retired attempts once nothing of them can still write (end of run, or after restarting every Gym server) delete([ids]) to remove the fences
Reusing a rollout ID delete([ids]) first (rollout_collection already does this before re-dispatch)

Always pass a list; both operations are idempotent, so a failed batch can be retried as is.

Compatibility

  • TokenSource gains retire and delete. Gym never checks a TokenSource against the protocol at runtime, but the conformance suite now requires both, so an external source must add them to keep passing.
  • TokenCaptureStore.delete(rollout_id) was synchronous and single-ID; it is now async delete(rollout_ids), with delete_now(rollout_ids) as the synchronous form. A caller still writing store.delete("id") gets an un-awaited coroutine and deletes nothing, so update such calls to delete_now(["id"]). Gym and NeMo RL have no such callers outside the one updated here.
  • Writes, begin_call, mark_incomplete, and freeze on a retired rollout raise TokenCaptureRetiredError. It subclasses TokenCaptureFrozenError and so RuntimeError, so code that caught either still catches it.
  • TokenCaptureStore.sweep_retired and TokenCaptureStore.sweep_stale are removed. Nothing in Gym or NeMo RL called either. A caller that swept abandoned captures by age now retires the rollouts it abandoned.

Issue

No tracking issue. This aligns the complete-record store's retirement with the capture ledger's and closes its gaps for masked, failed, and abandoned captures and for fences.

Validation

  • New tests in tests/unit_tests/test_token_id_capture.py cover retiring live, frozen, and never-captured rollouts (records removed; later writes, begin_call, mark_incomplete, and freeze all raise TokenCaptureRetiredError; a repeat is a no-op), a late call after retire dropped by the sink without a capture failure or an incomplete marker, one directory fsync per retire batch and per drop, delete removing the fence so the ID can be reused, batch validation with bare-string rejection, every non-empty delete batch syncing, files removed before a later removal failed still being synced, a batch failing part way still syncing what it changed, a retire failing part way leaving the rollout fenced and a retry finishing it, an overlapping retire returning only after the fence is durable, a sync failure after an error not hiding that error, two directory syncs per retire batch and per drop, a retire that cannot write its fence changing nothing, and late calls without token ids or with a failed build not reported as failures. Existing tests that used sweep_retired or single-ID delete now use delete_now, and the sweep_stale tests are removed with it.
  • New conformance checks unconditional_retirement and delete_clears_retirement pass for the file store and the in-memory reference backend, and reject a backend that lets a late mark_incomplete through after retire, rejects late writes with a plain or merely frozen error, or whose freeze fails after retire.
  • The new tests fail when the retire step in retire_now, the fence removal in delete_now, the retired error type, or the per-batch fsync is reverted.
  • Measured with the token-capture metrics from feat(token-capture): low-overhead metrics for capture operations, outcomes, digests, and caches #4218: 50 late calls to retired rollouts were counted as 50 capture_failed{write} and 50 marked_incomplete before the error-type change, and as 50 late_write_dropped after it. Retiring a rollout takes about 330 µs on a local disk, down from about 420 µs, and its system calls drop from three fsyncs to one.
  • The combined tree (main with this PR, fix(token-capture): use striped lock files that are never deleted #3937, feat(token-capture): retire and delete external-staging capture ledgers #3938, feat(token-capture): low-overhead metrics for capture operations, outcomes, digests, and caches #4218, and fix(token-capture): bound the capture-ledger cache #4268): pytest tests/unit_tests passed.
  • pytest on every token-capture, staging, trajectory-builder, rollout-collection, and model-server unit test file: passed.
  • pytest responses_api_models/vllm_model/tests/test_token_capture_integration.py responses_api_models/vllm_model/tests/test_external_capture_streaming.py: passed.
  • pre-commit run --files <changed files>: all hooks passed.

Rollout evidence

N/A: the delivery path still retires through the same drop, and the new operations run only when a caller invokes them, so rollouts behave as before.

@copy-pr-bot

copy-pr-bot Bot commented Oct 2, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@pthombre
pthombre added this pull request to stack #3962 October 2, 2026 22:50
@ananthsub
ananthsub force-pushed the ananthsub/token-capture-store-retire branch from 079db1a to 673025f Compare October 6, 2026 19:28
@ananthsub
ananthsub force-pushed the ananthsub/token-capture-store-retire branch from 673025f to de83018 Compare October 6, 2026 20:54
@copy-pr-bot

copy-pr-bot Bot commented Oct 6, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@ananthsub
ananthsub force-pushed the ananthsub/token-capture-store-retire branch from de83018 to 6faa172 Compare October 6, 2026 22:42
@ananthsub
ananthsub force-pushed the ananthsub/token-capture-store-retire branch from 6faa172 to d755c67 Compare October 8, 2026 15:08
@ananthsub
ananthsub force-pushed the ananthsub/token-capture-store-retire branch from d755c67 to 61fd7cd Compare October 8, 2026 21:30
@ananthsub
ananthsub force-pushed the ananthsub/token-capture-store-retire branch from 61fd7cd to bd354a1 Compare October 8, 2026 21:32

@macandro96 macandro96 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both cases below are race conditions for very unlikely cases but fixes seem cheap

Comment thread nemo_gym/token_id_capture/store.py Resolved
Comment thread nemo_gym/token_id_capture/store.py Resolved
With external staging the model server keeps a capture ledger per rollout
and never removes it, because only the training framework knows when a
rollout's manifest is no longer needed.

Give the ledger the two operations the complete-record token store already
has. CaptureLedger.retire removes a rollout's ledger and leaves an empty
<id>.lineage.retired fence, like the fence TokenSource.drop leaves: the framework retires every rollout it is done with, finished or
abandoned, and later calls for it are discarded instead of starting a new
ledger. That covers an abandoned agent that keeps running and a client
retry that ran a finished rollout twice. CaptureLedger.delete removes the
ledger and the fence, like TokenCaptureStore.delete: the framework deletes
retired rollouts once nothing of them can still write, and deletes a
rollout ID before reusing it.

Both are batched, validate the whole batch first, reject a bare string, and
are safe to retry, behind bearer-protected POST .../rollouts/retire and
.../rollouts/delete routes with matching RolloutControlClient methods. Writes
look for the fence only when they would create the ledger, so a captured
call costs no extra metadata lookup. Staged token data is untouched; the
framework that staged it removes it.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
…yncs

RolloutControlClient now validates and deduplicates the whole list of
rollout IDs before sending the first batch. Before, a bare string was
split into one-character IDs, an invalid ID in a later batch failed only
after earlier batches had been retired, and an ID repeated across batches
was reported both removed and absent.

Retire no longer touches fences that already exist, and retire and delete
sync the directory only when they changed something. A retire with a
ledger left to delete still syncs first, so a fence left by a crashed
earlier retire is durable before its ledger goes.

The manifest of a retired rollout now raises RolloutRetiredError, which
the manifest route returns as HTTP 410, instead of an empty manifest that
reads like a rollout with no calls. The warning for a discarded late row
names the call's staging key, since its staged tokens are in no manifest.

retire and delete declare their result as the RolloutRemovalPayload
TypedDict instead of dict.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
…client's manifest errors

A call that started after its rollout was retired was admitted as a new
text root: the retired rollout had no ledger rows, so admission saw a
fresh rollout. The worker then staged the call's tokens and the ledger
discarded its row, leaving staged tokens that no manifest names. The
same happened to the first call of a duplicate run, which resolves as a
root. has_rows now raises RolloutRetiredError for a retired rollout, and
admission skips capture for it.

RolloutControlClient.manifest now validates the rollout ID before
building the URL (dot segments are normalized, so "../rollouts/r1"
fetched r1's manifest), raises RolloutRetiredError for HTTP 410 instead
of a plain RuntimeError, and rejects a manifest for a different rollout.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
Retire and delete removed cached ledger rows with a direct dictionary
pop. A cache that also tracks its size, as the bounded ledger cache in
#4268 does, would then keep counting the removed rows and
gradually evict everything else. Removals now go through
_ledger_cache_pop, the helper that cache defines, and a test checks that
removing ledgers leaves no tracked size behind.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
A delete that found nothing to remove skipped the directory sync. When it
overlapped another delete of the same rollout that had removed the files
but not yet synced, it returned success while the removal was not
durable, so a crash could bring back the old fence or ledger after the
caller had started reusing the rollout ID. Every non-empty delete batch
now syncs; a retire with nothing left to remove still syncs nothing,
because its fences are already durable.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
…edger

A retire that found every fence already in place and no ledger to
delete returned without syncing. If an overlapping retire had created
those fences without syncing yet, a crash could lose them after this
call had reported success, and a late write could start a new ledger.
Every non-empty retire batch now syncs its fences before removing
anything and before returning; a retried batch still writes nothing.

A retire removes ledgers after releasing and retaking each rollout's
lock. In between, a delete could clear the fence and a reused rollout
ID record a new ledger, which the retire then deleted. Removal now
re-checks the fence under the lock and leaves an unfenced rollout's
ledger alone.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
The complete-record token store could only retire a consumed snapshot
through the conditional TokenSource.drop. Masked, failed, and abandoned
captures had no removal path, and fences were only removable by
sweep_retired, an age-based sweep that nothing called.

Add two batched operations, the same ones the external-staging capture
ledger gets. TokenSource.retire removes records and keeps the fence
whatever the rollout's state (drop without the snapshot check): for masked
or failed captures once their evidence is no longer needed, and for
abandoned attempts. The fence makes later writes fail, including from a
duplicate execution of the same rollout. TokenSource.delete removes records
and fences: for purging once nothing can still write, and for resetting a
rollout ID before reuse. TokenCaptureStore implements both, with
retire_now and delete_now as synchronous forms, and drop shares the retire
step. delete is now batched (it was single-ID and synchronous). The
conformance suite checks both operations.

Writes to a retired rollout fail with TokenCaptureRetiredError, a
TokenCaptureFrozenError, instead of a plain RuntimeError. The sink already
drops a late write to a frozen rollout quietly; before this, a late call of
an abandoned attempt was logged and counted as a capture failure and the
sink tried to mark the retired rollout incomplete.

retire fsyncs the directory once per batch, and drop once instead of
twice: the directory fsync after the state replace already covers the
unlinks before it.

The age-based sweeps are removed: sweep_retired, and sweep_stale, which
reclaimed unretired captures once every file of them was older than a
cutoff. Nothing in Gym called either. A capture's end of life is the
training framework's explicit retire, for every attempt it finishes or
abandons, including one whose request was cancelled mid-call, and delete
once nothing can still write. A time cutoff can remove a capture that is
still in use by a long session, and leaves the rest until the cutoff.

rollout_collection still keeps masked and failed captures; callers can now
remove them.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
TokenCaptureStore.delete_now synced the capture directory for every
batch, even when none of the rollouts had files left, so a retried or
empty delete still paid a directory fsync. It now syncs only when it
removed a file, matching the capture ledger's delete.

TokenSource.retire and delete, and the store's implementations, declare
their result as the RolloutRemovalPayload TypedDict the capture ledger
uses, instead of dict.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
…ches

A retire that failed after removing a rollout's records but before
writing its fence left the state file pointing past records that no
longer existed, so every later freeze or write failed with a corrupt
index error. The fence is now written first; a retire that then fails
leaves the rollout fenced, and a retry finishes removing its records.

retire_now and delete_now sync the directory once per batch, but an
error part way through skipped that sync, and a retry found nothing left
to change and returned without syncing. Both now sync what they changed
before the error propagates.

A late call of an abandoned attempt that produced no token ids still
tried to mark the retired rollout incomplete and logged the refusal with
a traceback. The sink now treats that refusal like a late write.

The conformance check for retirement now requires late writes,
mark_incomplete, and begin_call to fail with TokenCaptureFrozenError,
the error the sink depends on, instead of accepting any exception. The
in-memory reference backend in the tests is updated to match.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
…uiet

Two retires of one rollout could overlap so that the second returned
before the fence was durable: the first had written the fence and removed
the records, the second then found nothing to change and returned without
syncing, and a crash before the first's batch sync undid the fence.
retire_now and drop now work in two phases, each ending in one directory
sync: write the fences, then remove the records. The first sync also runs
when only records are left to remove, because their fences may not be
synced yet. A sync after a failed batch is logged instead of raised, so
it never hides the error that failed the batch.

A late call of a frozen or retired rollout that failed to build, produced
no token ids, or arrived under a mismatched identity still counted a
capture failure or logged a warning before finding out the rollout was
gone. The sink now tries the mark first and stays quiet when the call is
late.

The conformance retirement check requires TokenCaptureRetiredError, as
TokenSource.retire documents, instead of any TokenCaptureFrozenError.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
…ovals

delete_now skipped its directory sync when it found nothing to remove.
Overlapping another delete of the same rollout that had removed the
files but not yet synced, it returned success while the removal was not
durable, so a crash could bring back the old fence or records after the
caller had started reusing the rollout ID. Every non-empty delete batch
now syncs.

_remove_record_files reported what it removed only when it returned, so
when a later unlink failed, retire_now and delete_now did not know that
earlier files were already gone and skipped the error-path sync. Both
now treat a removal step as changing the directory before it starts.

The conformance retirement check passed one string to ConformanceError,
which takes a check name and a detail, so it raised TypeError and was
reported as an unexpected error instead of naming the late write. It
now passes both, and tests check the reported message.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
…cords

A retire that found its rollouts already retired, with nothing left to
remove, returned without syncing. If an overlapping retire had written
those fences without syncing yet, a crash could lose them after this
call had reported success, and a late write could land unfenced. Every
non-empty retire batch now syncs its fences; a retried batch still
writes nothing.

retire_now removes records in a second phase, after releasing and
retaking each rollout's lock. In between, delete_now could clear the
fence and a reused rollout ID write new records, which the retire then
removed, leaving the new attempt's state pointing past missing records.
The second phase now re-checks the fence under the lock and leaves an
unfenced rollout's records alone. drop holds the lock across both
phases, so it was not affected.

Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔄 Merge queue validation started! You can track the progress here: https://github.com/NVIDIA-NeMo/Gym/actions/runs/38026783272

This branch was previously deployed

1 inactive deployment
public — 7615cb2d Deployed Oct 10, 2026 by copy-pr-bot[bot] via release / finalize / notify #4954
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core Shared APIs, servers, telemetry, health, and registries complexity:high Cross-component or shared-contract change with a large review surface feature New capabilities, enhancements, or enablement work needs-review PR is ready for code review and waiting on a reviewer sla:review-overdue Review response is over the one-business-day SLA

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants