subetha-cxc 0.1.4

MMF-backed cross-process IPC primitives for SubEtha: SharedRing, SharedHashMap, SharedRWLock, SharedSemaphore, SharedLRUCache, OwnerLease, HeartbeatTable, plus 30+ more. One byte layout serves cross-thread, cross-process, and disk-persistent.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# BackgroundScheduler

![Rust](https://img.shields.io/badge/Rust-1.96+-orange?logo=rust)
![Edition](https://img.shields.io/badge/Edition-2024-blue)
![Layout](https://img.shields.io/badge/Layout-MMF--backed-green)
![Protocol](https://img.shields.io/badge/dispatch-Pass_via_ring-brightgreen)
![Cross-Process](https://img.shields.io/badge/Cross--Process-yes-success)
![Failover](https://img.shields.io/badge/heartbeat--driven-yes-informational)

Autonomous Pass executor backed by [`SharedRing`](./SHARED_RING.md)
+ [`HeartbeatTable`]./HEARTBEAT.md +
[`FailoverWatchdog`]./FAILOVER.md +
[`pass_registry`]./PASS_REGISTRY.md. Each process opens shared
submit + result rings, registers in the heartbeat table, and
drives one worker thread that drains the submit-ring, executes
the Pass via the closure registry, and pushes the result onto
the result-ring. Cross-thread + cross-process + disk durability
from the same MMF substrate.

> **The "cross-process Pass dispatch with auto-failover"
> primitive.** Submit at **219 ns** vs `mpsc::sync_channel`
> 204 ns (1.07x slower; tied). watchdog_scan at 18.91 ns for an
> 8-slot heartbeat (~2.36 ns per slot). try_recv at 254 ns
> (full submit -> worker -> result -> recv path). Architectural
> lever stacks: cross-process visibility AND durable ring (file
> IS the queue) AND auto-failover, at zero measurable cost over
> the in-process mpsc baseline.

**Constraints (read first):**

- **Native sidecar integration**: `BackgroundScheduler` carries a `HandshakeHeader` + `ObservationRing` and implements `subetha_sidecar::AdaptiveInstance`. `Submitter` / `ResultCollector` handles forward their observations through the underlying `SharedRing`'s sidecar (no separate wrapper-level ring). Wrap in `SidecarBox::new` to register with the global sidecar; raw `start()` returns the unregistered type unchanged.

- **Pass payload bounded by `MAX_ARG_LEN`** (46 bytes within
  the 56-byte ring slot, minus header). Larger args need a
  side-channel.
- **Wire format**: `[closure_id u32][result_token u32][arg_len
  u16][args...]` for submit; `[result_token u32][status
  u8][result_len u16][result...]` for result.
- **`result_token` is caller-supplied correlation ID**:
  echoed in the result-ring payload so the originator matches
  results to submissions.
- **Worker drops results when result-ring is full**: no
  blocking on result-push (the worker stays unblocked even
  when no consumer is reading).
- **Worker beats heartbeat every 100 iterations** of its
  drain loop. `mark_in_flight` flips a bit during execution;
  `clear_in_flight` clears it on completion.
- **`Drop` on the scheduler signals stop + joins the worker**.
- **Three MMF files**: submit ring + result ring + heartbeat
  table. Pass paths; the scheduler creates-or-opens each.
- **Cross-process backed by MMF.**

---

## Table of contents

- [What it is]#what-it-is
- [Wire format]#wire-format
- [Worker loop]#worker-loop
- [Bench evidence]#bench-evidence
- [Worked examples]#worked-examples
- [Use case patterns]#use-case-patterns
- [Known limitations]#known-limitations
- [Common pitfalls]#common-pitfalls
- [References]#references

---

## What it is

```text
                  Producer side                     Consumer side
+--------------------+              +----------------------+
| Submitter          |              | ResultCollector      |
|   submit(pass)     |              |   try_recv()         |
|   -> result_token  |              |   -> SubmittedResult |
+----------|---------+              +----------^-----------+
           |                                   |
           v                                   |
+-----------------------+         +----------------------+
| SharedRing submit     |         | SharedRing result    |
|   <base>.submit.bin   |         |   <base>.result.bin  |
+-----------|-----------+         +----------^-----------+
            |                                |
            v                                |
   +---------------------------+              |
   | Worker thread (per proc)  |              |
   |   1. drain submit ring    |              |
   |   2. mark_in_flight bit   |              |
   |   3. pass_registry exec   |--------------+
   |   4. push to result ring  |
   |   5. clear in_flight bit  |
   |   6. heartbeat.beat        |
   +---------------------------+
                  |
                  v
        +------------------+
        | HeartbeatTable   |  <base>.hb.bin
        +------------------+
                  ^
                  |
        +------------------+
        | FailoverWatchdog |  (coordinator process only)
        |   .scan() ->     |
        |   ReclaimReport  |
        +------------------+
```

Three MMF files. Multiple processes can each `start()` against
the same paths; they share the rings and the heartbeat. One
process (typically the coordinator) periodically calls
`watchdog_scan()` to detect dead workers and reclaim their
in-flight bits.

---

## Wire format

### Submit ring slot (56 bytes)

```text
| offset 0  | closure_id (u32 LE)    |
| offset 4  | result_token (u32 LE)  |
| offset 8  | arg_len (u16 LE)       |
| offset 10 | args (variable bytes)  |
```

`MAX_ARG_LEN = 46` (56 - 10).

### Result ring slot (56 bytes)

```text
| offset 0  | result_token (u32 LE)  |
| offset 4  | status (u8)            |
| offset 5  | result_len (u16 LE)    |
| offset 7  | result (variable)      |
```

Status 0 = Ok, status 1 = Err (with stringified error). `MAX_RESULT_LEN = 49`.

---

## Worker loop

```text
loop:
   if stop.load(Acquire): break
   beat_counter += 1
   if beat_counter % 100 == 0: heartbeat.beat(my_slot)
   match submit_ring.try_pop:
       Ok(slot):
           pass, token = parse_submit_slot(slot)
           bit = (token & 0x3F) as u8
           heartbeat.mark_in_flight(my_slot, bit)
           result = pass_registry::execute(&pass)
           result_ring.try_push(encode_result_slot(token, result)).ok()
           heartbeat.clear_in_flight(my_slot, bit)
       Err(Empty):
           sleep 100us               # idle backoff
       Err(_):
           break
heartbeat.unregister(my_slot)
```

The worker is one autonomous thread per process. Multiple
workers across processes naturally load-balance via the
single shared submit ring (each `try_pop` is one CAS).

---

## Bench evidence

Bench harness: `crates/subetha-cxc/benches/scheduler.rs`. Captured
2026-06-02 on Windows 11 / Zen+ R7 2700, Criterion with
`--sample-size=15 --warm-up-time=1 --measurement-time=2`.

Workload: Pass with 4-byte args, echo closure (returns args).
Worker drains concurrently (pre-spawned at scheduler start).

| Op | `BackgroundScheduler` (mmf) | `mpsc::sync_channel<Pass>` | Relative |
|---|---:|---:|---|
| submit (encode + ring push + opp. recv) | 219.25 ns | 204.49 ns | tied (mmf 1.07x slower) |
| try_recv (encode + recv + decode + submit-feed) | 254.30 ns | n/a | full path |
| watchdog_scan (8-slot heartbeat) | 18.91 ns | n/a | ~2.36 ns/slot |

### Reading the trade-offs

1. **Submit is tied with mpsc::sync_channel.** Both contenders
   pay: encode the Pass + push to queue + opportunistically
   drain. The MMF substrate adds zero measurable cost over the
   in-process std queue. The Vyukov MPMC ring + 56-byte slot
   encode equals the cost of `mpsc::SyncSender::send` + Vec
   allocation for the Pass.
2. **try_recv at 254 ns** measures the full submit -> worker
   pickup -> execute -> result push -> recv loop. The
   worker's idle-sleep of 100 µs means recv is bottlenecked on
   the worker's wake-up cadence; under steady submission
   pressure the worker stays awake.
3. **watchdog_scan at 18.91 ns** is the same scan pattern as
   `failover.scan`: O(slots) atomic loads, dead count is free.
4. **The architectural lever is cross-process + durable +
   failover.** mpsc::sync_channel is in-process only and has
   no failover. The scheduler's ring file IS the persistent
   queue.

### Rule 3b bench audit

- **Fair contender**: `mpsc::sync_channel<Pass>` is the std
  in-process Pass-dispatch equivalent. Same payload shape, same
  encode cost (Vec arg in Pass), same drain pattern.
- **Worker pre-spawned**: `BackgroundScheduler::start()` spawns
  the worker thread once before the bench; no `thread::spawn`
  inside `b.iter`. The mpsc baseline pre-spawns its drain
  thread similarly.
- **Sizing**: ring 1024 slots (no overflow at criterion's iter
  counts); heartbeat 8 slots (typical scheduler pool size).
- **MMF lifecycle managed**: scheduler created + ops + dropped +
  3 files removed per bench function.

### What the numbers do NOT show

- **Cross-process Pass dispatch**: process A submits, process B's
  worker drains and executes. The mpsc baseline cannot do this.
- **Crash recovery**: a worker process crashes mid-pass; the
  submit ring's items are still in the durable file. A
  restarted worker (or another worker process) picks them up.
- **Auto-failover via watchdog**: dead workers' heartbeat lapses;
  the watchdog reclaims their in-flight bits; their in-progress
  work is reassigned.
- **Multi-process load balancing**: N worker processes drain the
  same submit ring; each `try_pop` is one CAS, scaling without
  serialization.

---

## Worked examples

### Single-process worker pool

```rust
use subetha_cxc::{BackgroundScheduler, Pass, pass_registry};

// Register closures at startup.
pass_registry::register(0x1234, |args| Ok(args.to_vec()));

let sched = BackgroundScheduler::start(
    "/tmp/sched-submit.bin",
    "/tmp/sched-result.bin",
    "/tmp/sched-hb.bin",
    1024,    // ring capacity
    16,      // heartbeat slots
).unwrap();

let submitter = sched.submitter();
let collector = sched.collector();

// Submit work.
let token = submitter.submit(&Pass {
    closure_id: 0x1234,
    args: vec![1, 2, 3, 4],
}).unwrap();

// Receive result (poll).
loop {
    if let Ok(r) = collector.try_recv() {
        assert_eq!(r.token, token);
        break;
    }
    std::thread::sleep(std::time::Duration::from_micros(100));
}
```

### Cross-process worker pool with coordinator

```rust
// Worker process (multiple of these):
use subetha_cxc::BackgroundScheduler;
let _sched = BackgroundScheduler::start(
    "/tmp/sched-submit.bin",
    "/tmp/sched-result.bin",
    "/tmp/sched-hb.bin",
    1024, 16,
).unwrap();
// Worker thread runs autonomously; main thread continues.
std::thread::park();  // or do other coordinator work

// Coordinator process: submit + collect + run watchdog
let sched = BackgroundScheduler::start(
    "/tmp/sched-submit.bin",
    "/tmp/sched-result.bin",
    "/tmp/sched-hb.bin",
    1024, 16,
).unwrap();
let submitter = sched.submitter();
let collector = sched.collector();
loop {
    submitter.submit(&Pass { closure_id: 0x1234, args: vec![] }).ok();
    while let Ok(r) = collector.try_recv() { handle_result(r); }
    let report = sched.watchdog_scan();
    for (slot, snap) in &report.dead_slots {
        eprintln!("dead worker pid={} slot={}", snap.pid, slot);
        reassign(snap.in_flight_bitmap);
    }
    std::thread::sleep(std::time::Duration::from_millis(100));
}
```

---

## Use case patterns

### Pattern: cross-process job queue with failover

Multiple worker processes share submit + result rings. Any
worker drains; a coordinator runs the watchdog. Crashed
workers' in-flight bits are reclaimed within `grace_epochs`.

### Pattern: durable RPC

The submit ring file IS the persistent queue. A coordinator
crash leaves submitted Pass items in the ring; a restarted
coordinator (or any other process) drains them and produces
results. No external queue service needed.

### Pattern: failure-tolerant batch dispatch

Submit N Pass items; track which `result_token`s have been
collected. On worker death (detected via watchdog),
re-submit the unhandled tokens. The result ring carries the
token in the payload so correlation is direct.

---

## Known limitations

- **Args capped at 46 bytes, result capped at 49 bytes**: same
  as `SharedRing::PAYLOAD_BYTES = 56` minus header. Larger
  payloads need pointer indirection or a side-channel.
- **Worker has 100 us idle-sleep**: bursty workloads pay up to
  100 us latency on the first submit after idle. Sustained
  submission keeps the worker awake.
- **Result dropped when result-ring is full**: the worker does
  not block on result-push. Consumers must drain promptly.
- **One worker per scheduler instance**: scaling requires
  multiple `BackgroundScheduler::start` calls (multiple worker
  threads in one process, or one per process).
- **Worker beats heartbeat every 100 iterations** of its loop:
  on idle, this is one beat every ~10 ms. Tune
  `grace_epochs` accordingly.
- **No prioritization**: the submit ring is FIFO. For
  priority dispatch, compose with
  [PriorityFanout]./PRIORITY_FANOUT.md.
- **Cross-process backed by MMF.**

---

## Common pitfalls

- **Forgetting to register the closure in every participating
  process.** The `pass_registry` is per-process; each worker
  process must call `register(id, closure)` at startup for
  every closure it intends to execute.

- **Not draining the result ring.** The worker drops results
  when the result-ring is full; submissions still produce work
  but their results are lost. The collector must drain
  steadily.

- **Treating `result_token` as a sequence number.** It is a
  correlation ID picked monotonically per submitter; different
  submitters' tokens may interleave. Use it ONLY to match
  results to submissions, not for ordering.

- **Holding the scheduler in a long-running scope without
  draining.** The internal worker keeps running but no
  external party reads results. Drop the scheduler explicitly
  (or wrap it in a tight scope) to signal stop.

- **Wrapping the submitter/collector in a Mutex.** Pointless;
  the underlying rings are lock-free MPMC.

---

## References

- Source: `crates/subetha-cxc/src/scheduler.rs` (739 lines, 8 unit
  tests including submit-execute-result round-trip,
  unknown-closure error path, args-too-large rejection,
  heartbeat registration, and 16-message in-order delivery).
- Bench: `crates/subetha-cxc/benches/scheduler.rs` (submit,
  try_recv, watchdog_scan vs `mpsc::sync_channel`).
- Underlying primitive: [SHARED_RING.md]./SHARED_RING.md -
  the Vyukov MPMC rings for submit + result.
- Underlying primitive: [HEARTBEAT.md]./HEARTBEAT.md -
  the heartbeat table workers register in.
- Underlying primitive: [PASS_REGISTRY.md]./PASS_REGISTRY.md -
  the in-process closure registry executed by the worker.
- Composes with: [FAILOVER.md]./FAILOVER.md -
  `watchdog_scan` calls `FailoverWatchdog::scan` on the
  heartbeat table.
- Composes with: [PROGRESS_TASK.md]./PROGRESS_TASK.md -
  each Pass closure runs to completion; long-running passes
  may track their own progress via a separate ProgressTask.