Skip to content

Conversation

@jdoucett
Copy link
Contributor

What

ship_object's post-create round-trip verify retrieve shared the create-side HTAR_STALL_SECONDS (2 h) silence budget. It now has its own bound, retrieve_stall_seconds, whose default lives in data (stall_defaults.json, 21600 s = 6 h) and which a per-asset config may override.

Why

A create streams per-file progress, so silence there is a genuine hang. A retrieve is silent by design while HPSS queues and stages the tape. On a large routed shard the shared bound was killing healthy retrieves.

Observed — repository_X1D_3_metabolomics_rawspectra, one 6,624,961,024-byte shard:

  • htar -cvf succeeded every run; the retrieve was killed at exactly 7200 s on both attempts (~8 h/job, exit 1);
  • its own stall log: poll: 7191.9s since last activity (stall limit 7200s) then activity resumed after 7200.1s silence (+73 bytes) — the retrieve was healthy, 0.1 s past the kill line;
  • verify never passed -> no success record -> the wrapper re-shipped the whole shard every scheduled run: 48 identical 6.62 GB tars, ~318 GB of duplicate tape, 2026-07-17 -> 08-03.

(Separate and already resolved: the 2026-08-01 HPSS outage produced a different mode, htar create failed (rc=71). Not conflated here.)

Changes

  • stall_defaults.json (new, committed) — the default retrieve budget, with the incident recorded beside it. 6 h is ~3x the one observed silence gap and stays well inside the 48 h Slurm walltime even if both retrieve attempts burn their full budget.
  • load_stall_defaults() — fails loud on a missing/malformed/invalid file; no code-side fallback. load_config reads it lazily, so a config that pins retrieve_stall_seconds needs no defaults file, and validates a per-asset override.
  • ship_object(..., retrieve_stall_seconds)required; raises before any htar/hsi call rather than silently reinstating 2 h. Threaded through the ship_one closure (positionally — the local/globus run dispatcher forwards positional args only).
  • The create keeps HTAR_STALL_SECONDS, documented at both constants, in config.example.json, and in CLAUDE.md invariant docs: RFC — staged-hybrid incremental backup (Phase 2 = size-routing/append) #7.

Tests

New tests/test_retrieve_stall_config.py: the defaults-file contract (including the default must exceed the create bound — a smaller value would re-open the loop while looking configured), load_config default/override/validation, that the retrieve calls carry the configured bound while the create does not, and that an absent/invalid value raises before any tape work. Existing ship_object callers in three test files updated. Full suite: 423 passed.

Scope / deploy notes

  • This alone does not stop a re-ship loop — a repeatedly-failing verify still re-ships. The per-target circuit breaker for that is florasense-tools PR (fortress/repo_backup.py), shipped alongside.
  • Deploy is by git pull on the engine checkout, so stall_defaults.json travels with archive.py. Copying archive.py alone would make every config load fail loud — by design, but worth knowing.
  • Not touched (out of scope, needs its own gated go/no-go): reclaiming the ~318 GB of duplicate X1D tars. That is a destructive Fortress op — gated-data-remediation protocol, provenance record first, and only after one verify actually passes.
  • No merge, no deploy without explicit approval.

… re-ship loop)

ship_object's post-create round-trip verify retrieve shared the create-side
HTAR_STALL_SECONDS (2h) silence budget. A create streams per-file progress, so
silence there is a genuine hang; a retrieve is silent BY DESIGN while HPSS queues
and stages the tape. On a large routed shard the shared bound was killing healthy
retrieves — and because verify never passed, ship_object wrote no success record,
so the wrapper re-shipped the whole object on every scheduled run.

Observed (repository_X1D_3_metabolomics_rawspectra, one 6,624,961,024-byte shard):
htar -cvf succeeded every run; the retrieve was killed at exactly 7200s on both
attempts (~8h/job, exit 1), and its own stall log records "activity resumed after
7200.1s silence" — 0.1s past the kill line. Result: 48 identical 6.62 GB tars
(~318 GB of duplicate tape) between 2026-07-17 and 2026-08-03.

- new stall_defaults.json holds the default retrieve budget (21600s = 6h, ~3x the
  one observed gap, well inside the 48h Slurm walltime even if both retrieve
  attempts burn it). Defaults live in DATA, not in a function signature.
- load_stall_defaults() fails loud on a missing/malformed/invalid file; load_config
  reads it LAZILY so a config that pins retrieve_stall_seconds needs no defaults
  file, and validates a per-asset override.
- ship_object takes retrieve_stall_seconds and REQUIRES it (raises before any
  htar/hsi call) — no code-side fallback that could silently reinstate 2h.
- create keeps HTAR_STALL_SECONDS, documented at both constants and in CLAUDE.md
  invariant #7.

Tests: new tests/test_retrieve_stall_config.py (defaults-file contract incl. the
default must exceed the create bound, load_config default/override/validation,
the retrieve calls carry the configured bound while the create does not, and the
absent/invalid-value raises land before any tape work). Existing ship_object
callers in three test files updated. Full suite: 423 passed.

Note: this does NOT by itself stop a re-ship loop — a repeatedly-failing verify
still re-ships. The per-target circuit breaker for that lives in florasense-tools
(fortress/repo_backup.py), shipped alongside this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jdoucett Jarrod S Doucette (jdoucett) deleted the fix/retrieve-stall-configurable branch August 5, 2026 16:02
Jarrod S Doucette (jdoucett) added a commit that referenced this pull request Aug 5, 2026
…rieve too

PR #37 (23a7963) fixed ship_object's routed-object retrieve sharing the
create-side HTAR_STALL_SECONDS (2h) silence budget, but send_to_fortress -
the whole-target and Phase-3 leveled-incremental transport - carries its own
fully self-contained inline twin of the stall-watch logic (run_watched,
invariant #1: no outer-scope references, since it can run remotely under
--globus) and was left on its own hardcoded 7200s default for both the
create AND the two retrieve steps (hsi get, then local tar xvf).

Confirmed hit independently of X1D: repository_X0H_2_spectral-standoff
(job 41417829, 2026-07-31, a leveled-incremental, multi-TB whole-target
tar) had a successful create, then its round-trip retrieve was killed at
exactly "no output for 2.0h" - the identical bug class, on the unfixed twin.

- send_to_fortress now takes retrieve_stall_seconds and REQUIRES it (raises
  before any htar_large/hsi/tar call) - no code-side fallback, mirroring
  ship_object's contract exactly.
- Threaded into the two retrieve run_watched() calls only (hsi get, tar
  xvf); the create call keeps the hardcoded 7200s default - a create
  streams per-file progress, so silence there really is a hang.
- Both real call sites (whole-target Step 2, and Phase-3's ship_level)
  updated to pass the already-loaded config value positionally (run
  forwards positional args only).
- Reused stall_defaults.json's existing retrieve_stall_seconds default
  (21600s/6h) rather than adding a second knob: the config key was already
  path-agnostic ("the post-create round-trip verify retrieve"), and an
  operator can still raise it per-asset for X0H's larger multi-TB tars via
  the same override mechanism.
- CLAUDE.md, stall_defaults.json, and config.example.json comments updated
  to describe both transports sharing this one bound.

Tests: new tests/test_send_to_fortress_retrieve_stall.py - fakes
htar_large/hsi/tar as shell-script stand-ins on PATH (matching
test_ship_object_htar_rc.py's approach), since run_watched is a closure
local to send_to_fortress and can't be intercepted the way
archive._run_with_stall_watch is. Each behavioral assertion runs
send_to_fortress in a background thread with a bounded join so a
regression that falls back to the 2h default fails fast rather than
hanging. Covers: hsi-get retrieve honors the configured bound, tar-extract
retrieve honors it too, create stays on its own default even with a tiny
configured bound, and the absent/invalid-value raises land before any tape
work. Full suite: 428 passed (423 + 5 new), no regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign in to join this conversation on GitHub.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant