slick-stream-buffer-multiplexer-py 2.0.0__tar.gz → 2.2.0.post1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (17) hide show
  1. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/API_DIFFERENCES.md +64 -20
  2. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/PKG-INFO +150 -40
  3. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/README.md +147 -37
  4. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/pyproject.toml +2 -2
  5. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/requirements.txt +2 -2
  6. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/setup.py +2 -2
  7. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/slick_stream_buffer_multiplexer_py.egg-info/SOURCES.txt +1 -0
  8. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/slick_stream_buffer_multiplexer_py.py +447 -104
  9. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/tests/test_interop.py +92 -7
  10. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/tests/test_multiplexer_mpmc.py +146 -1
  11. slick_stream_buffer_multiplexer_py-2.2.0.post1/tests/test_multiplexer_queue.py +572 -0
  12. slick_stream_buffer_multiplexer_py-2.2.0.post1/tests/test_multiplexer_shm.py +714 -0
  13. slick_stream_buffer_multiplexer_py-2.0.0/tests/test_multiplexer_shm.py +0 -328
  14. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/LICENSE +0 -0
  15. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/MANIFEST.in +0 -0
  16. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/setup.cfg +0 -0
  17. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0.post1}/tests/test_multiplexer.py +0 -0
@@ -2,8 +2,9 @@
2
2
 
3
3
  The Python implementation is binary-compatible with the C++
4
4
  `slick::stream_buffer_multiplexer` at the shared memory level — the shared record queue
5
- ('SLQ1', or 'SLQ0' with `enable_read_last` off; 16-byte elements) and every producer segment
6
- ('SSB1') match byte-for-byte. Peers must agree on `enable_read_last`, the one trait recorded in
5
+ ('SLQ1', or 'SLQ0' with `enable_read_last` off; 16-byte elements), every stream buffer
6
+ producer segment ('SSB1') and every queue producer segment (a slick-queue with 1-byte elements)
7
+ match byte-for-byte. Peers must agree on `enable_read_last`, the one trait recorded in
7
8
  the queue's header (see [Traits](#traits-are-a-constructor-argument-not-a-template-parameter)).
8
9
  The *language-level* API differs in the following ways.
9
10
 
@@ -17,6 +18,11 @@ The *language-level* API differs in the following ways.
17
18
  | `add_producer(id, cap, ctl)` | `add_producer(id, capacity=cap, control_size=ctl)` |
18
19
  | `add_producer(id, cap, ctl, "name")` | `add_producer(id, capacity=cap, control_size=ctl, name="name")` |
19
20
  | `add_producer(id, "name")` | `add_producer(id, name="name")` |
21
+ | `add_queue_producer(id, size)` | `add_queue_producer(id, size=size)` |
22
+ | `add_queue_producer(id, size, items_per_slot)` | `add_queue_producer(id, size=size, items_per_slot=ips)` |
23
+ | `add_queue_producer(id, size, "name")` / `(id, size, items_per_slot, "name")` | `add_queue_producer(id, size=size, [items_per_slot=ips,] name="name")` |
24
+ | `add_queue_producer(id, "name")` | `add_queue_producer(id, name="name")` (an explicit `items_per_slot=` must match the segment) |
25
+ | `add_queue_producer(id, std::shared_ptr<queue_type>)` | `add_queue_producer(id, queue=q)` |
20
26
  | throws `std::invalid_argument` (duplicate id, bad sizes) | raises `ValueError` |
21
27
  | `uint32_t producer_id` (range enforced by the type) | raises `ValueError` outside `0..0xFFFFFFFF`, `TypeError` for a non-integer |
22
28
  | throws `std::runtime_error` (shm failures) | raises `RuntimeError`, `ValueError` (queue geometry mismatch), or `FileNotFoundError` (missing segment) |
@@ -57,16 +63,42 @@ C++ exposes both `stream_buffer()` (reference) and `stream_buffer_ptr()`
57
63
  Similarly, `get_producer_buffer()` and `find_producer()` both return the same
58
64
  `ProducerBuffer` object (or `None`).
59
65
 
66
+ ## Queue producers
67
+
68
+ C++ `producer_queue::operator[](index)` returns a `uint8_t*` to the reservation; Python's
69
+ `ProducerQueue[index]` returns a writable `memoryview` from `index` to the end of the ring's data
70
+ array, of which the reserved `n` bytes are the first `n` — write them as
71
+ `pq[index][:n] = payload`. (A reservation never splits across the end of the ring, so they are
72
+ always contiguous.) `SlickQueue`'s own `[]` is one element wide, so it cannot stand in for this.
73
+
74
+ C++ `queue_type` is `slick::queue<uint8_t, Traits>`, so adopting a queue with different traits
75
+ fails to compile. Python builds a created queue with the multiplexer's traits too, but accepts an
76
+ adopted `SlickQueue` whatever its traits — only `enable_read_last` reaches shared memory, and
77
+ slick-queue-py checks that against the segment — and requires only `element_size == 1`
78
+ (`ValueError` otherwise, `TypeError` for something that is not a `SlickQueue`).
79
+
80
+ `queue` is the single accessor for the underlying `SlickQueue`, as `stream_buffer` is for
81
+ `ProducerBuffer` (C++ has both `queue()` and `queue_ptr()`). `size`, `items_per_slot`,
82
+ `own_buffer` and `use_shm` are properties, matching `SlickQueue`'s attributes.
83
+
84
+ C++ `slick::producer_kind::stream_buffer` / `::queue` is `ProducerKind.STREAM_BUFFER` /
85
+ `ProducerKind.QUEUE`, on each handle as `.kind`.
86
+
87
+ The payload-retention check (a queue message is skipped once the reservation counter is more than
88
+ one ring past it) runs after `read()` has copied the bytes, so in Python it covers the copy handed
89
+ back; in C++, which hands back a pointer, a producer can still overwrite the bytes after the check.
90
+
60
91
  ## Producer lookup
61
92
 
62
- C++ uses a dense vector for producer ids < 4096 with a hash-map fallback; Python uses a
63
- plain dict for all ids (already O(1) — the dense-path micro-optimization is meaningless
64
- in Python).
93
+ C++ uses one dense vector of both kinds for producer ids < 4096 with a hash-map fallback;
94
+ Python uses one plain dict of both kinds for all ids (already O(1) — the dense-path
95
+ micro-optimization is meaningless in Python), and each handle dereferences its own records where
96
+ C++ branches on the kind.
65
97
 
66
- `get_producer_buffers()` returns a `const` map reference in C++; Python returns a cached
67
- read-only `types.MappingProxyType` over the same dict — mutation raises, later
68
- `add_producer()` calls still show through, and the call allocates nothing. Iteration order
69
- is insertion order, which C++'s `unordered_map` does not promise.
98
+ `get_producer_buffers()` and `get_producer_queues()` return `const` map references in C++;
99
+ Python returns cached read-only `types.MappingProxyType`s over the same dicts — mutation raises,
100
+ later registrations still show through, and the call allocates nothing. Iteration order is
101
+ insertion order, which C++'s `unordered_map` does not promise.
70
102
 
71
103
  ## Traits are a constructor argument, not a template parameter
72
104
 
@@ -122,25 +154,37 @@ either way. All counters are per-instance, not shared through the segments.
122
154
  traits with `count_loss` off, so that counter only moves for reads the caller makes directly
123
155
  through `producer.stream_buffer.read(cursor, traits)`.
124
156
 
125
- ## producer_buffer is not movable in C++
157
+ `ProducerQueue.loss_count()` is the inner queue's own counter on both sides, and the multiplexer's
158
+ lapped dereferences reach it on both sides. It moves by default in Python only because
159
+ slick-queue-py's default traits count; in a C++ release build of the default configuration it
160
+ reads 0.
126
161
 
127
- C++ deletes `producer_buffer`'s copy and move operations, because the multiplexer keeps a raw
128
- pointer to the same object its `shared_ptr` names, and moving one out would leave both pointing
129
- at an emptied object. Python has no moves and rebinding a name copies a reference, so there is
130
- nothing to delete — `ProducerBuffer` has no equivalent restriction.
162
+ ## Producer handles are not movable in C++
163
+
164
+ C++ deletes the copy and move operations of `producer_buffer` and `producer_queue`, because the
165
+ multiplexer keeps a raw pointer to the same object its `shared_ptr` names, and moving one out
166
+ would leave both pointing at an emptied object. Python has no moves and rebinding a name copies a
167
+ reference, so there is nothing to delete — `ProducerBuffer` and `ProducerQueue` have no
168
+ equivalent restriction.
131
169
 
132
170
  ## Lifecycle
133
171
 
134
172
  Python adds explicit lifecycle management that C++ handles via RAII:
135
173
 
136
- - `close()` — detach from all producer segments and the queue segment; the registration table is
137
- kept, so a later `unlink()` still reaches every producer
138
- - `unlink()` — delete the queue segment and all registered producers' segments; valid before or
139
- after `close()` (close every handle, then unlink once), and a no-op when repeated
174
+ - `close()` — detach from all producer segments, of both kinds, and the queue segment; the
175
+ registration tables are kept, so a later `unlink()` still reaches every producer
176
+ - `unlink()` — delete the queue segment and all registered producers' segments, of both kinds;
177
+ valid before or after `close()` (close every handle, then unlink once), and a no-op when repeated
140
178
  - context manager support: `with StreamBufferMultiplexer(...) as mux: ...`
141
- - `get_shm_name()` — on the multiplexer (queue segment) and on each `ProducerBuffer`,
142
- the exact names to pass to C++ (`/`-prefixed on POSIX)
179
+ - `get_shm_name()` — on the multiplexer (queue segment) and on each `ProducerBuffer` and
180
+ `ProducerQueue`, the exact names to pass to C++ (`/`-prefixed on POSIX)
143
181
 
144
182
  `remove(name)` is the one lifecycle call both sides share, with the same contract: unlink a
145
183
  segment by name without attaching to it, returning `True` on success and, on Windows, always
146
184
  `True` because a section dies with its last handle.
185
+
186
+ Neither side deletes a segment on its own: not the C++ destructors (since v2.1.0), and not
187
+ Python's `close()`, context manager or process exit. Python's dependencies keep their segments out
188
+ of `multiprocessing`'s resource tracker for this, which would otherwise unlink them when the
189
+ process exits. Each inner ring's own `remove_shm()` is reachable on both sides, through
190
+ `stream_buffer` / `queue` in Python and `stream_buffer()` / `queue()` in C++.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slick-stream-buffer-multiplexer-py
3
- Version: 2.0.0
3
+ Version: 2.2.0.post1
4
4
  Summary: Lock-free MPMC byte stream multiplexer with C++ interoperability via shared memory
5
5
  Home-page: https://github.com/SlickQuant/slick-stream-buffer-multiplexer-py
6
6
  Author: Slick Quant
@@ -28,8 +28,8 @@ Classifier: Operating System :: MacOS
28
28
  Requires-Python: >=3.8
29
29
  Description-Content-Type: text/markdown
30
30
  License-File: LICENSE
31
- Requires-Dist: slick-queue-py>=2.0.0
32
- Requires-Dist: slick-stream-buffer-py>=2.0.0
31
+ Requires-Dist: slick-queue-py>=2.3.0
32
+ Requires-Dist: slick-stream-buffer-py>=2.1.0
33
33
  Dynamic: author
34
34
  Dynamic: home-page
35
35
  Dynamic: license-file
@@ -45,29 +45,36 @@ consumers can be mixed across processes, fanning into the same shared memory seg
45
45
  every peer of a shared queue agrees on `enable_read_last` (see
46
46
  [`enable_read_last` must match on every peer](#enable_read_last-must-match-on-every-peer)).
47
47
 
48
- Tracks C++ `slick/stream_buffer_multiplexer.hpp` **v2.0.0**, and requires slick-queue-py >=
49
- 2.0.0 and slick-stream-buffer-py >= 2.0.0. The shared-memory formats are unchanged
48
+ Tracks C++ `slick/stream_buffer_multiplexer.hpp` **v2.2.0**, and requires slick-queue-py >=
49
+ 2.3.0 and slick-stream-buffer-py >= 2.1.0. The shared-memory formats are unchanged
50
50
  (`'SLQ1'` / `'SSB1'`), so segments written by v0.1.0 or by a C++ v1.x process are still
51
51
  readable with the default traits.
52
52
 
53
53
  ## Concept
54
54
 
55
- Each producer owns its own [slick-stream-buffer](https://github.com/SlickQuant/slick-stream-buffer)
56
- (an independently-sized byte ring with per-producer lap/loss detection). `consume()` additionally
57
- publishes a small 16-byte `{sequence, producer_id}` record into one shared
58
- [slick-queue](https://github.com/SlickQuant/slick-queue), which acts as the lock-free MPMC
59
- fan-in / global ordering point. Consumers read from the shared queue and dereference each record
60
- back into the matching producer's buffer.
55
+ Each producer owns its own byte ring, of one of two kinds, freely mixed under one multiplexer and
56
+ one `producer_id` space:
57
+
58
+ - a [slick-stream-buffer](https://github.com/SlickQuant/slick-stream-buffer) (an
59
+ independently-sized byte ring with per-producer lap/loss detection), written with
60
+ `prepare`/`commit`/`consume` from one thread;
61
+ - a byte [slick-queue](https://github.com/SlickQuant/slick-queue) (`element_size=1`, the C++
62
+ `slick::queue<uint8_t>`), written with `reserve`/`publish`, which several threads may do at once.
63
+
64
+ Publishing through either additionally publishes a small 16-byte `{sequence, producer_id}` record
65
+ into one shared slick-queue, which acts as the lock-free MPMC fan-in / global ordering point.
66
+ Consumers read from the shared queue and dereference each record back into the matching
67
+ producer's ring, getting `(data, length, producer_id)` whichever kind published it.
61
68
 
62
69
  ```
63
70
  Producer A ──prepare/commit/consume──▶ [StreamBuffer A] ──┐
64
71
  Producer B ──prepare/commit/consume──▶ [StreamBuffer B] ──┼─▶ {sequence, producer_id}
65
- Producer N ──prepare/commit/consume──▶ [StreamBuffer N] ──┘ 16-byte records
72
+ Producer C ──reserve/write/publish───▶ [byte SlickQueue C]┘ 16-byte records
66
73
  │
67
74
  [shared SlickQueue]
68
75
  │
69
76
  consumers read(cursor) and dereference
70
- into StreamBuffer[producer_id]
77
+ into the ring of producer_id
71
78
  ```
72
79
 
73
80
  Producers and the shared queue each independently choose local memory or shared memory (IPC).
@@ -77,8 +84,10 @@ records referencing other producer ids are silently skipped (not counted as loss
77
84
  ## Requirements
78
85
 
79
86
  - Python 3.8+
80
- - [slick-queue-py](https://github.com/SlickQuant/slick-queue-py) >= 2.0.0
81
- - [slick-stream-buffer-py](https://github.com/SlickQuant/slick-stream-buffer-py) >= 2.0.0
87
+ - [slick-queue-py](https://github.com/SlickQuant/slick-queue-py) >= 2.3.0 (queue producers need
88
+ its `items_per_slot`; 2.3.0 is the release whose segments outlive their creator)
89
+ - [slick-stream-buffer-py](https://github.com/SlickQuant/slick-stream-buffer-py) >= 2.1.0 (likewise
90
+ for segment lifetime - see [Segment lifetime](#segment-lifetime))
82
91
 
83
92
  Both dependencies bundle a small C++ extension for `std::atomic` operations; this package itself
84
93
  is pure Python.
@@ -125,12 +134,53 @@ while True:
125
134
  handle_message(rec.producer_id, rec.data, rec.length)
126
135
  ```
127
136
 
137
+ ### Queue producers
138
+
139
+ A producer can be a byte slick-queue instead. Register it with `add_queue_producer()` and publish
140
+ through the `ProducerQueue` it returns, which has slick-queue's own producer interface:
141
+
142
+ ```python
143
+ # 1 MB byte queue, minimum 64 bytes per reservation (both must be powers of 2)
144
+ fills = mux.add_queue_producer(2, size=1 << 20, items_per_slot=64, name="fills")
145
+
146
+ index = fills.reserve(len(message)) # len(message) contiguous bytes; never blocks
147
+ fills[index][:len(message)] = message # fills[index] is a writable view from index onward
148
+ fills.publish(index, len(message)) # publish as ONE message + fan into the shared queue
149
+ ```
150
+
151
+ One message is the bytes of one `reserve()`/`publish()` pair, and consumers get it exactly as they
152
+ get a stream buffer message. What differs is the producer side:
153
+
154
+ - **Several threads may publish through one `ProducerQueue`.** slick-queue is MPMC, and so is the
155
+ fan-in. Each thread's own messages stay in order.
156
+ - **Give a large queue an `items_per_slot`.** It is the minimum number of bytes one `reserve()`
157
+ consumes, and slick-queue keeps one 16-byte control slot per unit of it. Without it there is a
158
+ slot per byte - 16 MB of control for 1 MB of data. See
159
+ [slick-queue-py](https://github.com/SlickQuant/slick-queue-py#byte-buffers-and-items_per_slot).
160
+ - **A message is limited to the queue's size.** `reserve()` raises `RuntimeError` for a larger
161
+ one and `ValueError` for 0, as `SlickQueue.reserve()` does.
162
+
163
+ The forms mirror the C++ overloads: `add_queue_producer(id, size=...[, items_per_slot=...])` for
164
+ local memory, add `name=...` to create a segment, `add_queue_producer(id, name=...)` to open one
165
+ (its size and `items_per_slot` come from the segment), or `add_queue_producer(id, queue=q)` to
166
+ adopt a byte `SlickQueue` you already have. Only what is published through the handle is
167
+ multiplexed: a message put on an adopted queue directly reaches whoever reads that queue, but
168
+ `mux.read()` never returns it.
169
+
170
+ `producer_id` is one namespace across both kinds: an id registered with `add_producer()` cannot be
171
+ registered again with `add_queue_producer()`, or the other way round. `has_producer()` and
172
+ `producer_count()` cover both; `get_producer_queue()`, `find_producer_queue()` and
173
+ `get_producer_queues()` are the queue counterparts of the stream buffer accessors, and each kind's
174
+ accessors return `None` for the other kind's ids.
175
+
128
176
  ### Work-stealing consumers (each message to exactly one consumer)
129
177
 
130
178
  ```python
131
179
  from slick_stream_buffer_multiplexer_py import AtomicCursor
132
180
  from multiprocessing.shared_memory import SharedMemory
133
181
 
182
+ # On Python 3.13+ add track=False, or the resource tracker unlinks the cursor's
183
+ # segment when this process exits - see Segment lifetime
134
184
  cursor_shm = SharedMemory(name="mux_cursor", create=True, size=8)
135
185
  shared_cursor = AtomicCursor(cursor_shm.buf, 0)
136
186
 
@@ -191,14 +241,17 @@ slick::basic_stream_buffer_multiplexer<no_read_last_traits> mux("mux_records");
191
241
 
192
242
  The multiplexer itself never calls `read_last()` (`initial_reading_index()` reads the reservation
193
243
  counter instead), so turning it off only drops one CAS from each `consume()`'s publish into the
194
- shared queue. Producer segments (`'SSB1'`) carry no such flag.
244
+ shared queue. Stream buffer producer segments (`'SSB1'`) carry no such flag. Queue producers are
245
+ built with the multiplexer's traits, as in C++, so their segments carry the same marker and the
246
+ same rule applies to them.
195
247
 
196
248
  ## Three Loss Counters
197
249
 
198
250
  | Counter | Meaning |
199
251
  |---|---|
200
252
  | `mux.loss_count()` | shared-queue wrap loss **plus** multiplexer-level loss (registered producers lapped before dereference); both terms switched by `traits.enable_loss_detection` |
201
- | `producer.loss_count()` | that producer's own inner-ring loss — **only** for reads the caller makes through `producer.stream_buffer.read(cursor, traits)` with counting traits |
253
+ | `ProducerBuffer.loss_count()` | that producer's own inner-ring loss — **only** for reads the caller makes through `producer.stream_buffer.read(cursor, traits)` with counting traits |
254
+ | `ProducerQueue.loss_count()` | the inner queue's own counter — a diagnostic of that queue, **not** a record count (see below) |
202
255
  | unregistered producer ids | **never counted** — silently skipped |
203
256
 
204
257
  Only the multiplexer-level term is filtered by registration. The wrap-loss term counts entries
@@ -212,35 +265,79 @@ dereferences through traits with `count_loss` off, because `read()`'s counter me
212
265
  a *sequential scan* jumped, which for a jump-read is a distance every later lapped dereference
213
266
  would add all over again. The multiplexer counts one loss per lapped record instead.
214
267
 
215
- `mux.get_producer_buffers()` returns every registered producer keyed by `producer_id` — a
216
- read-only live view of the registration table, cheap enough for a monitoring loop:
268
+ `ProducerQueue.loss_count()` cannot be kept clean the same way: slick-queue configures loss
269
+ detection per queue, not per call, so with counting traits (slick-queue-py's default) every
270
+ dereference that finds its record lapped adds slick-queue's "how far has this slot moved on"
271
+ distance to it - and each later lapped dereference adds its own again. Ten one-byte messages
272
+ through a 4-byte queue lose 6 records, which `mux.loss_count()` reports as 6 while that queue's
273
+ own counter reads 32. Read loss off the multiplexer.
274
+
275
+ **A queue message is lost once its bytes may be overwritten, not only its slot.** Messages differ
276
+ in length, so a later reservation can cover an older message's bytes without landing on its
277
+ control slot: in an 8-byte queue, `"ijkl"` at index 8 overwrites `"AB"` at 0 and `"CD"` at 2 while
278
+ `"CD"`'s slot still names it. `read()` therefore also skips (and counts) any queue message the
279
+ reservation counter has moved more than one ring past. `read()` copies the bytes before it checks,
280
+ so the check covers the copy you get back. It is conservative, as in C++: a reservation that does
281
+ not fit before the end of the ring skips the rest of the lap, so the counter also passes indices
282
+ nothing wrote, and a message lying in that skipped tail is reported lost although its bytes are
283
+ intact.
284
+
285
+ `mux.get_producer_buffers()` returns every registered stream buffer producer keyed by
286
+ `producer_id`, and `mux.get_producer_queues()` every queue producer — read-only live views of the
287
+ registration tables, cheap enough for a monitoring loop:
217
288
 
218
289
  ```python
219
290
  inner_loss = sum(p.loss_count() for p in mux.get_producer_buffers().values())
291
+ queues = {pid: p.size for pid, p in mux.get_producer_queues().items()}
220
292
  ```
221
293
 
222
- ## Shutdown
294
+ ## Segment lifetime
223
295
 
224
- Close every handle, then unlink once — the usual `shared_memory` idiom:
296
+ Closing a multiplexer or a producer handle, leaving a `with` block, or exiting the process never
297
+ deletes a shared-memory segment - not even one this process created - matching C++ v2.1.0 and
298
+ later. On POSIX a name therefore stays in `/dev/shm` until it is removed explicitly, and a creator
299
+ that finds it attaches to the existing segment instead of starting fresh: the shared record queue
300
+ still holds its records, a producer's `own_buffer` is `False` - for either kind of producer - and a
301
+ creator passing a different geometry raises. That is what lets a restarted producer rejoin
302
+ consumers that are still attached.
303
+
304
+ slick-queue-py (2.3.0+) and slick-stream-buffer-py (2.1.0+) keep their segments out of
305
+ `multiprocessing`'s resource tracker for this, since it would otherwise unlink them when the
306
+ process exits - before Python 3.13, even in a process that only attached. A `SharedMemory` your own
307
+ code opens, such as one holding an `AtomicCursor`, is still tracked unless you pass `track=False`.
308
+
309
+ Whichever process coordinates the lifetime deletes the segments - at shutdown once no peer will
310
+ attach again, by closing every handle and then unlinking once (the usual `shared_memory` idiom):
225
311
 
226
312
  ```python
227
313
  mux.close() # or leave a `with StreamBufferMultiplexer(...) as mux:` block
228
- mux.unlink() # deletes the record queue and every registered producer's segment
314
+ mux.unlink() # deletes the record queue and every registered producer's segment, both kinds
229
315
  ```
230
316
 
231
- `close()` keeps the registration table so `unlink()` can still reach each producer's segment, and
232
- `unlink()` runs once: repeating it never deletes a segment a later run has created under the same
233
- name. On Windows `unlink()` is a no-op — a section disappears once its last handle closes.
317
+ or at startup, by name, for a clean start (see below). `close()` keeps the registration table so
318
+ `unlink()` can still reach each producer's segment, and `unlink()` runs once: repeating it never
319
+ deletes a segment a later run has created under the same name. On Windows `unlink()` is a no-op —
320
+ a section disappears once its last handle closes.
321
+
322
+ > **Upgrading from 2.0.** Before slick-queue-py 2.3.0 and slick-stream-buffer-py 2.1.0, a segment
323
+ > on POSIX was unlinked by the resource tracker when the process that created it exited - and,
324
+ > before Python 3.13, when any process that attached to it did. A producer that exited first
325
+ > therefore orphaned every consumer still attached. Code that relied on that cleanup now leaves
326
+ > segments behind and attaches to them on its next run - call `unlink()` at shutdown or
327
+ > `remove(name)` at startup.
234
328
 
235
- ## Stale-segment recovery
329
+ ## Removing segments by name
236
330
 
237
- `StreamBufferMultiplexer.remove(name)` unlinks a segment by name — it works for both kinds this
238
- class creates, a shared record queue's and a producer stream buffer's, since both are plain
239
- shared memory segments underneath. Use it when a previous run died mid-initialization and left a
240
- segment wedged, which construction over that name reports by raising:
331
+ `StreamBufferMultiplexer.remove(name)` unlinks a segment by name without attaching to it — it
332
+ works for every kind this class creates, a shared record queue's, a producer stream buffer's and a
333
+ producer queue's, since all are plain shared memory segments underneath. Use it at startup for a
334
+ clean start, and to clear a segment a previous run left wedged by dying mid-initialization, which
335
+ construction over that name reports by raising:
241
336
 
242
337
  ```python
243
- StreamBufferMultiplexer.remove("md_records") # clear what a dead run left
338
+ StreamBufferMultiplexer.remove("md_records") # shared record queue
339
+ StreamBufferMultiplexer.remove("md_p0") # a producer's stream buffer
340
+ StreamBufferMultiplexer.remove("md_q2") # a producer's queue
244
341
  mux = StreamBufferMultiplexer(shared_queue_size=1024, name="md_records")
245
342
  ```
246
343
 
@@ -256,6 +353,7 @@ The C++ side composes the identical primitives — either language can create an
256
353
 
257
354
  slick::stream_buffer_multiplexer mux("mux_records"); // open queue created by Python
258
355
  auto feed = mux.add_producer(0, "feed_a"); // open Python's producer segment
356
+ auto fills = mux.add_queue_producer(2, "fills"); // and its queue producer's
259
357
 
260
358
  uint64_t cursor = mux.initial_reading_index();
261
359
  for (;;) {
@@ -265,8 +363,12 @@ for (;;) {
265
363
  }
266
364
  ```
267
365
 
268
- Use `get_shm_name()` on the multiplexer (queue segment) and each producer buffer to get the
269
- exact names to pass to C++ (POSIX names include the required leading `/`).
366
+ Use `get_shm_name()` on the multiplexer (queue segment) and each producer to get the exact names
367
+ to pass to C++ (POSIX names include the required leading `/`). Each side must register an id as
368
+ the kind it is: the record carries only `{sequence, producer_id}`, so the kind behind an id is part
369
+ of the same convention as its name. A consumer on C++ 2.1 or earlier, or on this package before
370
+ 2.2.0, has no queue producers; it skips their records as unregistered, without counting them as
371
+ loss.
270
372
 
271
373
  ### Binary layout
272
374
 
@@ -274,16 +376,23 @@ The multiplexer adds no shared-memory structures of its own:
274
376
 
275
377
  - The shared queue is a standard slick-queue segment (`'SLQ1'`, or `'SLQ0'` when created with
276
378
  `enable_read_last` off; `element_size=16`) whose elements are
277
- `uint64 sequence | uint32 producer_id | uint32 pad0`
278
- - Each producer buffer is a standard slick-stream-buffer segment (`'SSB1'`)
379
+ `uint64 sequence | uint32 producer_id | uint32 pad0`; `sequence` is a stream buffer's record
380
+ sequence or a queue producer's reservation index
381
+ - Each stream buffer producer is a standard slick-stream-buffer segment (`'SSB1'`)
382
+ - Each queue producer is a standard slick-queue segment with `element_size=1`, marked like any
383
+ slick-queue segment: `'SLQ1'` by default, with the `items_per_slot` bit added (`'SLQ3'`) when
384
+ `items_per_slot` is not 1, and the read-last bit cleared (`'SLQ0'` / `'SLQ2'`) when created
385
+ with `enable_read_last` off
279
386
 
280
387
  ## Caveats (same as C++)
281
388
 
282
- - `add_producer()` is single-threaded setup: call it before producer/consumer threads start.
283
- - A `producer_id` must fit the record's `uint32` field: `add_producer()` raises `ValueError`
284
- for one outside `0..0xFFFFFFFF` (and `TypeError` for a non-integer), before any segment is
285
- created.
286
- - Each producer's `prepare/commit/consume/...` must be called from a single thread.
389
+ - `add_producer()` and `add_queue_producer()` are single-threaded setup: call them before
390
+ producer/consumer threads start.
391
+ - A `producer_id` must fit the record's `uint32` field: both registration calls raise
392
+ `ValueError` for one outside `0..0xFFFFFFFF` (and `TypeError` for a non-integer), before any
393
+ segment is created.
394
+ - Each `ProducerBuffer`'s `prepare/commit/consume/...` must be called from a single thread. A
395
+ `ProducerQueue` has no such limit.
287
396
  - Lossy semantics: slow consumers skip overwritten data (see the loss counters above).
288
397
  Records are skipped correctly whatever the traits say; only the counters go quiet.
289
398
  - A single message (one `consume()` call) is limited to < 4 GiB: `consume()` raises
@@ -298,6 +407,7 @@ See [API_DIFFERENCES.md](API_DIFFERENCES.md) for exact deviations from the C++ A
298
407
  # Pure-Python tests (sibling repos slick-queue-py / slick-stream-buffer-py are
299
408
  # picked up automatically from ../ if not pip-installed)
300
409
  python tests/test_multiplexer.py
410
+ python tests/test_multiplexer_queue.py
301
411
  python tests/test_multiplexer_mpmc.py
302
412
  python tests/test_multiplexer_shm.py
303
413