slick-stream-buffer-multiplexer-py 2.0.0__tar.gz → 2.2.0__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 (16) hide show
  1. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/API_DIFFERENCES.md +58 -20
  2. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/PKG-INFO +112 -30
  3. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/README.md +110 -28
  4. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/pyproject.toml +1 -1
  5. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/requirements.txt +1 -1
  6. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/setup.py +1 -1
  7. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/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}/slick_stream_buffer_multiplexer_py.py +433 -89
  9. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/tests/test_interop.py +92 -7
  10. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/tests/test_multiplexer_mpmc.py +146 -1
  11. slick_stream_buffer_multiplexer_py-2.2.0/tests/test_multiplexer_queue.py +572 -0
  12. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/tests/test_multiplexer_shm.py +249 -8
  13. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/LICENSE +0 -0
  14. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/MANIFEST.in +0 -0
  15. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/setup.cfg +0 -0
  16. {slick_stream_buffer_multiplexer_py-2.0.0 → slick_stream_buffer_multiplexer_py-2.2.0}/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,24 +154,30 @@ 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.
161
+
162
+ ## Producer handles are not movable in C++
126
163
 
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.
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
@@ -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
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,7 +28,7 @@ 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
31
+ Requires-Dist: slick-queue-py>=2.1.0
32
32
  Requires-Dist: slick-stream-buffer-py>=2.0.0
33
33
  Dynamic: author
34
34
  Dynamic: home-page
@@ -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.1.0 and slick-stream-buffer-py >= 2.0.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,7 +84,8 @@ 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
87
+ - [slick-queue-py](https://github.com/SlickQuant/slick-queue-py) >= 2.1.0 (queue producers need
88
+ its `items_per_slot`)
81
89
  - [slick-stream-buffer-py](https://github.com/SlickQuant/slick-stream-buffer-py) >= 2.0.0
82
90
 
83
91
  Both dependencies bundle a small C++ extension for `std::atomic` operations; this package itself
@@ -125,6 +133,45 @@ while True:
125
133
  handle_message(rec.producer_id, rec.data, rec.length)
126
134
  ```
127
135
 
136
+ ### Queue producers
137
+
138
+ A producer can be a byte slick-queue instead. Register it with `add_queue_producer()` and publish
139
+ through the `ProducerQueue` it returns, which has slick-queue's own producer interface:
140
+
141
+ ```python
142
+ # 1 MB byte queue, minimum 64 bytes per reservation (both must be powers of 2)
143
+ fills = mux.add_queue_producer(2, size=1 << 20, items_per_slot=64, name="fills")
144
+
145
+ index = fills.reserve(len(message)) # len(message) contiguous bytes; never blocks
146
+ fills[index][:len(message)] = message # fills[index] is a writable view from index onward
147
+ fills.publish(index, len(message)) # publish as ONE message + fan into the shared queue
148
+ ```
149
+
150
+ One message is the bytes of one `reserve()`/`publish()` pair, and consumers get it exactly as they
151
+ get a stream buffer message. What differs is the producer side:
152
+
153
+ - **Several threads may publish through one `ProducerQueue`.** slick-queue is MPMC, and so is the
154
+ fan-in. Each thread's own messages stay in order.
155
+ - **Give a large queue an `items_per_slot`.** It is the minimum number of bytes one `reserve()`
156
+ consumes, and slick-queue keeps one 16-byte control slot per unit of it. Without it there is a
157
+ slot per byte - 16 MB of control for 1 MB of data. See
158
+ [slick-queue-py](https://github.com/SlickQuant/slick-queue-py#byte-buffers-and-items_per_slot).
159
+ - **A message is limited to the queue's size.** `reserve()` raises `RuntimeError` for a larger
160
+ one and `ValueError` for 0, as `SlickQueue.reserve()` does.
161
+
162
+ The forms mirror the C++ overloads: `add_queue_producer(id, size=...[, items_per_slot=...])` for
163
+ local memory, add `name=...` to create a segment, `add_queue_producer(id, name=...)` to open one
164
+ (its size and `items_per_slot` come from the segment), or `add_queue_producer(id, queue=q)` to
165
+ adopt a byte `SlickQueue` you already have. Only what is published through the handle is
166
+ multiplexed: a message put on an adopted queue directly reaches whoever reads that queue, but
167
+ `mux.read()` never returns it.
168
+
169
+ `producer_id` is one namespace across both kinds: an id registered with `add_producer()` cannot be
170
+ registered again with `add_queue_producer()`, or the other way round. `has_producer()` and
171
+ `producer_count()` cover both; `get_producer_queue()`, `find_producer_queue()` and
172
+ `get_producer_queues()` are the queue counterparts of the stream buffer accessors, and each kind's
173
+ accessors return `None` for the other kind's ids.
174
+
128
175
  ### Work-stealing consumers (each message to exactly one consumer)
129
176
 
130
177
  ```python
@@ -191,14 +238,17 @@ slick::basic_stream_buffer_multiplexer<no_read_last_traits> mux("mux_records");
191
238
 
192
239
  The multiplexer itself never calls `read_last()` (`initial_reading_index()` reads the reservation
193
240
  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.
241
+ shared queue. Stream buffer producer segments (`'SSB1'`) carry no such flag. Queue producers are
242
+ built with the multiplexer's traits, as in C++, so their segments carry the same marker and the
243
+ same rule applies to them.
195
244
 
196
245
  ## Three Loss Counters
197
246
 
198
247
  | Counter | Meaning |
199
248
  |---|---|
200
249
  | `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 |
250
+ | `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 |
251
+ | `ProducerQueue.loss_count()` | the inner queue's own counter — a diagnostic of that queue, **not** a record count (see below) |
202
252
  | unregistered producer ids | **never counted** — silently skipped |
203
253
 
204
254
  Only the multiplexer-level term is filtered by registration. The wrap-loss term counts entries
@@ -212,11 +262,30 @@ dereferences through traits with `count_loss` off, because `read()`'s counter me
212
262
  a *sequential scan* jumped, which for a jump-read is a distance every later lapped dereference
213
263
  would add all over again. The multiplexer counts one loss per lapped record instead.
214
264
 
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:
265
+ `ProducerQueue.loss_count()` cannot be kept clean the same way: slick-queue configures loss
266
+ detection per queue, not per call, so with counting traits (slick-queue-py's default) every
267
+ dereference that finds its record lapped adds slick-queue's "how far has this slot moved on"
268
+ distance to it - and each later lapped dereference adds its own again. Ten one-byte messages
269
+ through a 4-byte queue lose 6 records, which `mux.loss_count()` reports as 6 while that queue's
270
+ own counter reads 32. Read loss off the multiplexer.
271
+
272
+ **A queue message is lost once its bytes may be overwritten, not only its slot.** Messages differ
273
+ in length, so a later reservation can cover an older message's bytes without landing on its
274
+ control slot: in an 8-byte queue, `"ijkl"` at index 8 overwrites `"AB"` at 0 and `"CD"` at 2 while
275
+ `"CD"`'s slot still names it. `read()` therefore also skips (and counts) any queue message the
276
+ reservation counter has moved more than one ring past. `read()` copies the bytes before it checks,
277
+ so the check covers the copy you get back. It is conservative, as in C++: a reservation that does
278
+ not fit before the end of the ring skips the rest of the lap, so the counter also passes indices
279
+ nothing wrote, and a message lying in that skipped tail is reported lost although its bytes are
280
+ intact.
281
+
282
+ `mux.get_producer_buffers()` returns every registered stream buffer producer keyed by
283
+ `producer_id`, and `mux.get_producer_queues()` every queue producer — read-only live views of the
284
+ registration tables, cheap enough for a monitoring loop:
217
285
 
218
286
  ```python
219
287
  inner_loss = sum(p.loss_count() for p in mux.get_producer_buffers().values())
288
+ queues = {pid: p.size for pid, p in mux.get_producer_queues().items()}
220
289
  ```
221
290
 
222
291
  ## Shutdown
@@ -225,7 +294,7 @@ Close every handle, then unlink once — the usual `shared_memory` idiom:
225
294
 
226
295
  ```python
227
296
  mux.close() # or leave a `with StreamBufferMultiplexer(...) as mux:` block
228
- mux.unlink() # deletes the record queue and every registered producer's segment
297
+ mux.unlink() # deletes the record queue and every registered producer's segment, both kinds
229
298
  ```
230
299
 
231
300
  `close()` keeps the registration table so `unlink()` can still reach each producer's segment, and
@@ -234,9 +303,9 @@ name. On Windows `unlink()` is a no-op — a section disappears once its last ha
234
303
 
235
304
  ## Stale-segment recovery
236
305
 
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
306
+ `StreamBufferMultiplexer.remove(name)` unlinks a segment by name — it works for every kind this
307
+ class creates, a shared record queue's, a producer stream buffer's and a producer queue's, since
308
+ all are plain shared memory segments underneath. Use it when a previous run died mid-initialization and left a
240
309
  segment wedged, which construction over that name reports by raising:
241
310
 
242
311
  ```python
@@ -256,6 +325,7 @@ The C++ side composes the identical primitives — either language can create an
256
325
 
257
326
  slick::stream_buffer_multiplexer mux("mux_records"); // open queue created by Python
258
327
  auto feed = mux.add_producer(0, "feed_a"); // open Python's producer segment
328
+ auto fills = mux.add_queue_producer(2, "fills"); // and its queue producer's
259
329
 
260
330
  uint64_t cursor = mux.initial_reading_index();
261
331
  for (;;) {
@@ -265,8 +335,12 @@ for (;;) {
265
335
  }
266
336
  ```
267
337
 
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 `/`).
338
+ Use `get_shm_name()` on the multiplexer (queue segment) and each producer to get the exact names
339
+ to pass to C++ (POSIX names include the required leading `/`). Each side must register an id as
340
+ the kind it is: the record carries only `{sequence, producer_id}`, so the kind behind an id is part
341
+ of the same convention as its name. A consumer on C++ 2.1 or earlier, or on this package before
342
+ 2.2.0, has no queue producers; it skips their records as unregistered, without counting them as
343
+ loss.
270
344
 
271
345
  ### Binary layout
272
346
 
@@ -274,16 +348,23 @@ The multiplexer adds no shared-memory structures of its own:
274
348
 
275
349
  - The shared queue is a standard slick-queue segment (`'SLQ1'`, or `'SLQ0'` when created with
276
350
  `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'`)
351
+ `uint64 sequence | uint32 producer_id | uint32 pad0`; `sequence` is a stream buffer's record
352
+ sequence or a queue producer's reservation index
353
+ - Each stream buffer producer is a standard slick-stream-buffer segment (`'SSB1'`)
354
+ - Each queue producer is a standard slick-queue segment with `element_size=1`, marked like any
355
+ slick-queue segment: `'SLQ1'` by default, with the `items_per_slot` bit added (`'SLQ3'`) when
356
+ `items_per_slot` is not 1, and the read-last bit cleared (`'SLQ0'` / `'SLQ2'`) when created
357
+ with `enable_read_last` off
279
358
 
280
359
  ## Caveats (same as C++)
281
360
 
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.
361
+ - `add_producer()` and `add_queue_producer()` are single-threaded setup: call them before
362
+ producer/consumer threads start.
363
+ - A `producer_id` must fit the record's `uint32` field: both registration calls raise
364
+ `ValueError` for one outside `0..0xFFFFFFFF` (and `TypeError` for a non-integer), before any
365
+ segment is created.
366
+ - Each `ProducerBuffer`'s `prepare/commit/consume/...` must be called from a single thread. A
367
+ `ProducerQueue` has no such limit.
287
368
  - Lossy semantics: slow consumers skip overwritten data (see the loss counters above).
288
369
  Records are skipped correctly whatever the traits say; only the counters go quiet.
289
370
  - A single message (one `consume()` call) is limited to < 4 GiB: `consume()` raises
@@ -298,6 +379,7 @@ See [API_DIFFERENCES.md](API_DIFFERENCES.md) for exact deviations from the C++ A
298
379
  # Pure-Python tests (sibling repos slick-queue-py / slick-stream-buffer-py are
299
380
  # picked up automatically from ../ if not pip-installed)
300
381
  python tests/test_multiplexer.py
382
+ python tests/test_multiplexer_queue.py
301
383
  python tests/test_multiplexer_mpmc.py
302
384
  python tests/test_multiplexer_shm.py
303
385
 
@@ -8,29 +8,36 @@ consumers can be mixed across processes, fanning into the same shared memory seg
8
8
  every peer of a shared queue agrees on `enable_read_last` (see
9
9
  [`enable_read_last` must match on every peer](#enable_read_last-must-match-on-every-peer)).
10
10
 
11
- Tracks C++ `slick/stream_buffer_multiplexer.hpp` **v2.0.0**, and requires slick-queue-py >=
12
- 2.0.0 and slick-stream-buffer-py >= 2.0.0. The shared-memory formats are unchanged
11
+ Tracks C++ `slick/stream_buffer_multiplexer.hpp` **v2.2.0**, and requires slick-queue-py >=
12
+ 2.1.0 and slick-stream-buffer-py >= 2.0.0. The shared-memory formats are unchanged
13
13
  (`'SLQ1'` / `'SSB1'`), so segments written by v0.1.0 or by a C++ v1.x process are still
14
14
  readable with the default traits.
15
15
 
16
16
  ## Concept
17
17
 
18
- Each producer owns its own [slick-stream-buffer](https://github.com/SlickQuant/slick-stream-buffer)
19
- (an independently-sized byte ring with per-producer lap/loss detection). `consume()` additionally
20
- publishes a small 16-byte `{sequence, producer_id}` record into one shared
21
- [slick-queue](https://github.com/SlickQuant/slick-queue), which acts as the lock-free MPMC
22
- fan-in / global ordering point. Consumers read from the shared queue and dereference each record
23
- back into the matching producer's buffer.
18
+ Each producer owns its own byte ring, of one of two kinds, freely mixed under one multiplexer and
19
+ one `producer_id` space:
20
+
21
+ - a [slick-stream-buffer](https://github.com/SlickQuant/slick-stream-buffer) (an
22
+ independently-sized byte ring with per-producer lap/loss detection), written with
23
+ `prepare`/`commit`/`consume` from one thread;
24
+ - a byte [slick-queue](https://github.com/SlickQuant/slick-queue) (`element_size=1`, the C++
25
+ `slick::queue<uint8_t>`), written with `reserve`/`publish`, which several threads may do at once.
26
+
27
+ Publishing through either additionally publishes a small 16-byte `{sequence, producer_id}` record
28
+ into one shared slick-queue, which acts as the lock-free MPMC fan-in / global ordering point.
29
+ Consumers read from the shared queue and dereference each record back into the matching
30
+ producer's ring, getting `(data, length, producer_id)` whichever kind published it.
24
31
 
25
32
  ```
26
33
  Producer A ──prepare/commit/consume──▶ [StreamBuffer A] ──┐
27
34
  Producer B ──prepare/commit/consume──▶ [StreamBuffer B] ──┼─▶ {sequence, producer_id}
28
- Producer N ──prepare/commit/consume──▶ [StreamBuffer N] ──┘ 16-byte records
35
+ Producer C ──reserve/write/publish───▶ [byte SlickQueue C]┘ 16-byte records
29
36
  │
30
37
  [shared SlickQueue]
31
38
  │
32
39
  consumers read(cursor) and dereference
33
- into StreamBuffer[producer_id]
40
+ into the ring of producer_id
34
41
  ```
35
42
 
36
43
  Producers and the shared queue each independently choose local memory or shared memory (IPC).
@@ -40,7 +47,8 @@ records referencing other producer ids are silently skipped (not counted as loss
40
47
  ## Requirements
41
48
 
42
49
  - Python 3.8+
43
- - [slick-queue-py](https://github.com/SlickQuant/slick-queue-py) >= 2.0.0
50
+ - [slick-queue-py](https://github.com/SlickQuant/slick-queue-py) >= 2.1.0 (queue producers need
51
+ its `items_per_slot`)
44
52
  - [slick-stream-buffer-py](https://github.com/SlickQuant/slick-stream-buffer-py) >= 2.0.0
45
53
 
46
54
  Both dependencies bundle a small C++ extension for `std::atomic` operations; this package itself
@@ -88,6 +96,45 @@ while True:
88
96
  handle_message(rec.producer_id, rec.data, rec.length)
89
97
  ```
90
98
 
99
+ ### Queue producers
100
+
101
+ A producer can be a byte slick-queue instead. Register it with `add_queue_producer()` and publish
102
+ through the `ProducerQueue` it returns, which has slick-queue's own producer interface:
103
+
104
+ ```python
105
+ # 1 MB byte queue, minimum 64 bytes per reservation (both must be powers of 2)
106
+ fills = mux.add_queue_producer(2, size=1 << 20, items_per_slot=64, name="fills")
107
+
108
+ index = fills.reserve(len(message)) # len(message) contiguous bytes; never blocks
109
+ fills[index][:len(message)] = message # fills[index] is a writable view from index onward
110
+ fills.publish(index, len(message)) # publish as ONE message + fan into the shared queue
111
+ ```
112
+
113
+ One message is the bytes of one `reserve()`/`publish()` pair, and consumers get it exactly as they
114
+ get a stream buffer message. What differs is the producer side:
115
+
116
+ - **Several threads may publish through one `ProducerQueue`.** slick-queue is MPMC, and so is the
117
+ fan-in. Each thread's own messages stay in order.
118
+ - **Give a large queue an `items_per_slot`.** It is the minimum number of bytes one `reserve()`
119
+ consumes, and slick-queue keeps one 16-byte control slot per unit of it. Without it there is a
120
+ slot per byte - 16 MB of control for 1 MB of data. See
121
+ [slick-queue-py](https://github.com/SlickQuant/slick-queue-py#byte-buffers-and-items_per_slot).
122
+ - **A message is limited to the queue's size.** `reserve()` raises `RuntimeError` for a larger
123
+ one and `ValueError` for 0, as `SlickQueue.reserve()` does.
124
+
125
+ The forms mirror the C++ overloads: `add_queue_producer(id, size=...[, items_per_slot=...])` for
126
+ local memory, add `name=...` to create a segment, `add_queue_producer(id, name=...)` to open one
127
+ (its size and `items_per_slot` come from the segment), or `add_queue_producer(id, queue=q)` to
128
+ adopt a byte `SlickQueue` you already have. Only what is published through the handle is
129
+ multiplexed: a message put on an adopted queue directly reaches whoever reads that queue, but
130
+ `mux.read()` never returns it.
131
+
132
+ `producer_id` is one namespace across both kinds: an id registered with `add_producer()` cannot be
133
+ registered again with `add_queue_producer()`, or the other way round. `has_producer()` and
134
+ `producer_count()` cover both; `get_producer_queue()`, `find_producer_queue()` and
135
+ `get_producer_queues()` are the queue counterparts of the stream buffer accessors, and each kind's
136
+ accessors return `None` for the other kind's ids.
137
+
91
138
  ### Work-stealing consumers (each message to exactly one consumer)
92
139
 
93
140
  ```python
@@ -154,14 +201,17 @@ slick::basic_stream_buffer_multiplexer<no_read_last_traits> mux("mux_records");
154
201
 
155
202
  The multiplexer itself never calls `read_last()` (`initial_reading_index()` reads the reservation
156
203
  counter instead), so turning it off only drops one CAS from each `consume()`'s publish into the
157
- shared queue. Producer segments (`'SSB1'`) carry no such flag.
204
+ shared queue. Stream buffer producer segments (`'SSB1'`) carry no such flag. Queue producers are
205
+ built with the multiplexer's traits, as in C++, so their segments carry the same marker and the
206
+ same rule applies to them.
158
207
 
159
208
  ## Three Loss Counters
160
209
 
161
210
  | Counter | Meaning |
162
211
  |---|---|
163
212
  | `mux.loss_count()` | shared-queue wrap loss **plus** multiplexer-level loss (registered producers lapped before dereference); both terms switched by `traits.enable_loss_detection` |
164
- | `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 |
213
+ | `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 |
214
+ | `ProducerQueue.loss_count()` | the inner queue's own counter — a diagnostic of that queue, **not** a record count (see below) |
165
215
  | unregistered producer ids | **never counted** — silently skipped |
166
216
 
167
217
  Only the multiplexer-level term is filtered by registration. The wrap-loss term counts entries
@@ -175,11 +225,30 @@ dereferences through traits with `count_loss` off, because `read()`'s counter me
175
225
  a *sequential scan* jumped, which for a jump-read is a distance every later lapped dereference
176
226
  would add all over again. The multiplexer counts one loss per lapped record instead.
177
227
 
178
- `mux.get_producer_buffers()` returns every registered producer keyed by `producer_id` — a
179
- read-only live view of the registration table, cheap enough for a monitoring loop:
228
+ `ProducerQueue.loss_count()` cannot be kept clean the same way: slick-queue configures loss
229
+ detection per queue, not per call, so with counting traits (slick-queue-py's default) every
230
+ dereference that finds its record lapped adds slick-queue's "how far has this slot moved on"
231
+ distance to it - and each later lapped dereference adds its own again. Ten one-byte messages
232
+ through a 4-byte queue lose 6 records, which `mux.loss_count()` reports as 6 while that queue's
233
+ own counter reads 32. Read loss off the multiplexer.
234
+
235
+ **A queue message is lost once its bytes may be overwritten, not only its slot.** Messages differ
236
+ in length, so a later reservation can cover an older message's bytes without landing on its
237
+ control slot: in an 8-byte queue, `"ijkl"` at index 8 overwrites `"AB"` at 0 and `"CD"` at 2 while
238
+ `"CD"`'s slot still names it. `read()` therefore also skips (and counts) any queue message the
239
+ reservation counter has moved more than one ring past. `read()` copies the bytes before it checks,
240
+ so the check covers the copy you get back. It is conservative, as in C++: a reservation that does
241
+ not fit before the end of the ring skips the rest of the lap, so the counter also passes indices
242
+ nothing wrote, and a message lying in that skipped tail is reported lost although its bytes are
243
+ intact.
244
+
245
+ `mux.get_producer_buffers()` returns every registered stream buffer producer keyed by
246
+ `producer_id`, and `mux.get_producer_queues()` every queue producer — read-only live views of the
247
+ registration tables, cheap enough for a monitoring loop:
180
248
 
181
249
  ```python
182
250
  inner_loss = sum(p.loss_count() for p in mux.get_producer_buffers().values())
251
+ queues = {pid: p.size for pid, p in mux.get_producer_queues().items()}
183
252
  ```
184
253
 
185
254
  ## Shutdown
@@ -188,7 +257,7 @@ Close every handle, then unlink once — the usual `shared_memory` idiom:
188
257
 
189
258
  ```python
190
259
  mux.close() # or leave a `with StreamBufferMultiplexer(...) as mux:` block
191
- mux.unlink() # deletes the record queue and every registered producer's segment
260
+ mux.unlink() # deletes the record queue and every registered producer's segment, both kinds
192
261
  ```
193
262
 
194
263
  `close()` keeps the registration table so `unlink()` can still reach each producer's segment, and
@@ -197,9 +266,9 @@ name. On Windows `unlink()` is a no-op — a section disappears once its last ha
197
266
 
198
267
  ## Stale-segment recovery
199
268
 
200
- `StreamBufferMultiplexer.remove(name)` unlinks a segment by name — it works for both kinds this
201
- class creates, a shared record queue's and a producer stream buffer's, since both are plain
202
- shared memory segments underneath. Use it when a previous run died mid-initialization and left a
269
+ `StreamBufferMultiplexer.remove(name)` unlinks a segment by name — it works for every kind this
270
+ class creates, a shared record queue's, a producer stream buffer's and a producer queue's, since
271
+ all are plain shared memory segments underneath. Use it when a previous run died mid-initialization and left a
203
272
  segment wedged, which construction over that name reports by raising:
204
273
 
205
274
  ```python
@@ -219,6 +288,7 @@ The C++ side composes the identical primitives — either language can create an
219
288
 
220
289
  slick::stream_buffer_multiplexer mux("mux_records"); // open queue created by Python
221
290
  auto feed = mux.add_producer(0, "feed_a"); // open Python's producer segment
291
+ auto fills = mux.add_queue_producer(2, "fills"); // and its queue producer's
222
292
 
223
293
  uint64_t cursor = mux.initial_reading_index();
224
294
  for (;;) {
@@ -228,8 +298,12 @@ for (;;) {
228
298
  }
229
299
  ```
230
300
 
231
- Use `get_shm_name()` on the multiplexer (queue segment) and each producer buffer to get the
232
- exact names to pass to C++ (POSIX names include the required leading `/`).
301
+ Use `get_shm_name()` on the multiplexer (queue segment) and each producer to get the exact names
302
+ to pass to C++ (POSIX names include the required leading `/`). Each side must register an id as
303
+ the kind it is: the record carries only `{sequence, producer_id}`, so the kind behind an id is part
304
+ of the same convention as its name. A consumer on C++ 2.1 or earlier, or on this package before
305
+ 2.2.0, has no queue producers; it skips their records as unregistered, without counting them as
306
+ loss.
233
307
 
234
308
  ### Binary layout
235
309
 
@@ -237,16 +311,23 @@ The multiplexer adds no shared-memory structures of its own:
237
311
 
238
312
  - The shared queue is a standard slick-queue segment (`'SLQ1'`, or `'SLQ0'` when created with
239
313
  `enable_read_last` off; `element_size=16`) whose elements are
240
- `uint64 sequence | uint32 producer_id | uint32 pad0`
241
- - Each producer buffer is a standard slick-stream-buffer segment (`'SSB1'`)
314
+ `uint64 sequence | uint32 producer_id | uint32 pad0`; `sequence` is a stream buffer's record
315
+ sequence or a queue producer's reservation index
316
+ - Each stream buffer producer is a standard slick-stream-buffer segment (`'SSB1'`)
317
+ - Each queue producer is a standard slick-queue segment with `element_size=1`, marked like any
318
+ slick-queue segment: `'SLQ1'` by default, with the `items_per_slot` bit added (`'SLQ3'`) when
319
+ `items_per_slot` is not 1, and the read-last bit cleared (`'SLQ0'` / `'SLQ2'`) when created
320
+ with `enable_read_last` off
242
321
 
243
322
  ## Caveats (same as C++)
244
323
 
245
- - `add_producer()` is single-threaded setup: call it before producer/consumer threads start.
246
- - A `producer_id` must fit the record's `uint32` field: `add_producer()` raises `ValueError`
247
- for one outside `0..0xFFFFFFFF` (and `TypeError` for a non-integer), before any segment is
248
- created.
249
- - Each producer's `prepare/commit/consume/...` must be called from a single thread.
324
+ - `add_producer()` and `add_queue_producer()` are single-threaded setup: call them before
325
+ producer/consumer threads start.
326
+ - A `producer_id` must fit the record's `uint32` field: both registration calls raise
327
+ `ValueError` for one outside `0..0xFFFFFFFF` (and `TypeError` for a non-integer), before any
328
+ segment is created.
329
+ - Each `ProducerBuffer`'s `prepare/commit/consume/...` must be called from a single thread. A
330
+ `ProducerQueue` has no such limit.
250
331
  - Lossy semantics: slow consumers skip overwritten data (see the loss counters above).
251
332
  Records are skipped correctly whatever the traits say; only the counters go quiet.
252
333
  - A single message (one `consume()` call) is limited to < 4 GiB: `consume()` raises
@@ -261,6 +342,7 @@ See [API_DIFFERENCES.md](API_DIFFERENCES.md) for exact deviations from the C++ A
261
342
  # Pure-Python tests (sibling repos slick-queue-py / slick-stream-buffer-py are
262
343
  # picked up automatically from ../ if not pip-installed)
263
344
  python tests/test_multiplexer.py
345
+ python tests/test_multiplexer_queue.py
264
346
  python tests/test_multiplexer_mpmc.py
265
347
  python tests/test_multiplexer_shm.py
266
348
 
@@ -30,7 +30,7 @@ classifiers = [
30
30
  "Operating System :: MacOS",
31
31
  ]
32
32
  dependencies = [
33
- "slick-queue-py>=2.0.0",
33
+ "slick-queue-py>=2.1.0",
34
34
  "slick-stream-buffer-py>=2.0.0",
35
35
  ]
36
36
 
@@ -1,3 +1,3 @@
1
1
  # Runtime dependencies (binary-compatible Python ports of the C++ dependencies)
2
- slick-queue-py>=2.0.0
2
+ slick-queue-py>=2.1.0
3
3
  slick-stream-buffer-py>=2.0.0
@@ -52,7 +52,7 @@ setup(
52
52
  keywords='stream-buffer, multiplexer, lock-free, atomic, shared-memory, ipc, multiprocessing, mpmc',
53
53
  py_modules=['slick_stream_buffer_multiplexer_py'],
54
54
  install_requires=[
55
- 'slick-queue-py>=2.0.0',
55
+ 'slick-queue-py>=2.1.0',
56
56
  'slick-stream-buffer-py>=2.0.0',
57
57
  ],
58
58
  python_requires='>=3.8',