Skip to content

Latest commit

 

History

History
11 lines (9 loc) · 7.86 KB

pipeline.md

File metadata and controls

11 lines (9 loc) · 7.86 KB

pipeline — notes

Hand-written. ../harvested/pipeline.md is what the documents say; this file is what the implementation found, and it wins. Each entry names the harvested entry it answers by that entry's stable key.

Superseded

  • RECOV-10's "rethrow the contained throwable UNCHANGED" is broken by the obvious Ruby spelling: a bare raise error silently assigns whatever exception is in flight as that error's #cause, and the in-flight exception can be the caller's. Supersedes pipeline/b3f74458 ("Dispatch unwraps a Failure by re-raising the contained error unchanged, with no wrapping or substitution"), whose rule is exactly right and whose Ruby realisation is a trap the entry cannot warn about because it names no code. Verified on 2026-09-08 against 3.2.11, 3.4.10 and 4.0.6. The facts, uniform across the range. raise stored when $! is non-nil and stored.cause is nil sets stored.cause to $! — Ruby's implicit cause assignment — so the object the caller receives is not the object the Failure carried: it has acquired a parent it never had. $! is dynamically scoped to the thread and is therefore non-nil inside a method called from a caller's rescue block, which is ordinary consumer code (begin; …; rescue; client.send(request); end) and needs no rescue anywhere in core to reach; measured, the surfaced error came back carrying "the CALLER's unrelated in-flight error" as its cause. raise stored, cause: nil suppresses the assignment; it does not clear a cause the error legitimately already has (measured: a pre-existing cause survives, same object); and it never trips ArgumentError: circular causes, including against the caller-defined #cause overrides ../notes/error-handling.md records as the only route to a cycle. raise stored, cause: stored.cause behaves identically in every case measured but calls a caller-overridable method to do it. What the SDK does, per docs/work/mvp/phase4/phase4b/2026-09-08-phase4b-recovery-primitives-design.md: every RECOV-10 unwrap and every place core re-raises an error it is carrying rather than one it just rescued is written raise error, cause: nil, with the reason at the call site. Outside the recovery chain there is exactly one such place and it is phase 2's: Dexpace::Hooks.notify collects the first handler failure, runs the rest of the list, and re-raises the stored object afterwards, and $! at that line is measurably the caller's in-flight exception whenever a hook list is drained from inside a rescue; phase 4b's Task 2, which attaches the later handler failures Hooks.notify drops, rewrites it as raise failure, cause: nil in the same edit that adds the suppressed trail. How far cause: nil reaches is worth stating exactly, because it is less than the rule sounds. It suppresses an assignment the re-raise would itself make; it cannot undo one already made. Measured on all three: an error the caller's code raised while an outer rescue was in flight acquired that exception as its cause at its own raise, and cause: nil at a later re-raise leaves it in place — so the spelling changes the surfaced object only for an error that reaches the re-raise with cause == nil, which is exactly RECOV-10's own named case, "a recovery step constructing the error and returning a Failure". The consequence for tests is concrete: a RECOV-10 fixture whose Failure carries a raised error makes assert_nil error.cause fail against a correct implementation, so the fixture must construct the error and never raise it. The RECOV-10 test asserts #cause is unchanged across the unwrap while an unrelated exception is in flight — an assertion a test written without this fact would not think to make, and which the bare spelling passes whenever the suite happens not to be inside a rescue. pipeline/c76ca402 (the RECOV-10/RECOV-11 spec rule) and pipeline/77de2b12 are unaffected and adopted verbatim. review · docs/work/mvp/phase4/phase4b/2026-09-08-phase4b-recovery-primitives-design.md · high · sha:manual-phase4b-reraise-cause
  • PIPE-15's "reusing the same cursor handle must be treated as a defect" is enforceable in Ruby only for a sequential second call, and the race that hides that is visible on the 3.2 floor and invisible above it. Adds to pipeline/591329b5 ("A step that drives the downstream chain more than once must fork a fresh cursor per re-drive, and reusing the same cursor handle for a second downstream invocation must be treated as a defect") and pipeline/debd2eb6; the rule is right, is implemented, and is not weakened here — what neither entry can say, because neither names code, is how far the obvious Ruby spelling reaches. Verified on 2026-09-08 against 3.2.11, 3.4.10 and 4.0.6. The measurement. A per-invocation cursor guards its single use with a plain instance variable — unless @spent; @spent = true; …; end — because it is created per step invocation, handed to exactly one step and never published, so a Thread::Mutex would cost an allocation on every step of every call and would put a per-fiber-owned, non-reentrant lock in the hot path (concurrency-and-async/c0fab747, the smallest-critical-section rule, and the Mutex-is-per-fiber-owned entry in ../notes/concurrency-and-async.md). Eight threads released from one Thread::Queue barrier onto that body, 2000 runs: 29 of 2000 runs let more than one caller through on 3.2.11, and 0 of 2000 on 3.4.10 and on 4.0.6; at 32 threads, 9 of 2000 on 3.2.11 and 0 of 2000 on the other two. A Thread::Mutex around the same body gave 0 of 50 on all three. What that licenses, and what it does not. It licenses the design decision — no lock, and a YARD sentence saying detection is sequential-only, since handing one cursor to two threads is already the defect PIPE-15 names. It does not license "3.4 and 4.0 are safe": zero in 2000 is an absence of observation and the interleaving is not excluded, so the port's statement is written over the whole supported range. The consequence for tests is the part a reader will otherwise get wrong: a test that asserts the race — that two concurrent callers both get through — passes on the matrix's floor and fails to reproduce on every other column, which is a flake reporting the opposite of what it means. docs/work/mvp/phase4/phase4c/2026-09-08-phase4c-stage-pipeline-design.md therefore ships no such test and records the limit as deviation P4-33 instead. A phase-6 pillar-step author reading that rule alone would reasonably conclude the runtime catches every reuse; it catches every sequential one. A second thing neither entry can say, and it is about the fork rather than the latch. Both are written as "fork a fresh cursor per re-drive", which describes the reference's shape: drive 1 runs on the handle and drives 2..n on copies. This port's rule is the other one — a pillar step that may drive more than once forks for every drive, the first included, never calls its own #call, and #fork on a cursor whose #call is already spent raises Dexpace::PipelineError — because the fork is also where cursor-scoped state is written (docs/sdk-design-ruby/05-pipeline-architecture.md §5.1, and §6.2's cross-origin marker), so a first drive that ran on the un-forked handle would have no stage slot to write and hop 1 could publish nothing. Forking earlier than the requirement demands is inside it: what PIPE-15 forbids is reusing a spent handle. The consequence for a phase-6 redirect or retry step is concrete and is the reason this is recorded here rather than only in the phase document: writing the mixed shape the harvested sentence describes compiles, passes every ordering test, and silently drops whatever the pillar meant to publish on its first drive. review · docs/work/mvp/phase4/phase4c/2026-09-08-phase4c-stage-pipeline-design.md · high · sha:manual-phase4c-cursor-latch