Skip to content

fix(core): stream the resumable-upload resume instead of buffering the whole file - #279

Merged
pmaxhogan merged 5 commits into
mainfrom
fix/streaming-resume-oom
Aug 14, 2026
Merged

fix(core): stream the resumable-upload resume instead of buffering the whole file#279
pmaxhogan merged 5 commits into
mainfrom
fix/streaming-resume-oom

Conversation

@pmaxhogan

@pmaxhogan pmaxhogan commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Incident (2026-08-14)

Quitting Driven mid-upload of an 88.6 GB file (dev-drives/dev.vhdx) and relaunching made the app consume >10 GB of RAM in ~30 s (at disk read speed) until Windows killed it - and it repeated on every relaunch. Diagnostics zip + the laptop's pending_ops row confirmed the mechanism:

  • The startup reconcile (runs before any scan, so the UI still shows the indeterminate state) found the persisted resumable session (size=88655003648, acked_offset=7436500992), passed the resume-identity gate, and called read_hash_encrypt - which buffers the entire upload body into one Vec. The live upload path has streamed with bounded channels since DESIGN s11.4.3; only the restart-resume path (and the adopt re-hash) still used the legacy buffered read.
  • The OOM kill happens before delete_pending_op, so the op survives and every launch retries the same 88 GB read.
  • The whole fatal phase logged nothing at INFO - the diagnostics bundle was blind to it.

Fix

  • resume_persisted now streams via a pass-based design: one sequential read that hashes every byte, discards the already-acked prefix, and pushes the tail in bounded wire chunks (~2 in flight, ResumeAcc RAII-guarded MemGauge accounting that stays balanced even on ? error unwinds). The pacer is charged only for pushed wire bytes.
  • Backend offset semantics are interpreted against the streaming window (offset always equals the file position of the accumulator's first byte): driven-s3's one-shot post-hydration rewind-to-0 and sftp/localfs resyncs RESTART the pass from the store's stated offset (one-restart budget); in-window forward offsets drain exactly the newly covered bytes; an exact stall abandons. Without this, every S3 resume with prior progress degraded to a full re-upload.
  • Legacy rows (recorded hash, no identity) are verified by a hash-only prepass before any byte is pushed; identity-carrying rows get a cheap EOF re-fstat that aborts a mid-stream-modified file before the finalizing chunk (md5-vs-store remains the correctness backstop).
  • rehash_local_plaintext streams (hash-only): its old callee's plaintext arm buffered the whole body despite the comment claiming otherwise.
  • Excluded paths: reconcile skips only the resume for a path the user has since excluded and lets adopt-or-requeue run - a finalized object is adopted into a file_state row (never left as an invisible untracked orphan on the remote), an unfinalized one requeues into nothing (the next scan excludes the path; the abandoned session is GC'd).
  • push_chunks (fresh-session uploads) gets the same no-progress stall guard the resume path has.

Instrumentation (make the next diagnostics zip self-diagnosing)

  • INFO breadcrumbs: cycle start with tick source, orchestrator state transitions (variant name only - never the Error variant's free-text details, which could leak partially-redacted paths), reconcile entry with pending-op count, resume start/abandon/restart with sizes + offsets + reasons.
  • RSS watchdog task (driven::app::memlog): samples every 15 s on the blocking pool, logs on >=128 MiB movement, keeps a trailing ~15 min window.
  • Diagnostic bundle: new pending_ops.txt (op shapes, sizes, offsets, ages, recovery-flag presence; session URLs are capability secrets and are never included) and memory.txt (current + peak RSS, a sample=fresh|stale-or-unread honesty flag, and the trailing sample window). Redaction policy text updated in the same change.

Review

An ultra multi-agent review ran against the first cut; its confirmed findings (S3 rewind misread, MemGauge leak on error unwind, excluded-op orphan gap, push-before-validate on legacy rows, state-transition Debug leak, plus several consistency nits) are fixed in the follow-up commit. Refuted findings (e.g. mid-stream prefix modification "undetected" - the full-stream md5 vs the store's stored md5 catches exactly that) are documented in code comments.

Tests

  • crash_mid_upload_resumes_persisted_session_byte_for_byte asserts MemGauge peak > 0 (proves the resume routes through the instrumented streaming loop) and peak <= 3 * WIRE_CHUNK (boundedness).
  • New: resume_honours_a_one_shot_rewind_to_zero (S3 contract; red on the stall-guard misread), resume_error_midstream_keeps_the_mem_gauge_balanced (red without the RAII guard), reconcile_skips_resuming_a_now_excluded_paths_session (+ control proving the gate), reconcile_adopts_finalized_orphan_even_when_path_now_excluded (red on a drop-the-op design), pending_ops_summary_names_resumable_sessions_without_urls, memlog sampler tests.
  • Full workspace suite green locally (macOS): driven-core 547 lib + 23 e2e + integration suites, driven-app 437.

README checked - no changes needed (it does not enumerate bundle contents, and no feature claims changed).

🤖 Generated with Claude Code

https://claude.ai/code/session_01A7q3CvJzL4zZmDA9CbXyQQ

…e whole file

Incident 2026-08-14: quitting mid-upload of an 88 GB file and relaunching
made the startup reconcile's resume path (resume_persisted) re-read the
ENTIRE file into one Vec via read_hash_encrypt - >10 GB of RSS growth in
~30 s at disk speed until the OS killed the app, repeating on every launch
because the pending op survives the kill. The live upload path was already
streaming and bounded; only the restart-resume (and the adopt re-hash) still
buffered.

- resume_persisted: single streaming pass - hash every byte, discard the
  acked prefix, push the tail in bounded wire chunks (~2 in flight), with
  the legacy-hash cross-check still running before the final chunk can
  finalize the object. MemGauge-instrumented; pacer charged only for pushed
  bytes.
- rehash_local_plaintext: hash-only streaming read (its old callee's
  plaintext arm buffered the body despite the comment claiming otherwise).
- reconcile now DROPS pending upload ops for paths the user has since
  excluded (the incident file's folder had just been excluded; recovery
  would have kept uploading it).
- Instrumentation so the next diagnostics zip self-diagnoses: INFO logs for
  cycle start (tick source), orchestrator state transitions, reconcile
  entry/resume decisions with sizes+offsets; an RSS watchdog task with a
  trailing sample window; pending_ops.txt + memory.txt in the diagnostic
  bundle (session URLs never included); redaction policy updated.

Regression tests: the crash-mid-resumable e2e now asserts the resume routes
through the gauge-instrumented streaming loop and peaks under 3 wire chunks;
new tests for the excluded-op drop, the pending-ops bundle summary (URL
non-leak), and the RSS sampler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7q3CvJzL4zZmDA9CbXyQQ
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Coverage

Area main this PR delta
Rust (lib crates) 84.63% 84.60% -0.04 (OK)
UI (vue/ts) 93.11% 93.11% +0.00 (OK)

Gate: passed - no coverage regression (epsilon 0.1 pp).

pmaxhogan and others added 3 commits August 14, 2026 13:29
- Backend offset semantics: interpret every InProgress answer against the
  streaming window (offset == file position of acc[0]). A backward offset
  (driven-s3's one-shot post-hydration rewind-to-0; sftp/localfs backward
  resync) RESTARTS the pass from the store's stated offset (budget: one
  restart); a forward offset within the window drains exactly the newly
  covered bytes; beyond the window restarts; an exact stall abandons.
  Previously every S3 resume with prior progress was misread as a stall and
  abandoned to a full re-upload.
- ResumeAcc RAII guard ties the MemGauge charge to the buffer so error
  unwinds (`?` on resume_chunk / persist_payload) refund in-flight bytes.
- Legacy rows (uploaded_blake3_hex, no resume_identity) are verified by a
  hash-only prepass BEFORE any byte is pushed, restoring the buffered
  code's check-then-push ordering for rows whose only gate is the hash.
- Cheap EOF identity re-fstat aborts a mid-stream-modified file before the
  finalizing chunk (the md5-vs-store check remains the correctness backstop).
- Excluded-path handling redesigned (angleB): reconcile now SKIPS only the
  resume for a now-excluded path and lets adopt-or-requeue run, so a
  finalized object is adopted into a file_state row (never an invisible
  untracked orphan) and an unfinalized one requeues into nothing.
- push_chunks (fresh-session path) gets the same no-progress stall guard.
- Orchestrator transition log emits the state NAME only (the Error variant's
  free-text details could leak partially-redacted paths into bundles).
- memlog: shared SystemClock for sample timestamps, Option<u64> log
  sentinel, spawn_blocking for the syscall, and snapshot() now reports
  staleness (memory.txt labels sample=fresh|stale-or-unread).
- Cross-reference comments tie PendingOpPayload to the bundle summary's
  field list.

New tests: one-shot rewind resumes to completion (red on the stall-guard
misread), mid-resume RPC error leaves the gauge balanced (red without the
RAII guard), excluded path skips resume with a never-called shim + control,
finalized orphan on an excluded path is adopted not leaked (red on the
previous drop-the-op design).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A7q3CvJzL4zZmDA9CbXyQQ
…ounting

Lifts the Rust line coverage back over the regression gate (the hardening round added abandon branches only reachable through fault shims).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01A7q3CvJzL4zZmDA9CbXyQQ
The ultra-review verifier confirmed the flag list silently omitted it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01A7q3CvJzL4zZmDA9CbXyQQ
@pmaxhogan
pmaxhogan merged commit adc81fa into main Aug 14, 2026
18 checks passed
@pmaxhogan
pmaxhogan deleted the fix/streaming-resume-oom branch August 14, 2026 19:56
@github-project-automation github-project-automation Bot moved this from Todo to Done in Driven Aug 14, 2026
pmaxhogan added a commit that referenced this pull request Aug 14, 2026
🤖 I have created a release *beep* *boop*
---


##
[2.10.1](v2.10.0...v2.10.1)
(2026-08-14)


### Bug Fixes

* **core:** stream the resumable-upload resume instead of buffering the
whole file ([#279](#279))
([adc81fa](adc81fa))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant