Skip to content

fix: durable commit retries — journal, replay, and /v1/events fallback (v0.5.0) - #89

Merged
amavashev merged 7 commits into
mainfrom
fix/durable-commit-retry
Jul 27, 2026
Merged

fix: durable commit retries — journal, replay, and /v1/events fallback (v0.5.0)#89
amavashev merged 7 commits into
mainfrom
fix/durable-commit-retry

Conversation

@amavashev

@amavashev amavashev commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Problem

Committed spend could vanish. CommitRetryEngine.schedule() held pending commits only in in-memory daemon threads (async: unreferenced asyncio tasks — eligible for GC mid-flight). Any process exit, even a clean one, dropped them. Once the reservation's grace period elapsed (max 60s, default 5s), the server's expiry sweep (expire.lua) returned the reserved budget to the pool — permanently under-counting spend that had already happened.

No crash was required: the default retry schedule totals ~15.5s against a frozen expires_at (the heartbeat stops before commit), so a short server outage produced the same silent loss. And retry_enabled=False dropped failed commits with only a warning.

Fix

Durable journal (runcycles/journal.py) — every commit scheduled for retry is journaled to disk first (file-per-commit, atomic temp+rename, default ~/.runcycles/commit-journal) and deleted only on a terminal outcome. The first engine created per journal directory replays surviving entries for its base_url on the next run. Corrupt files are renamed *.corrupt for operator triage. Journal I/O is best-effort and never breaks the commit path.

POST /v1/events recovery — a commit answered RESERVATION_EXPIRED (budget already returned to the pool) is re-recorded via the spec's post-hoc direct-debit endpoint: spec-conformant EventCreateRequest, commit idempotency key reused (separate server-side namespace → exactly-once across restarts and shared journal dirs), metadata.recovered_reservation_id / recovery_reason markers for reconciliation, no overage_policy (spec default ALLOW_IF_AVAILABLE never rejects). Wired into both lifecycles and both streaming context managers, for first-attempt and retry-time expiry alike. RESERVATION_FINALIZED still treated as settled.

Clean-exit flush — a process-wide atexit hook gives in-flight sync retry threads a bounded window (retry_flush_timeout, default 10s) to finish; anything unfinished stays journaled.

Async engine fixes — task references held until completion (GC hazard); no-event-loop schedules journal instead of dropping.

retry_enabled=False now journals for later replay; the old drop behavior remains only when the journal is also disabled.

Config

journal_enabled (default true), journal_dir (default ~/.runcycles/commit-journal), retry_flush_timeout (default 10.0); env CYCLES_JOURNAL_ENABLED / CYCLES_JOURNAL_DIR / CYCLES_RETRY_FLUSH_TIMEOUT.

Note the default-on behavior change: the SDK now writes journal files, registers an atexit hook, and can call POST /v1/events. journal_enabled=False restores fire-and-forget.

Release

Bumps to v0.5.0 (minor, not patch — new API surface + new default runtime behavior), shipping together with the previously unreleased TENANT_CLOSED / LIMIT_EXCEEDED support already in [Unreleased].

Verification

  • 481 tests pass at 100% coverage (previously 403); ruff and mypy --strict clean
  • New tests/test_journal.py: journal roundtrip/corruption/IO-failure paths, engine durability (journal on schedule, discard on terminal outcomes, retain on exhaustion), expired→event fallback (sync + async), replay (once-per-dir, base_url filter, event-mode entries, deferred async replay), atexit flush, and lifecycle/streaming wiring
  • AUDIT.md, CHANGELOG.md, README.md updated

Review hardening (post-review commit)

  • Per-identity journal partitioning — records live under journal_dir/<sha256(base_url + principal)[:16]>/ (principal = configured tenant when set, else the API key), so co-located clients with different servers or principals never replay each other's records, and one identity's replay claim cannot starve another's.
  • 429 / LIMIT_EXCEEDED is transient everywhere — including the first commit attempt (previously released the reservation); journal entry retained, next attempt waits at least the server's Retry-After.
  • Rotation-safe identity + auth retention — journal identity keyed by tenant when configured (API-key rotation keeps records reachable); 401/403 retains the journal entry instead of discarding it.
  • Private journal files — directories 0700, files 0600 where supported.
  • Process-wide exit-flush deadlineretry_flush_timeout bounds total shutdown wait, not per engine.

Committed spend could vanish: a transiently failing commit lived only in
an in-memory daemon thread (or an unreferenced asyncio task), so any
process exit — even a clean one — dropped it. Once the reservation's
grace period elapsed (max 60s, default 5s), the server's expiry sweep
returned the reserved budget to the pool and the ledger permanently
under-counted spend that had already happened. No crash was required:
the default retry schedule (~15.5s) plus a frozen expires_at meant a
short server outage produced the same silent loss.

Fixes:
- New runcycles/journal.py: file-per-commit CommitJournal (atomic
  temp+rename write, delete on terminal outcome). Every scheduled retry
  is journaled first; the first engine per journal directory replays
  surviving entries for its base_url on the next run. Corrupt files are
  renamed *.corrupt for operator triage.
- Event fallback: a commit answered RESERVATION_EXPIRED (budget already
  back in the pool) is recovered via POST /v1/events — the spec's
  post-hoc direct-debit endpoint — reusing the commit idempotency key
  and tagging metadata with recovered_reservation_id/recovery_reason.
  Wired into both lifecycles and both streaming context managers, for
  first-attempt and retry-time expiry alike.
- atexit flush: sync retry threads get a bounded window
  (retry_flush_timeout, default 10s) to finish on clean exit; whatever
  remains stays journaled.
- AsyncCommitRetryEngine now holds task references until completion
  (previously eligible for GC mid-flight) and journals when no event
  loop is available instead of dropping.
- retry_enabled=False now journals instead of silently dropping (old
  behavior only when the journal is also disabled).

Config: journal_enabled (default true), journal_dir (default
~/.runcycles/commit-journal), retry_flush_timeout; env
CYCLES_JOURNAL_ENABLED / CYCLES_JOURNAL_DIR / CYCLES_RETRY_FLUSH_TIMEOUT.

460 tests pass at 100% coverage; ruff and mypy --strict clean.
AUDIT.md, CHANGELOG.md, README.md updated.
Minor bump (not patch): this release adds new public API surface
(journal_enabled/journal_dir/retry_flush_timeout config fields,
runcycles.journal module, engine flush()/schedule_event()) and new
default runtime behavior (on-disk commit journal, atexit flush,
POST /v1/events recovery), plus the previously unreleased
TENANT_CLOSED/LIMIT_EXCEEDED error-code support.

- pyproject.toml: 0.4.3 -> 0.5.0
- CHANGELOG.md: [Unreleased] -> [0.5.0] - 2026-07-27
- AUDIT.md: header dated entries stamped v0.5.0
Comment thread tests/test_journal.py
engine.flush(timeout=5.0) # clean up before the test ends

def test_atexit_hook_flushes_registered_engines(self, tmp_path: Path) -> None:
import runcycles.retry as retry_mod
Comment thread runcycles/journal.py
logger.warning("Skipping corrupt journal entry: %s", path, exc_info=True)
try:
path.replace(path.with_suffix(".corrupt"))
except OSError:
Comment thread runcycles/retry.py
_live_engines.add(engine)
if not _atexit_registered:
atexit.register(_flush_all_engines)
_atexit_registered = True
…, flush deadline

Addresses all four review findings:

1. [P1] Replay isolated by credentials: journal records now live in a
   per-identity subdirectory keyed by a non-secret truncated SHA-256
   fingerprint of (base_url, api_key). Co-located clients with different
   servers or API keys can no longer replay each other's records — a
   foreign record previously got 401/403 and was discarded as terminal,
   permanently losing the spend.

2. [P1] HTTP 429 / LIMIT_EXCEEDED is transient, not terminal: both the
   commit and event classifiers now detect rate limiting (status 429 or
   error code), retain the journal entry, keep retrying, and make the
   next attempt wait at least the server's Retry-After (consumed once,
   max'd against the normal backoff). Consistent with
   ErrorCode.is_retryable, which already classified LIMIT_EXCEEDED
   retryable.

3. [P2] Replay claim scoped to the identity subdirectory: because the
   claim now covers exactly one (server, credential) identity, an engine
   for server A can never block server B's entries from replaying out of
   a shared journal_dir.

4. [P2] Process-wide flush deadline: _flush_all_engines() computes one
   deadline from the max engine timeout and passes each engine only the
   remaining budget, so worst-case shutdown is retry_flush_timeout, not
   engine_count x retry_flush_timeout.

No journal-layout migration needed — v0.5.0 is unreleased.

469 tests pass at 100% coverage; ruff and mypy --strict clean.
AUDIT.md, CHANGELOG.md, README.md updated.
@amavashev

Copy link
Copy Markdown
Contributor Author

All four findings addressed in 17799c1 — thanks, all confirmed valid:

  1. [P1] Replay not isolated by tenant/credentials — journal records are now partitioned into per-identity subdirectories: journal_dir/<fingerprint>/ where the fingerprint is a truncated SHA-256 of (base_url, api_key) (non-secret, non-reversible; see journal.auth_fingerprint). An engine only ever sees its own identity's records, so a foreign record can no longer be replayed and 401-discarded. Regression test: test_replay_isolated_by_api_key (two tenants, shared dir — each engine replays exactly its own record).

  2. [P1] 429 deletes the durable record — both classifiers now detect rate limiting (status 429 or LIMIT_EXCEEDED code) before the client-error-terminal branch: the journal entry is retained, the retry continues, and the next attempt waits max(backoff, Retry-After) (header consumed once). This also makes the engine consistent with ErrorCode.is_retryable, which already classified LIMIT_EXCEEDED retryable. Tests: TestRateLimitedRetry (4 cases incl. bodyless 429 and the event-fallback path).

  3. [P2] Cross-server replay starvation — falls out of fix 1: the replay claim is now scoped to the identity subdirectory, so server A's engine claims only A's records and can never block B's. Test: test_one_server_claim_does_not_block_another.

  4. [P2] Exit timeout multiplied by engine count_flush_all_engines() now computes one deadline from the max engine timeout and hands each engine only the remaining budget, so total shutdown wait is bounded by retry_flush_timeout regardless of engine count. Test: test_flush_all_engines_shares_one_deadline (two engines with stuck retries, asserts single-budget elapsed time).

No journal-layout migration needed since v0.5.0 is unreleased. 469 tests at 100% coverage; ruff + mypy --strict clean. Docs (README/CHANGELOG/AUDIT) updated to describe the identity partitioning, 429 semantics, and process-wide flush bound.

Comment thread tests/test_journal.py
def test_flush_all_engines_with_no_engines(self) -> None:
import weakref

import runcycles.retry as retry_mod
Comment thread tests/test_journal.py
# whole process, not per engine.
import weakref

import runcycles.retry as retry_mod
Comment thread runcycles/journal.py Fixed
… identity, journal permissions

1. [P1] Rate-limited first commit no longer releases the reservation.
   All four lifecycle variants (sync/async lifecycle, sync/async
   streaming) previously routed a first-attempt 429/LIMIT_EXCEEDED into
   the generic client-error branch, which released the reservation —
   actively returning reserved budget for spend that already happened.
   They now detect rate limiting before that branch and schedule the
   commit for retry, passing the response's Retry-After into the engine
   via a new schedule(..., retry_after_ms=) parameter that seeds the
   first backoff.

2. [P1] Auth failures retained + rotation-safe journal identity.
   401/403 on a retried commit or event fallback is now terminal for the
   current run but retains the journal entry (previously discarded — the
   only durable record of the spend was destroyed by a misconfigured or
   mid-rotation key). The identity fingerprint now uses the configured
   tenant as the principal when set — stable across API-key rotation,
   and any same-tenant credential can settle the records — falling back
   to the API key when no tenant is configured (documented, with manual
   file-move migration since replay is idempotent). The two fixes
   compose: with 401/403 retained, any residual identity mispartition
   is noise, not loss.

3. [P2] Journal directories are created 0700 and record files 0600,
   best-effort (no-op semantics on platforms without POSIX modes; a
   chmod failure never blocks the write). Records carry subjects,
   spend amounts, metrics, and arbitrary commit metadata.

481 tests pass at 100% coverage; ruff and mypy --strict clean.
AUDIT.md, CHANGELOG.md, README.md updated.
@amavashev

Copy link
Copy Markdown
Contributor Author

Round-2 findings addressed in 0e9e0e7 — all three confirmed valid:

  1. [P1] First-attempt 429 released the reservation — all four _handle_commit variants now check status == 429 or error_code == "LIMIT_EXCEEDED" before the generic client-error branch, and schedule the commit for retry instead of releasing (the release was actively returning reserved budget for spend that had already happened). The response's Retry-After is passed into the engine via a new schedule(..., retry_after_ms=) parameter that seeds the first backoff delay. Tests: test_rate_limited_first_commit_schedules_retry_not_release × 4 (sync/async lifecycle, sync/async streaming), each asserting schedule called with retry_after_ms=3000 and release_reservation never called, plus test_schedule_seeds_retry_after on both engines.

  2. [P1] Auth failures + key rotation — two composing fixes:

    • 401/403 on a retried commit or event fallback is terminal for the current run but retains the journal entry (loud error log pointing at credentials). Tests: TestAuthFailureRetention::test_401_commit_retains_journal, test_403_event_fallback_retains_journal.
    • The identity fingerprint now uses the configured tenant as principal when set — stable across API-key rotation, and any same-tenant credential can settle the records — falling back to the API key when no tenant is configured (README documents the manual file-move migration for that case; replay is idempotent so moving records is safe). Tests: test_auth_fingerprint_tenant_is_rotation_safe, test_replay_survives_api_key_rotation_with_tenant (record written under old key, replayed by rotated key).
    • Together these also make any residual mispartition (e.g. two different tenants both configured tenant="acme") non-destructive: the foreign record fails with 403 and stays for its rightful owner.
  3. [P2] Journal permissions — identity directories are created 0700 and record files 0600 via best-effort chmod (no-op semantics on Windows; failure never blocks the write). Tests: test_journal_files_are_private (POSIX mode assertions), test_permission_tightening_failure_is_swallowed.

481 tests at 100% coverage; ruff + mypy --strict clean. README/CHANGELOG/AUDIT updated.

Comment thread runcycles/journal.py Fixed
Comment thread runcycles/journal.py Fixed
…a-hashing)

CodeQL flagged auth_fingerprint hashing the API key with bare SHA-256.
For a high-entropy key that is not exploitable, but the no-tenant
fallback can embed a user-chosen (potentially weak) key, and the
directory name is world-visible metadata. The fingerprint is now
PBKDF2-HMAC-SHA256 (600k iterations, fixed salt derived from base_url
for cross-process determinism), truncated to 16 hex chars as before.
An lru_cache pays the KDF cost once per identity per process.

Fingerprint values change; no migration concern — v0.5.0 is unreleased.

481 tests pass at 100% coverage; ruff and mypy --strict clean.
…sisted Retry-After, KDF cost, unique temp files

1. [P1] First-attempt 401/403 no longer releases. All four lifecycle
   variants now handle authentication failures like first-attempt 429:
   journal the commit via the retry engine (whose auth-retention path
   keeps the record) and never release — releasing returned budget for
   spend that already happened, or dropped the commit unjournaled when
   the release also failed.

2. [P2] Retry-After survives restarts. The floor is persisted in the
   journal record as an absolute not_before_ms (set at schedule time and
   re-journaled whenever a retry sees a new 429 with Retry-After) and
   restored as a relative delay on replay; a floor already in the past
   falls back to normal backoff.

3. [P2] PBKDF2 rounds reduced 600k -> 30k (~20 ms cold vs ~0.36 s) and
   the identity cache grown to 256. The principal is normally a
   high-entropy machine credential, so rounds only defend the weak-key
   fallback; password-storage round counts stalled engine setup and
   blocked async callers. PBKDF2 is kept (rather than a fast keyed HMAC)
   because CodeQL's py/weak-sensitive-data-hashing distinguishes
   computationally expensive algorithms and a fast HMAC risks
   re-triggering the alert cleared in 462d668.

4. [P2] Journal temp files use unique per-writer names
   (<name>.<pid>.<random>.tmp) so concurrent processes settling the same
   reservation cannot truncate each other's temp file or atomically
   publish partial JSON that the corrupt-file handler would quarantine.
   Failed publishes clean up their temp file; stale temp files from
   crashed writers are invisible to replay (*.json glob).

491 tests pass at 100% coverage; ruff and mypy --strict clean.
AUDIT.md, CHANGELOG.md, README.md updated.
@amavashev

Copy link
Copy Markdown
Contributor Author

Round-3 findings addressed in 5afef2c — all four confirmed valid; one implemented with a different mechanism than suggested (finding 3, rationale below):

  1. [P1] First-attempt 401/403 — all four _handle_commit variants now have an auth branch (after the 429 branch, before the generic client-error release): journal via schedule() — whose engine-side auth-retention path keeps the record — and never release. Tests: test_auth_failure_first_commit_journals_not_release × 4 (sync/async lifecycle, sync/async streaming), each asserting schedule called and release_reservation never called.

  2. [P2] Retry-After persistedPendingCommitRecord gains not_before_ms (absolute wall clock). It's written whenever a pending commit with a Retry-After floor is journaled — including at first-attempt schedule time — and re-journaled when a retry sees a fresh 429 with a header. Replay converts it back to a relative floor; a floor in the past falls back to normal backoff. Tests: test_retry_after_floor_is_persisted, test_replay_restores_future_retry_after_floor, test_replay_ignores_past_retry_after_floor, plus the not-before assertion added to the 429 classifier test.

  3. [P2] KDF cost — rounds reduced 600k → 30k (~20 ms cold, measured proportionally from your 0.36 s figure) and the identity cache grown to 256. I kept PBKDF2 rather than switching to a fast keyed HMAC deliberately: CodeQL's py/weak-sensitive-data-hashing distinguishes computationally expensive algorithms, and HMAC-SHA256 is not in that class — the sink would still be a fast hash of data tainted as a password, risking re-triggering the alert cleared in 462d668. The principal is normally a high-entropy machine credential, so rounds only defend the weak-key fallback; 30k keeps that defense meaningful without stalling engine setup or the event loop. Happy to switch to HMAC if you'd rather re-litigate the CodeQL alert with a dismissal.

  4. [P2] Unique temp files — writers now use <name>.<pid>.<random8>.tmp in the same directory before the atomic replace, so concurrent processes settling the same reservation can't truncate each other's temp or publish partial JSON for quarantine. Failed publishes unlink their temp; stale temps from crashed writers are invisible to replay (*.json glob) and never quarantined. Tests: test_failed_publish_cleans_up_temp_file, test_failed_publish_and_cleanup_never_raise, test_stale_temp_files_are_ignored.

491 tests at 100% coverage; ruff + mypy --strict clean. README/CHANGELOG/AUDIT updated.

Comment thread runcycles/journal.py
except OSError:
try:
tmp.unlink(missing_ok=True)
except OSError:
…4xx, delay clamps

From the five-way adversarial self-review of the durability rollout:

- Filename sanitization is ASCII-explicit (was Unicode-aware via
  str.isalnum), matching TS/Java: sibling SDKs sharing a tenant identity
  directory must compute identical filenames or a record they settle can
  never be discarded and replays forever (cross-SDK P1).
- The two cross-SDK PBKDF2 fingerprint vectors are pinned in this suite
  (the reference SDK previously asserted only stability, so a derivation
  drift would pass here and break TS/Java interop).
- Whitespace-only tenant falls back to the key principal (matches Java's
  isBlank; previously landed in a different identity dir than Java).
- Honored Retry-After and restored journal floors clamped to 1 hour.
- HTTP 410 triggers the expired/event-fallback path by status, so a
  proxy-mangled body cannot route an expired commit into release/discard.
- Unclassifiable 4xx (codeless or forward-compat unknown code) is no
  longer a genuine rejection anywhere: engine retains the journal entry;
  all four lifecycle wirings journal instead of releasing.
- Base journal directory also permission-tightened; stale temp files
  from crashed writers reaped after 1 hour.

506 tests pass at 100% coverage; ruff and mypy --strict clean.
Comment thread runcycles/journal.py
"""
try:
path.chmod(mode)
except OSError:
Comment thread runcycles/journal.py
try:
if tmp.stat().st_mtime < cutoff:
tmp.unlink(missing_ok=True)
except OSError:
@amavashev

Copy link
Copy Markdown
Contributor Author

Fleet-wide adversarial self-review (5 reviewers: one per PR + cross-SDK consistency) — fixes landed in f4c7d6e:

  • Cross-SDK P1: filename sanitization was Unicode-aware here but ASCII-only in TS/Java; a sibling SDK settling a record from a shared tenant identity dir could never discard it → infinite replay. Now ASCII-explicit, and the two cross-SDK PBKDF2 vectors are pinned in this suite (previously only TS/Java pinned them — a reference-derivation drift would have passed CI here and broken both).
  • Unclassifiable 4xx (codeless proxy pages, forward-compat unknown codes) is no longer a genuine rejection: engine retains the journal entry; all four lifecycle wirings journal instead of releasing. HTTP 410 now triggers the expired/event-fallback path by status, so a mangled body can't route an expired commit into release/discard.
  • Honored Retry-After and restored floors clamped to 1h; whitespace-only tenant normalized to the key principal (Java parity); base journal dir permission-tightened; stale temps reaped after 1h.

506 tests at 100% coverage; ruff + mypy strict clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants