5 Commits

Author SHA1 Message Date
xarmian 78ec772226 fix(collab): recovery refuses an incomplete op-log replay, and a failed compaction snapshot keeps the log (TASK-3574 (1) and (3)) (#2045)
From the day-91 outside review's op-log durability claims, both measured
and ruled by the lead.

(1) Recovery (materializeWith) serialized an op-log whose replay left
structs PENDING, because they referenced a row the log did not hold. It
returned a truncated but nonblank body ("alpha" for "alpha beta gamma"),
which the store's only guard (blank output over a nonblank body) let
through: the recovery flush stored it and moved the watermark over content
it never saw. Compaction (snapshotWith) always refused the same input.
- The bundle now refuses it too ("pending structs").
- The Runner and the supervisor map that to a typed
  materialize.ErrIncompleteLog (wrapped with ErrJobFailed through the
  supervisor).
- Recovery treats it as deterministic: it exhausts at once with kind
  incomplete_log, so the existing WARN exhaustion line and
  pad_materialize_giveup_total{kind="incomplete_log"} are the refusal's log
  and metric. The body and watermark do not move, and there is no retry
  until the op-log changes.
- The GC sweep already leaves such an item's rows alone: it prunes only
  fully flushed logs. A test pins it.

(3) With compaction on (PAD_OPLOG_COMPACT, default off), a failed snapshot
left the item to the sweep, which deleted its log and with it the Yjs
identity compaction exists to keep. A failed (or unreadable) snapshot now
KEEPS the log and retries with in-memory backoff: 10 minutes, doubling,
capped at 24h; a restart retries once. Success clears the backoff, and
entries for items no longer listed are dropped. The test that asserted the
delete is flipped to assert keep, back off, then compact (lead ruling).

Tests: the bundle refuses the incomplete log while the complete one
materializes; recovery leaves the body, seq and state untouched, gives up
once with incomplete_log, and the sweep does not list the item; the
compaction keep/backoff/retry test. Counterfactuals: without the bundle's
refusal, the bundle returns "alpha" and recovery APPLIES the truncated body;
without the keep, all 27 rows are deleted.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 01:23:31 -04:00
xarmian 1a80389341 feat(collab): compact a dormant op-log into one Yjs snapshot (TASK-3531 U1+U2) (#1984)
* feat(collab): compact a dormant op-log into one Yjs snapshot (TASK-3531 U1+U2)

The first half of TASK-3531. Nothing calls it yet: the sweep (U3) and
Join (U4) come in the second PR, behind PAD_OPLOG_COMPACT (default off).

U1, the materializer: snapshot() replays an op-log and returns its
markdown plus the WHOLE document as one y-protocols Update frame. It
refuses a frame whose replay alone does not give the same Yjs snapshot
and markdown, and an op-log that leaves structs pending. A row that does
not replay at all contributes nothing (here or in any tab) and is
compacted away. Runner.Snapshot, a `mode` on the worker protocol, and
Supervisor.Snapshot share Materialize's job slot, caps and deadline.

U2, the store: items.yjs_compacted_through (the highest op-log id a
snapshot covers) and items.yjs_snapshot_op_id (the snapshot row).
CompactItemOpLog swaps the rows for the snapshot in one transaction iff
the log is unchanged since it was read, still dormant and fully flushed,
and moves the flush watermark to the snapshot. CompactedResumeCovers
admits a cursor only while the snapshot row exists, so a path that
deletes the log can never leave the columns admitting a stale tab; the
three that delete the whole log also clear them.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* docs(store): CompactItemOpLog's no-append contract, and why the tx is not the exclusion (TASK-3531, codex)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 15:03:53 -04:00
xarmian 4ed3b5eca2 fix(materialize): a job's deadline scales with its op-log (BUG-3521) (#1946)
* fix(materialize): a job's deadline scales with its op-log (BUG-3521)

Op-log recovery could not finish a large document: the real op-log of
an 86 KB plan (278 content-bearing rows, 470 KiB) took 1.36 s median
in-process on an idle box and 1.77 s on one core, against a flat 2 s
hard / 1.8 s soft deadline. It failed every attempt and exhausted its
budget, leaving items.content stale for the CLI, MCP, search and
exports until someone reopened the item.

Each job's hard deadline is now the configured base
(PAD_MATERIALIZE_TIMEOUT, 2 s) plus 20 ms per KiB of its rows, never
below the base and at most MaxTimeout (60 s): ~5x the measured
single-core rate, so that document gets ~11 s. The supervisor computes
it from the job, so every caller gets it. The soft deadline stays at
90%, and recovery runs off the request path in its own worker.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(materialize): the worker-start line states the deadline rule, not only its base (BUG-3521, codex r1)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-09 05:31:37 -04:00
xarmian b67e78343a test(materialize): fire the idle timer mid-job on purpose, not by wall-clock luck (BUG-3435) (#1827)
TestSupervisorIdleTimerDuringJob staged 'the idle timer fires while a job
runs' with a 300ms timer, a 200ms pause and a 400ms job. On a loaded macOS
runner the pause stretched past the timer, the worker went idle BETWEEN the
jobs, and the test failed with '2 spawns'. Widening the pause to 320ms
reproduces that exact failure 3/3.

Now nothing fires on its own (a one-hour idle). A new script command,
await:<path>, holds the job inside the worker until the test releases it;
the test fires the timer itself (back-dating lastJob, Reset(0)) once the
job has provably started, and waits on a new test hook, onIdleBusy, which
idleStop calls when it finds the slot taken. Then it releases the job,
checks one spawn and that the job's end re-armed the timer on the same
worker, and fires once more to see the worker stop.

Mutants: idleStop proceeding without the slot check (red 10/10), and
armIdle not recording the job's end (red). 30/30 green with every CPU
saturated, and under -race.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-06 09:25:18 -04:00
xarmian d78ff9bdc5 feat(materialize): worker supervisor with per-OS memory cap (TASK-2198 U3) (#1682)
* feat(materialize): parent-side worker supervisor with per-OS memory cap (TASK-2198 U3)

The Supervisor runs materializer jobs in ONE long-lived child process
(`pad __materialize-worker`), started lazily on the first job and fed
jobs serially. A job past PAD_MATERIALIZE_TIMEOUT (default 2s) kills the
child (goja's Interrupt, sent as timeout_ms a tenth earlier, is only the
soft layer); a dead child is respawned on the next job after an
exponential backoff (1s doubling, capped at 5 min, reset by a child that
lived a minute). Every stop is logged with its reason (deadline, memory,
exit, protocol, start, canceled, closed); every start logs the effective
timeout, memory limit and cap mechanism. The child's stderr goes to the
log, pid-tagged and bounded, never near the protocol. A job whose child
died is never retried here.

Memory cap (PAD_MATERIALIZE_MEM_LIMIT, default 2GiB, bytes or
KiB/MiB/GiB, clamped to 1536MiB-16GiB):
- Linux: RLIMIT_AS set on the child by prlimit before its first byte of
  input; the child only runs its own startup uncapped.
- Windows: Job Object with JOB_OBJECT_LIMIT_PROCESS_MEMORY; the child is
  created suspended, assigned, then resumed: no uncapped window.
- macOS: parent-side RSS watchdog every 100ms (proc_info syscall, ps
  fallback).
- Other: deadline kill only.

Not wired into the server yet (U4).

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* ci(materialize): run the supervisor on the real macOS and Windows caps (TASK-2198 U3)

native-smoke now builds the real materializer bundle (as the go job does)
and runs the supervisor, memory-cap and deadline tests on macos-latest
(proc_info RSS watchdog) and windows-latest (suspended start + Job
Object). PAD_MATERIALIZE_TEST_NO_SKIP turns the cap tests' skips into
failures there, so a cap that stops running cannot pass as skipped.

Tests: a real Windows processGone (OpenProcess + GetExitCodeProcess),
RSS samplers for darwin (the production proc_info path) and Windows
(K32GetProcessMemoryInfo), and an allocation helper that writes noise
into every byte so macOS memory compression cannot hide it from the
watchdog.

The worker-loop tests share one loaded Runner through serveWorker
(RunWorker's loop, split out); TestWorkerRoundTrip still goes through
RunWorker end to end. go test -race -count=3 on the package: 714s -> 413s.

docs: the materializer section says the worker and its knobs are inert
until U4 wires it in.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): a worker never outlives its supervising process (TASK-2198 U3, codex r1)

If the server died mid-job (SIGKILL, OOM kill, crash) its worker ran on
with no deadline kill and, on macOS, no memory watchdog. Two layers:

- Linux: Pdeathsig = SIGKILL. The fork runs on a fresh goroutine
  (startUnlocked), never locked to its thread, so it cannot land on a
  thread the runtime later retires (golang/go#27505); Go's forkExec
  re-checks getppid if the parent is already gone.
- Every Unix (the only layer on macOS): the worker command calls
  ExitWhenOrphaned before the bundle loads, polling its parent pid every
  200ms against PAD_MATERIALIZE_PARENT_PID and exiting (status 70) when
  it changes. It writes nothing.
- Windows was already covered by the Job Object's KILL_ON_JOB_CLOSE.

TestSupervisorOrphanedWorkerExits kills a helper process that supervises a
worker hung in a job and requires the worker gone within 1s: 0s on Linux,
105ms with the ppid watch alone; with both layers removed the worker
outlives its parent by 5s. It matches native-smoke's TestSupervisor.*
selection, so it runs on macOS and Windows too.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): relative memory cap, refuse when uncappable, GOMAXPROCS=2; nix-safe ps test (TASK-2198 U3)

The absolute Linux RLIMIT_AS failed on CI: a 64 MiB allocation died under a
1600 MiB cap, because the runtime there reserved more address space than on
the machine the floor was measured on. The cap is now RELATIVE on every OS:
once the worker has loaded and before it is sent any job, its usage is read
as a baseline and the cap is set to baseline + PAD_MATERIALIZE_MEM_LIMIT —
address space on Linux (/proc/<pid>/stat vsize, prlimit, read back),
committed memory on Windows (the Job Object's per-process limit, set after
the suspended start and load), resident memory on macOS (the watchdog's
threshold). Every start logs baseline, limit and effective cap.

Lead ruling, REFUSE: if the baseline probe fails or is implausible (0, or
above MaxPlausibleBaseline = 8 GiB, ~4.8x the largest measured 1704 MiB),
onBaselineProbeFailure kills the worker without sending it the job and
fails the job with ErrNoMemoryCap; one WARN until a probe succeeds again.
An OS with no cap mechanism never starts a worker and refuses every job
with ErrNoMemoryCap, one WARN naming runtime.GOOS.

The worker runs with GOMAXPROCS=2: measured loaded-worker baseline
1357 MiB vs 1597 MiB at this host's default (8) and 1704 MiB at 64.
MinMemLimit drops from 1536 MiB (an artefact of the absolute cap) to
256 MiB: the corpus grows the worker by at most +10 MiB of address space and
+18 MiB resident, and a 4,000-deep nested document by +129..194 MiB.
A cgo worker's "pthread_create failed" at the cap is now classified as a
memory death.

Nix: TestParsePSRSS parses fixed strings only; the live `ps` leg is
TestPSRSSLive and skips when there is no `ps` (the Nix sandbox has none;
ps is only macOS's fallback), unless PAD_MATERIALIZE_TEST_NO_SKIP is set.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): Windows kills on the job's memory notification; steady thread set before the baseline (TASK-2198 U3)

Windows (CI on c1866957): the Job Object refused the 1 GiB commit
(VirtualAlloc errno=1455), but the worker did not die and the job ran to its
deadline. The cap no longer depends on the runtime choosing to crash: the
job is associated with an I/O completion port, and
JOB_OBJECT_MSG_PROCESS_MEMORY_LIMIT for the worker's pid kills it at once
(ErrMemoryLimit). errno=1455 on stderr is a second signal. On every OS a
cap mechanism that sees the limit hit calls onLimit, and the kill is
reported as "reached its <mechanism> cap".

Linux (CI's cgo test binary at GOMAXPROCS=64): threads created after the
baseline ate the growth budget. The worker now brings its thread set to
steady state before readiness (warmThreads: an allocating goroutine per P,
then a GC). Measured on a cgo build: threads added after the baseline are 0
at GOMAXPROCS=2 with or without it; at 64, +3 threads and +272 MiB of
address space during the corpus without it, +0 and +0 MiB with it. 256 MiB
stays the floor (largest growth at GOMAXPROCS=2: +193 MiB, a 4,000-deep
nested document; the corpus adds +0 MiB).

The cap test's second leg no longer uses GOMAXPROCS=64 (which also changed
thread creation) to stand in for a bigger host: the helper inflates its
own baseline before readiness, with never-touched address space on Linux,
committed memory on Windows and resident memory on macOS.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* feat(materialize): the worker stops after an idle timeout; the next job restarts it (TASK-2198 U3)

Dave's ruling on the worker process: GO, with an idle exit. A loaded worker
holds ~200 MB resident, which an install that rarely materializes should
not pay for all day.

PAD_MATERIALIZE_IDLE_TIMEOUT: default 5m, clamped to 30s-24h, 0 = never.
After each job the supervisor (re)arms a timer. When it fires, the stop
runs only while holding the job slot, so it never takes a worker with a job
in flight. It re-checks the idle time, forgets the worker WITHOUT recording
a death (backoff is untouched), and closes the worker's stdin so its read
loop ends with status 0, with a kill after a grace if it does not go. The
stop is logged at INFO, reason=idle. A job that arrives meanwhile waits for
the slot, finds no worker, and starts a new one with no backoff wait.

Legs: the idle exit after the timeout (INFO, status 0, no WARN); the next
job respawns and succeeds with no wait; an idle stop leaves the backoff
streak alone (crash, idle, crash waits 2s, not 4s); a job arriving during
the stop runs on a new worker; a timer that fires mid-job does not stop that
worker; NeverIdle arms no timer; env parsing and clamps. Mutations, each red
on its own: recording a death (3 legs), logging as a kill (1), not holding
the slot (1), never arming (3). Docs: the knob beside the other two, and
`idle` in the stop reasons.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): Close does not return while an idle-stopping worker still runs (TASK-2198 U3, codex r1)

An idle stop forgets the worker before it has exited, so a Close in that
window found no worker and returned with it still alive. The supervisor now
tracks the worker an idle stop is ending, and Close kills and reaps it. A
leg pins it, and goes red without the fix.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC

* fix(materialize): an idle stop that Close ended says so (TASK-2198 U3, codex r2)

The idle stop stored its kill reason unconditionally, so it overwrote the
'closed' of a Close that had already killed the worker, and its stop line
said idle. It now claims the reason only if none is set, and the line
reports reason=closed. The Close-during-idle-stop leg asserts it.

Claude-Session: https://claude.ai/code/session_01H63zaWhkb5pAafx2WGjrVC
2026-09-30 09:52:49 -04:00