Skip to content

Fix minor issue in FastSlowStore - #21

Merged
allada merged 1 commit into
masterfrom
fix-early-shutdown
Nov 11, 2021
Merged

allada merged 1 commit into
masterfrom
fix-early-shutdown

Conversation

@allada

@allada allada commented Nov 11, 2021 •

Copy link
Copy Markdown
Contributor

A case where the client can close the connection causing the future
to be destoryed resulting in the EOF to fail to write to underlying
store. We no longer care if the EOF suceeds.


This change is Reviewable

A case where the client can close the connection causing the future
to be destoryed resulting in the EOF to fail to write to underlying
store. We no longer care if the EOF suceeds.
@allada
allada merged commit 81fb378 into master Nov 11, 2021
@allada
allada deleted the fix-early-shutdown branch November 11, 2021 06:21
rejuvenile added a commit to rejuvenile/nativelink that referenced this pull request Aug 19, 2026
… memory-only CAS blobs

Operator directive (2026-06-23): "remove the timeout from the shutdown path;
give shutdown unlimited time to flush blobs to redis and to disk. we can't
have this keep happening every time the server restarts."

At the 2026-06-23 17:38 buildcache restart the Phase-2 shutdown flush hit its 30 s
deadline and lost 827,204 memory-only SMALL_CAS_CACHED blobs
(`flushed=0 skipped_already=591792 deadline_exceeded=827204`,
`elapsed_ms=29997`), causing CI "lost input too many times (TraceMachina#21)" failures
across >=3 branches for ~30 min post-restart.

ROOT CAUSE of flushed=0 (verified, not a counter bug): the per-blob loop in
`flush_fast_to_slow_at_shutdown` was STRICTLY SERIAL — one `slow.has` RTT then
one `slow.update_oneshot` RTT per blob. With 1.4M keys the 30 s budget was
spent on ~591k serial Redis `has` probes (all already-present -> skipped) and
the deadline (`started.elapsed() >= deadline`) fired before the loop reached a
single blob that NEEDED writing. `flushed` increments correctly on the success
path; the loop simply never reached a writable blob in time. So removing the
deadline alone was necessary-but-insufficient — the serial structure had to be
parallelized too, or an unbounded drain of 1.4M serial RTTs would near-wedge.

FIX (three parts):
1. Deadline `Duration` -> `Option<Duration>`; `None` = unbounded (production
   path), `Some(d)` = best-effort within `d` (kept for the deadline-bound test
   + any future bounded caller). `store_manager` Phase 2 and the bin now pass
   `None`; the bin drops its outer `tokio::time::timeout` around
   `flush_slow_writes` (it would have re-capped the unbounded drain). Phase 1
   (in-flight slow-write drain) stays bounded by `timeout` — it was NOT the
   incident surface (`total_pending=0` in the incident).
2. Bounded-concurrency drain (`SHUTDOWN_FLUSH_CONCURRENCY = 64`, via
   `buffer_unordered`) so N serial RTTs become N/K; a large fast tier drains
   in minutes, not tens of minutes. Skip-existing `has` pre-check is preserved
   (Test 5 still passes) but now runs concurrently.
3. `info!` forward-progress line every 50,000 processed blobs
   (`flushed/skipped/remaining/elapsed`) so an operator watching journalctl
   through a long drain sees motion, not a wedge.

Both arms are unbounded: SMALL_CAS_CACHED (Redis slow tier — the 827k victim)
AND cas_FAST_SLOW_STORE (FilesystemStore /srv/bulk slow tier). The disk arm is a
plain async `update_oneshot` (FilesystemStore dedups via its in-memory index;
no sync-write primitive added anywhere).

INVARIANT WALK
- Invariant being violated: every memory-only CAS blob in a FastSlowStore fast
  tier must reach its slow tier before process exit, or AC entries dangle and
  Bazel reports "lost input".
- Mechanism that violates it: `fast_slow_store.rs` serial per-blob flush loop +
  30 s deadline — burned the whole budget on serial `has` RTTs, flushed 0.
- Mechanism that re-establishes it: `flush_fast_to_slow_at_shutdown(None)` runs
  the per-blob has/get/update at concurrency 64 with NO deadline, draining to
  zero (`unflushed = errored + deadline_exceeded`, both 0 under `None`+no-error).
- Composite invariants this fix interacts with: mirror_blobs >=2-replica (NOT
  touched — mirror blobs are deliberately not flushed here); ZFS sync=disabled
  durability (unchanged — async writes only); admission/eviction/pin (untouched
  — shutting_down already fences new writes via Phase 1).
- Test that proves the fix: `shutdown_flush_unbounded_drains_all_blobs`
  (all N land, unflushed==0) + `shutdown_flush_unbounded_drains_despite_slow_tier`
  (slow tier + None still drains all N; exactly N writes issued).

BEHAVIOR CHANGES (every runtime change vs production composition)
- FastSlowStore::flush_fast_to_slow_at_shutdown: bounded(30s)+serial ->
  unbounded(None)+concurrent. In prod (StoreManager Phase 2 over
  SMALL_CAS_CACHED + cas_FAST_SLOW_STORE): shutdown now blocks until every
  memory-only blob is durable instead of dropping the remainder at 30 s.
- StoreManager::flush_slow_writes Phase 2: removed per-store deadline math
  (PHASE_2_FLOOR) and the Phase-2 outer wall-clock timeout; Phase 2 awaits all
  per-store drains to completion. Phase 1 unchanged.
- src/bin/nativelink.rs shutdown step 4: removed the
  `tokio::time::timeout(flush_budget+8s, ...)` wrapper around
  `flush_slow_writes`; calls it directly. Phase-1 budget still passed in.
- ACCEPTED RISK (per directive): a genuinely wedged slow tier now blocks
  SIGTERM-to-exit indefinitely. No in-process safety valve added (TRUE
  unbounded per directive). REQUIRES systemd `TimeoutStopSec=infinity`
  (currently 90 in /etc/systemd/system/nativelink.service — parent applies at
  deploy) or systemd SIGKILLs the unit mid-flush and re-introduces the loss.

TDD: 8/8 tests green. Mutations verified:
- force `Some(Duration::ZERO)` on the None path -> unbounded test red-fails
  "TraceMachina#210 UNBOUNDED flush must drain ALL memory-only blobs — data-loss
  regression: 250 of 250 blobs left unflushed with no deadline".
- skip the actual `update_oneshot` write -> content check red-fails
  "blob ... missing from slow tier post-unbounded-flush".

No fsync/sync-write primitives; no tokio-worker blocking; mirror invariant
untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rejuvenile added a commit to rejuvenile/nativelink that referenced this pull request Aug 19, 2026
…rver durability bundle, directive-2)

Adds graceful-shutdown Phase 3 (pull worker-only CAS blobs to the server
slow tier while workers are still connected) plus a Phase-0 Bazel-REAPI
quiesce, on top of directive-1's unbounded flush. New shutdown ordering:
quiesce Bazel REAPI -> flush server fast tier -> pull worker-only blobs ->
[directive-3 persist-locality slot] -> evict workers -> stop accepting ->
drain -> shut down local workers -> exit. Eviction MOVED after the pull
(you cannot pull from an evicted/disconnected worker) but stays BEFORE
listener-drain (preserves d204e1b's drain-promptness rationale).

Design: .claude/audits/shutdown-pull-worker-blobs-design-2026-06-23.md.

== Invariant-walk ==
Invariant being violated: after a server restart, a CAS blob that exists
ONLY on workers is not served by the server until the worker re-handshakes
and re-backfills, causing a multi-minute NotFound window (Bazel "lost input
TraceMachina#21"); and during shutdown the flush cannot converge because new Bazel
writes keep arriving.
Mechanism that violates it: src/bin/nativelink.rs evicted/disconnected all
workers FIRST (old Step 1), wiping the locality map + per-worker tx before
any localize attempt (worker_api_server.rs remove_endpoint); and no gate
stopped new Bazel REAPI during shutdown so the flush chased fresh writes.
Mechanism that re-establishes it: (Phase 0) BazelReapiQuiesce latch flipped
at SIGTERM start makes new requests on the public Bazel listener return
UNAVAILABLE (in-flight drain) so the in-flight write set converges;
(Phase 3) ShutdownPuller::run (worker_api_server.rs) drives the existing
has_with_results-gated request_missing_blob_uploads in a completion-poll
loop over connected_workers_with_senders, BEFORE eviction, until every
locality digest is durable in the server slow tier or has zero connected
source (at-risk-skip; never wedges; no-progress watchdog bounds a stalled
worker).
Composite invariants this fix interacts with: (1) mirror_blobs >=2-replica
+ BIS-ack-as-durability-oath -- the pull ADDS the server as a replica and
reuses the has_with_results-gated mark_stable (worker_api_server.rs ~2525),
NOT weakened; (2) d204e1b evict-before-listener-drain -- preserved
(eviction still precedes drain); (3) TraceMachina#210 unbounded memory->slow flush --
the pull runs AFTER it and depends on it for the existence check;
(4) no per-RPC timeout on internal RPCs -- the pull is unbounded-time,
bounded by skip-policy + no-progress watchdog, not RPC deadlines;
(5) worker-facing CAS listeners (:50071/:50072) + worker_api (:50061) stay
OPEN through shutdown -- the pull needs them.
Test that proves the fix: shutdown_pull_lands_worker_only_blob_on_local_disk_before_future_resolves
(pull future does not resolve until has_with_results == Some);
shutdown_pull_skips_zero_source_blob_and_does_not_hang (zero-source ->
resolves, at_risk_skipped==1, no wedge);
closed_gate_rejects_new_request_with_unavailable (quiesce -> grpc 14).

== What changes (architectural; .claude/rules/architectural-changes.md) ==
- X (today): SIGTERM evicts/disconnects workers FIRST, then drains, then
  flushes; worker-only blobs abandoned on disconnect; no Bazel-REAPI gate.
- Y: SIGTERM quiesces Bazel REAPI, flushes, PULLS worker-only blobs (workers
  connected, unbounded), THEN evicts, drains, exits.
- Shutdown-ordering change (worker disconnect moves after the new pull) +
  flow-control change (new unbounded-time, network-driven completion loop on
  the shutdown path). NOT an async<->sync flip: no .await-on-send added to a
  steady-state pump, no tokio::spawn removed from a producer/consumer; the
  loop is shutdown-only and uses the EXISTING fire-and-forget send + a new
  poll, so it adds no synchronous backpressure to any production write path.

== Behavior changes (runtime, vs production composition) ==
- Steady state (no SIGTERM): NONE. The quiesce latch starts false (one
  Relaxed atomic load per request on the quiesce-marked listener only, never
  short-circuits). ShutdownPuller is only invoked from the SIGTERM handler.
  No production read/write path changes.
- Config: new ServerConfig.quiesce_on_shutdown (default false). Absent =>
  pre-change behavior. Set ONLY on the public Bazel listener (:50051). The
  worker-facing CAS listeners (:50071/:50072) and worker_api (:50061) do NOT
  set it and are unaffected.
- Shutdown: ordering changes as above. The flush moves before eviction; the
  pull is new; eviction moves after the pull. Local-worker shutdown + exit
  unchanged.

== Hard-constraint compliance (CLAUDE.md) ==
- NO fsync/sync-write: the pull writes nothing synchronously; blobs land via
  the worker's slow_store.update -> server gRPC CAS -> FilesystemStore/Redis
  async path; durability = mirror >=2-replica + ZFS sync=disabled.
- Never block a tokio worker: pull is pure async (has_with_results,
  tokio::time::sleep poll, mpsc send); no std::thread::sleep / sync FS.
- Unbounded-buffer rule: NO new blob buffer. Only a Vec<DigestInfo> +
  endpoints locality snapshot, annotated CAPPED AT digest_count() (~40 B/
  entry, one-shot, dropped post-loop). Blob bytes stream worker->disk, never
  collected server-side.
- BIS not weakened: the pull reuses the has_with_results-gated mark_stable;
  it acks-to-unpin only AFTER the server's existence check confirms
  durability.

Tee logs: /tmp/dir2-*-{libbuild,testcompile,testrun,binbuild,
mut-zerosource2,mut-skippull,mut-nocompletion,mut-quiesce}.log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant