writeonce/docs/stories/language-runtime-database/31-actor-lifecycle.md
shoney.arickathil 02b4b13a52 Merge master into db-residency-doctrine — and close the two half-exposed features
The branch was 17 ahead / 25 behind with 11 conflicting files, and drifting
further: db.c had been rewritten twice on master since (group commit, then
compaction). Resolved rather than rebased so both histories stay legible.

Conflicts, and how each was settled:

- db.c: BOTH semantics kept. Master's fatal path and compaction check now sit
  behind the branch's `table_is_durable` predicate, in all three inline arms —
  a volatile table reaches neither the barrier nor the compaction check
- db-bench sample: every mode from both sides (growth, growth-verify, randread,
  replayseed, wmix) and ONE `boot` mode, which both sides had added
  independently
- db-bench.py: all six legs kept. Both sides had also grown the same
  WAL-size helper under different names; collapsed into one
- perf-targets: the branch's §5 (RAM ceiling) then master's §6/§7 — master's
  numbering had already assumed a §5 it did not have
- story frontmatter: master's `status` (the landing truth) plus the branch's
  `readiness` axis. 03 would have read `done` + `refine`, which is a
  contradiction — it was brainstormed and landed on master, so `ready`
- board: both standup blocks newest-first; master's chain rows (a superset);
  the branch's databasev2 1-2 rows with master's 3-4. Fixed a stray `|` in
  master's row 3
- baseline: master's, then REGENERATED from a full campaign — 143 metrics,
  132 checks, 0 failures with both sides' legs present

TWO HALF-EXPOSED FEATURES FIXED, because the merge rule is that master gets
no feature that is honoured in name only:

- `resident: keys` PARSED, set a .wob flag, and did nothing: rows stayed fully
  resident. A developer could declare a 120 GB table keys-resident, watch it
  compile, and be OOM-killed. The loader now REFUSES it with a message naming
  what to write instead, until tasks 5c/5d land. The compiler still parses it
  and its AST golden still passes, so the grammar work stays tested
- `durable: false` was honoured ONLY on the inline path. wo_db_exec_req had no
  guard at all, so a volatile table written from an actor on a worker shard
  would still be logged — precisely porch's session-table case, and precisely
  what iteration 2 exists to provide. All three request-path arms now carry the
  same predicate. Found by reading the merged code, not by a test: the obvious
  probe runs main() on the primary and therefore only exercises the inline path

Verified on the merged tree: wovm-test 0, woc-test 0, oop-e2e 122/0,
residency-accept 8/0, db-bench 132/0, linkcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 10:14:25 +02:00

5.8 KiB

iteration status readiness chain
31 done ready 3

Iteration 31 — actor lifecycle: request/response, backpressure, death, timers

Format: product/story-iteration-template. Part of Story — one language, one runtime, one database, one binary.

Inserted 2026-08-21 (concurrency-chain re-sequence; the iteration was named as "new 31" in the 2026-08-20 code-review re-sequence — this is its story file). Third in the chain, stage 3 → 22 → 31 → 24 → 23 → 32: chat (iteration 24) cannot be written honestly without these four mechanisms.

✅ LANDED 2026-08-27 — INSIDE 24, per the 2026-08-23 directive that absorbed it. All four mechanisms shipped: call/reply with a typed scalar reply (id 88, WO-E226), bounded mailboxes (WO_MAILBOX, default 1024, fail-fast with a catchable WO_T_ACTOR), actor death that traps callers instead of hanging them, monitor (id 89) and time.after (id 90). Ids 89 and 90 were reserved holes in wob.h; they are filled.

A fifth mechanism was added that this story did not anticipate: the shutdown drain guarantee, 40. It is lifecycle semantics — this story gave actors a death notice, 40 gives the program a shutdown that does not lose mail — and it was found by measurement while proving 24's gate, not by review.

How each piece works: runtime/src/CODE-LOGIC.md, "Actor lifecycle".

Why this iteration exists

The arc's stages 1+2 shipped spawn/send mechanism without lifecycle: send is one-way and callers sleep-poll to await an answer; the mailbox FIFO grows without bound (a hot sender can exhaust a shard's memory); an actor that traps dies silently (nobody learns, nothing restarts, its mailbox rots); and there is no timer surface beyond a fiber blocking in time.sleep. Every real serving program — chat first — is made of request/response turns, bounded queues, death notices, and deadlines. Without this iteration the arc is a demo, not a runtime.

Goals

  • Request/response over one-way sends. A caller can send and park until the reply arrives — one surface, no sleep-polling, no second concurrency vocabulary. Ownership rules unchanged: the request moves, the reply moves back.
  • Bounded mailboxes with a stated backpressure policy. A mailbox has a cap; what happens at the cap (park the sender vs error) is decided by the spec, one policy, doctrine-pure — no silent unbounded growth anywhere in the runtime.
  • Actor death is observable. A trap or normal exit produces a signal another actor can receive; a fiber-trap already isolates (stage 1) — this makes the fact of death deliverable, so a supervisor CAN be written in .wo.
  • Timers as messages. A deadline or interval delivers to a mailbox like any other send, riding the shard's existing io_uring/epoll timeout plumbing (arc T4) — no new event loop.

Acceptance Criteria (draft — the spec refines)

  • What to achieve?
    • Given an actor that answers requests,
    • when a caller awaits the reply,
    • then the caller's fiber parks (the shard serves other fibers, TID-verified), resumes with the moved reply, and never busy-waits.
  • What to achieve?
    • Given a mailbox at its cap,
    • when another send arrives,
    • then the stated backpressure policy fires deterministically, memory stays bounded (RSS flat under a hot-sender soak), and no message is silently dropped.
  • What to achieve?
    • Given an actor that traps mid-message,
    • when it dies,
    • then its drop maps run (ASan zero leaks), a death signal reaches the observer that asked for one, and a supervisor written in .wo can respawn it.
  • What to achieve?
    • Given a timer armed by an actor,
    • when it fires,
    • then the actor receives it as an ordinary message on its own shard, and cancelling before expiry means it never delivers.

Out Of Scope

  • Supervision TREES / OTP-scale restart policy — a .wo library once death signals exist, not runtime policy.
  • Priorities and custom scheduling — the reduction budget stays the only fairness mechanism.
  • Distributed (cross-process) supervision — no network layer exists.
  • Changing the ownership-move rule or the unified address surface — iteration 8's decisions stand.

Info

Forks the spec must settle:

  1. The request/response surface. A reply-address baked into the message shape vs a runtime-level call that parks — and what the compiler checks (does a request type name its reply type?).
  2. The backpressure policy at the cap. Park the sender (natural with fibers, risks deadlock cycles) vs fail the send (explicit, pushes handling to the program). One policy, stated; not configurable per mailbox in v1.
  3. The death-signal shape. Erlang's link (bidirectional, dies together) vs monitor (one-way notice) — likely monitor-only v1.
  4. The timer surface. Builtin (time.after delivering a message) vs actor-spawned sleeper fiber — and cancellation semantics.

Sources: the 2026-08-20 code-review findings (the gaps this iteration answers), the arc plan's stage-2 deviations (2026-08-20-shard-fiber-arc.md — the unbounded FIFO is deviation 4's mutex inbox), and BEAM precedent already surveyed in docs/plan/exploration/fibers/00-fibers.md.

Proposed Solution

Brainstorm → spec → plan after the arc's stage 3 lands and iteration 22 has its baseline (the mailbox-cap and inbox-ring decisions want 22's mutex number). The spec is written against iteration 24's needs — chat names the lifecycle mechanisms it consumes, this iteration names chat as its first honest consumer.