natsio-pcgroups 0.1.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.
@@ -0,0 +1,291 @@
1
+ Metadata-Version: 2.4
2
+ Name: natsio-pcgroups
3
+ Version: 0.1.0
4
+ Summary: Partitioned consumer groups for natsio — static and elastic, with pinned-member failover
5
+ Keywords: nats,nats.io,jetstream,consumer-groups,partitioning
6
+ Author: Corrupt Mane
7
+ Author-email: Corrupt Mane <corruptmane@gmail.com>
8
+ License-Expression: Apache-2.0
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: AsyncIO
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Typing :: Typed
15
+ Requires-Dist: natsio
16
+ Requires-Python: >=3.13
17
+ Project-URL: Homepage, https://github.com/corruptmane/natsio
18
+ Project-URL: Repository, https://github.com/corruptmane/natsio
19
+ Project-URL: Changelog, https://github.com/corruptmane/natsio/blob/main/extensions/natsio-pcgroups/CHANGELOG.md
20
+ Description-Content-Type: text/markdown
21
+
22
+ # natsio-pcgroups
23
+
24
+ Partitioned consumer groups over JetStream — **static** and **elastic** —
25
+ mirroring [`orbit.go/pcgroups`](https://github.com/synadia-io/orbit.go/tree/main/pcgroups).
26
+ Distribution `natsio-pcgroups`, imported as `natsio.pcgroups`. Zero runtime
27
+ dependencies beyond `natsio`. Pre-1.0, no API-stability promises.
28
+
29
+ Requires **nats-server 2.11+** (ADR-42 pinned-client priority groups, subject
30
+ transforms). Verified against the repo-pinned **2.14.3**.
31
+
32
+ ## What it is
33
+
34
+ Strictly ordered consumption of a JetStream stream means `max_ack_pending=1` —
35
+ one message in flight, no horizontal scaling. A *consumer group* fixes that by
36
+ splitting the stream into `max_members` **partitions** keyed by subject and
37
+ handing each **member** a fixed subset of them. Members run in parallel;
38
+ messages that share a partitioning key are still processed one at a time, in
39
+ order.
40
+
41
+ Each member may run as **several instances**. The server pins exactly one of
42
+ them (ADR-42 `pinned_client`) and the rest sit as hot standbys, so a member is
43
+ highly available without ever double-processing a key.
44
+
45
+ The group's config lives in a KV bucket; the library derives every member's
46
+ partition filters from it, creates the consumers, and hands your handler
47
+ messages with the **partition token already stripped from the subject**, so
48
+ existing handler code works unchanged.
49
+
50
+ | | Static | Elastic |
51
+ |---|---|---|
52
+ | Partition token in the source subjects | required (stream subject transform) | not needed |
53
+ | Extra stream | none — consumers sit on your stream | a sourced work-queue stream `<stream>-<group>` |
54
+ | Membership | fixed at creation | changed at any time |
55
+ | Config bucket | `static-consumer-groups` | `elastic-consumer-groups` |
56
+ | Ack policy | your choice | must be `explicit` |
57
+
58
+ ## Static groups
59
+
60
+ The stream already carries the partition number as the first subject token —
61
+ put there on ingest by a stream-level subject transform:
62
+
63
+ ```python
64
+ import natsio
65
+ from natsio.jetstream import AckPolicy, ConsumerConfig, StreamConfig, SubjectTransform
66
+ from natsio.pcgroups import PartitionedMsg, create_static, static_consume
67
+
68
+ nc = await natsio.connect("nats://localhost")
69
+ js = nc.jetstream()
70
+
71
+ # Partition "orders.<id>" over 10 partitions on ingest.
72
+ await js.create_stream(
73
+ StreamConfig(
74
+ name="ORDERS",
75
+ subjects=["orders.*"],
76
+ subject_transform=SubjectTransform(src="orders.*", dest="{{partition(10,1)}}.orders.{{wildcard(1)}}"),
77
+ )
78
+ )
79
+
80
+ await create_static(js, "ORDERS", "cg", 10, ["orders.*"], ["m1", "m2"])
81
+
82
+ async def handle(msg: PartitionedMsg) -> None:
83
+ print(msg.subject, msg.data) # "orders.42" — the partition token is gone
84
+ await msg.ack()
85
+
86
+ context = await static_consume(
87
+ js, "ORDERS", "cg", "m1", handle,
88
+ ConsumerConfig(ack_policy=AckPolicy.EXPLICIT, max_ack_pending=1),
89
+ )
90
+ ...
91
+ await context.stop()
92
+ ```
93
+
94
+ A static group's config is **immutable**. Changing it (or deleting the group)
95
+ stops every running member instance; to re-shape the membership, delete the
96
+ group and create it again.
97
+
98
+ ## Elastic groups
99
+
100
+ The stream needs no partition token. Creating the group creates a sourced
101
+ **work-queue** stream that inserts one, and the membership can change at any
102
+ time — running members start and stop consuming as they are added and dropped:
103
+
104
+ ```python
105
+ from natsio.pcgroups import PartitioningFilter, add_members, create_elastic, delete_members, elastic_consume
106
+
107
+ await js.create_stream(StreamConfig(name="ORDERS", subjects=["orders.*"]))
108
+
109
+ # Partition on the first wildcard of "orders.*", over 10 partitions.
110
+ await create_elastic(js, "ORDERS", "cg", 10, [PartitioningFilter(filter="orders.*", partitioning_wildcards=[1])])
111
+
112
+ # Members may join before they are in the membership: they simply wait.
113
+ context = await elastic_consume(js, "ORDERS", "cg", "m1", handle, ConsumerConfig(ack_policy=AckPolicy.EXPLICIT))
114
+
115
+ await add_members(js, "ORDERS", "cg", ["m1", "m2"]) # m1 starts consuming
116
+ await context.wait_for_consuming() # optional: block until it does
117
+ await delete_members(js, "ORDERS", "cg", ["m1"]) # ...and stops again
118
+ ```
119
+
120
+ With no `partitioning_filters` the group covers the whole stream and partitions
121
+ on the entire subject.
122
+
123
+ ## API
124
+
125
+ ### Static
126
+
127
+ | Symbol | Purpose |
128
+ |---|---|
129
+ | `create_static(js, stream, group, max_members, filters=None, members=None, member_mappings=None)` | Create the group (idempotent for an identical config). |
130
+ | `get_static_config(js, stream, group)` | The stored `StaticConsumerGroupConfig`. |
131
+ | `delete_static(js, stream, group)` | Delete the config, then sweep the member consumers. |
132
+ | `list_static_groups(js, stream)` | Group names defined on a stream. |
133
+ | `list_static_active_members(js, stream, group)` | Members whose consumer has a waiting pull. |
134
+ | `static_member_step_down(js, stream, group, member)` | Unpin the member's active instance. |
135
+ | `static_consume(js, stream, group, member, handler, config=None)` | Join and consume → `StaticConsumeContext`. |
136
+
137
+ ### Elastic
138
+
139
+ | Symbol | Purpose |
140
+ |---|---|
141
+ | `create_elastic(js, stream, group, max_members, partitioning_filters=None, max_buffered_msgs=-1, max_buffered_bytes=-1)` | Create the group and its work-queue stream. |
142
+ | `get_elastic_config(js, stream, group)` | The stored `ElasticConsumerGroupConfig`. |
143
+ | `delete_elastic(js, stream, group)` | Delete the config and the group's stream. |
144
+ | `list_elastic_groups(js, stream)` | Group names defined on a stream. |
145
+ | `add_members(js, stream, group, members)` / `delete_members(...)` | Change the membership (CAS-guarded). |
146
+ | `set_member_mappings(js, stream, group, mappings)` / `delete_member_mappings(...)` | Explicit partition assignment. |
147
+ | `list_elastic_active_members(js, stream, group)` | Members that currently have a consumer. |
148
+ | `elastic_is_in_membership_and_active(js, stream, group, member)` | `(in_membership, is_active)` for one member. |
149
+ | `elastic_member_step_down(js, stream, group, member)` | Unpin the member's active instance. |
150
+ | `elastic_consume(js, stream, group, member, handler, config=None)` | Join and consume → `ElasticConsumeContext`. |
151
+
152
+ ### Consume context
153
+
154
+ Both `*_consume` calls perform I/O, so they are plain coroutines (no optional
155
+ `await` no-op — there is nothing to make optional). The context they return:
156
+
157
+ | Member | Purpose |
158
+ |---|---|
159
+ | `await ctx.stop()` | End the session. Idempotent, deterministic, safe to call **from inside a handler**. |
160
+ | `await ctx.wait()` | Block until the session ends; re-raises whatever terminated it. |
161
+ | `await ctx.wait_for_consuming(consuming=True, *, timeout=None)` | Block until this instance is (or stops being) attached to a consumer. |
162
+ | `async with ctx: ...` | Stops on exit. |
163
+ | `ctx.consuming` | Whether this instance holds a consumer (not whether it is the *pinned* one). |
164
+ | `ctx.error` | What terminated the session, or `None`. |
165
+ | `ctx.recovered_errors` / `ctx.last_recovered_error` | Recoverable faults absorbed while (re)joining — counted, never silent. |
166
+
167
+ `handler` is `async (PartitionedMsg) -> None`, invoked **serially**. A handler
168
+ exception terminates the session and surfaces from `wait()` / `error` — it is
169
+ never swallowed. `PartitionedMsg` exposes `subject` (partition token stripped),
170
+ `partitioned_subject`, `partition`, `data`/`payload`, `headers`, `metadata`,
171
+ `reply`, `message` (the underlying `JsMsg`) and the full ack surface
172
+ (`ack`, `ack_sync`, `nak`, `term`, `in_progress`).
173
+
174
+ ### Pure logic (importable, unit-tested against the oracle)
175
+
176
+ `generate_partition_filters`, `static_get_partition_filters`,
177
+ `elastic_get_partition_filters`, `partitioning_transform_destination`,
178
+ `compose_key`, `compose_static_consumer_name`, `compose_group_stream_name`,
179
+ `strip_partition`, `validate_static_config`, `validate_elastic_config`.
180
+
181
+ Errors (all subclass `natsio.jetstream.JetStreamError`): `ConsumerGroupError`,
182
+ `ConsumerGroupConfigError`, `ConsumerGroupNotFoundError`,
183
+ `ConsumerGroupExistsError`, `MemberNotInGroupError`, `GroupConfigChangedError`.
184
+
185
+ ## Wire contract
186
+
187
+ Everything below is pinned byte-for-byte to orbit.go so a group created by
188
+ either library is administered and consumed by the other.
189
+
190
+ - **KV buckets** `static-consumer-groups` / `elastic-consumer-groups`, key
191
+ `"<stream>.<group>"`.
192
+ - **Static config JSON**: `max_members`, `filters`, `members`, `member_mappings`
193
+ (the last three omitted when empty — Go tags them `omitempty` and compares
194
+ `member_mappings` with `reflect.DeepEqual`, where `[]` ≠ `nil`).
195
+ - **Elastic config JSON**: `max_members`, `partitioning_filters`
196
+ (`{filter, partitioning_wildcards}`), **`max_buffered_msg`** (singular — that
197
+ is the tag orbit.go puts on `MaxBufferedMsgs`), `max_buffered_bytes`,
198
+ `members`, `member_mappings`.
199
+ - **Static consumer name** `"<group>-<member>"`; **elastic consumer name**
200
+ `"<member>"` on the group's stream `"<stream>-<group>"`.
201
+ - **Priority group** `"PCG"`, policy `pinned_client`, `priority_timeout`
202
+ (nats.go's `PinnedTTL`) `= max(ack_wait, 1s)`; pulls carry `group=PCG` and
203
+ expire after `max(ack_wait/2, 1s)`.
204
+ - **Elastic group stream**: work-queue retention, `discard=new`,
205
+ `allow_direct`, same replicas/storage as the origin, one source with one
206
+ subject transform per partitioning filter, destination
207
+ `{{Partition(N[,w…])}}.<filter with {{Wildcard(i)}} substituted>`.
208
+ - **Partition assignment**: membership deduplicated, sorted, capped to
209
+ `max_members`; each member gets a contiguous run of `max_members //
210
+ len(members)` partitions and the remainder is dealt round-robin from the
211
+ front. Member mappings always yield `"<partition>.>"` filters (the configured
212
+ subject filters are ignored — the oracle's behaviour, load-bearing for
213
+ interop).
214
+
215
+ ## Ack wait drives failover
216
+
217
+ Every reactivity timer derives from the `ack_wait` in the `ConsumerConfig` you
218
+ pass (default **5s**): pinned TTL `max(ack_wait, 1s)`, pull expiry
219
+ `max(ack_wait/2, 1s)`, elastic inactive threshold and self-correction tick
220
+ `ack_wait` (+0.5s). **Use `ack_wait >= 2s`**: below that the pull-expiry floor
221
+ of 1s collides with the pinned TTL and the pinned instance can flap between
222
+ instances of the same member (harmless but noisy — the same caveat the oracle
223
+ documents).
224
+
225
+ ## Deliberate divergences from the oracle
226
+
227
+ Everything on the wire matches; these are local behaviours where the oracle is
228
+ either buggy or silent:
229
+
230
+ 1. **Duplicate partition filters are deduplicated.** The oracle emits one
231
+ `"<partition>.>"` per configured filter when member mappings are used, so a
232
+ group with mappings *and* more than one filter asks the server for the same
233
+ filter twice and is rejected with err_code **10138**
234
+ (`consumer subject filters cannot overlap`) — that combination can never
235
+ create a consumer. We drop the duplicate; the requested subjects are
236
+ identical, so nothing else changes. Live-regression-tested.
237
+ 2. **`elastic_is_in_membership_and_active` answers about the member you asked
238
+ about.** The oracle's version never compares against the member name, so it
239
+ reports "active" whenever *any* member has a consumer.
240
+ 3. **Creates are compare-and-set.** `create_static` / `create_elastic` write
241
+ with `create()` (not `put()`), and every elastic membership/mapping update
242
+ is CAS'd on the entry's revision, so a concurrent administrator loses loudly
243
+ (`WrongLastSequenceError`) instead of silently clobbering. The oracle CASes
244
+ only `AddMembers`/`DeleteMembers`.
245
+ 4. **Membership is stored sorted and deduplicated.** The oracle stores it in Go
246
+ map order (random). The assignment sorts anyway; this only makes the stored
247
+ bytes stable.
248
+ 5. **A deleted group is a normal end even if the consumer sweep wins the race.**
249
+ `delete_static` deletes the config and then the consumers; if a member sees
250
+ its consumer vanish first, it re-reads the config before deciding, so
251
+ `wait()` still returns cleanly rather than raising `ConsumerDeletedError`.
252
+ 6. **Purge counts as deletion.** A purged config key ends the session the same
253
+ way a deleted one does; the oracle checks only for the delete operation.
254
+ 7. **A handler exception is fatal and reported — for both flavours.** It ends
255
+ the session and surfaces from `wait()` / `error`; it is never folded into
256
+ the recoverable-fault path, so a poison message cannot be redelivered
257
+ forever (with `max_ack_pending=1` that would head-of-line block the whole
258
+ partition). Only transport-side churn — the consumer deleted underneath a
259
+ member during elastic membership changes — is recoverable. Go's callback
260
+ would just panic in its own goroutine.
261
+
262
+ ## Scope limits
263
+
264
+ - **No CLI.** The oracle ships a `cg` command-line tool; this package is a
265
+ library only.
266
+ - **Static groups are not elastic, by design.** Any change to a static group's
267
+ stored config stops every member instance — including changes made by another
268
+ tool. That is the oracle's contract, not an implementation shortcut.
269
+ - **`max_members` is the partition count** and cannot be changed for the life
270
+ of a group (it is baked into the elastic stream's subject transform).
271
+ - **Members beyond `max_members` get nothing.** The membership is capped after
272
+ sorting, so the extra names are silently unassigned — oracle parity.
273
+ - **Elastic membership changes are eventually consistent.** Members apply a
274
+ change independently, so during the transition a message can still be served
275
+ to the member that is about to lose that partition. Nothing is lost or
276
+ duplicated (the work-queue stream and the ack guarantee that), but the split
277
+ of a batch published *during* the change is not deterministic.
278
+ - **`max_buffered_msgs` pauses sourcing** for at least a second when the
279
+ work-queue stream fills. Size it above a second of consumption or expect
280
+ small delivery gaps (oracle behaviour, inherited from the server).
281
+ - **Single-replica testing.** The live suite runs against one server; the
282
+ replica/storage inheritance for the KV bucket and the group stream follows
283
+ the origin stream but is not exercised in a cluster here.
284
+
285
+ ## Example
286
+
287
+ A runnable script is at [`examples/basic.py`](https://github.com/corruptmane/natsio/blob/main/extensions/natsio-pcgroups/examples/basic.py) — start a server with `just server`, then:
288
+
289
+ ```bash
290
+ python extensions/natsio-pcgroups/examples/basic.py
291
+ ```
@@ -0,0 +1,270 @@
1
+ # natsio-pcgroups
2
+
3
+ Partitioned consumer groups over JetStream — **static** and **elastic** —
4
+ mirroring [`orbit.go/pcgroups`](https://github.com/synadia-io/orbit.go/tree/main/pcgroups).
5
+ Distribution `natsio-pcgroups`, imported as `natsio.pcgroups`. Zero runtime
6
+ dependencies beyond `natsio`. Pre-1.0, no API-stability promises.
7
+
8
+ Requires **nats-server 2.11+** (ADR-42 pinned-client priority groups, subject
9
+ transforms). Verified against the repo-pinned **2.14.3**.
10
+
11
+ ## What it is
12
+
13
+ Strictly ordered consumption of a JetStream stream means `max_ack_pending=1` —
14
+ one message in flight, no horizontal scaling. A *consumer group* fixes that by
15
+ splitting the stream into `max_members` **partitions** keyed by subject and
16
+ handing each **member** a fixed subset of them. Members run in parallel;
17
+ messages that share a partitioning key are still processed one at a time, in
18
+ order.
19
+
20
+ Each member may run as **several instances**. The server pins exactly one of
21
+ them (ADR-42 `pinned_client`) and the rest sit as hot standbys, so a member is
22
+ highly available without ever double-processing a key.
23
+
24
+ The group's config lives in a KV bucket; the library derives every member's
25
+ partition filters from it, creates the consumers, and hands your handler
26
+ messages with the **partition token already stripped from the subject**, so
27
+ existing handler code works unchanged.
28
+
29
+ | | Static | Elastic |
30
+ |---|---|---|
31
+ | Partition token in the source subjects | required (stream subject transform) | not needed |
32
+ | Extra stream | none — consumers sit on your stream | a sourced work-queue stream `<stream>-<group>` |
33
+ | Membership | fixed at creation | changed at any time |
34
+ | Config bucket | `static-consumer-groups` | `elastic-consumer-groups` |
35
+ | Ack policy | your choice | must be `explicit` |
36
+
37
+ ## Static groups
38
+
39
+ The stream already carries the partition number as the first subject token —
40
+ put there on ingest by a stream-level subject transform:
41
+
42
+ ```python
43
+ import natsio
44
+ from natsio.jetstream import AckPolicy, ConsumerConfig, StreamConfig, SubjectTransform
45
+ from natsio.pcgroups import PartitionedMsg, create_static, static_consume
46
+
47
+ nc = await natsio.connect("nats://localhost")
48
+ js = nc.jetstream()
49
+
50
+ # Partition "orders.<id>" over 10 partitions on ingest.
51
+ await js.create_stream(
52
+ StreamConfig(
53
+ name="ORDERS",
54
+ subjects=["orders.*"],
55
+ subject_transform=SubjectTransform(src="orders.*", dest="{{partition(10,1)}}.orders.{{wildcard(1)}}"),
56
+ )
57
+ )
58
+
59
+ await create_static(js, "ORDERS", "cg", 10, ["orders.*"], ["m1", "m2"])
60
+
61
+ async def handle(msg: PartitionedMsg) -> None:
62
+ print(msg.subject, msg.data) # "orders.42" — the partition token is gone
63
+ await msg.ack()
64
+
65
+ context = await static_consume(
66
+ js, "ORDERS", "cg", "m1", handle,
67
+ ConsumerConfig(ack_policy=AckPolicy.EXPLICIT, max_ack_pending=1),
68
+ )
69
+ ...
70
+ await context.stop()
71
+ ```
72
+
73
+ A static group's config is **immutable**. Changing it (or deleting the group)
74
+ stops every running member instance; to re-shape the membership, delete the
75
+ group and create it again.
76
+
77
+ ## Elastic groups
78
+
79
+ The stream needs no partition token. Creating the group creates a sourced
80
+ **work-queue** stream that inserts one, and the membership can change at any
81
+ time — running members start and stop consuming as they are added and dropped:
82
+
83
+ ```python
84
+ from natsio.pcgroups import PartitioningFilter, add_members, create_elastic, delete_members, elastic_consume
85
+
86
+ await js.create_stream(StreamConfig(name="ORDERS", subjects=["orders.*"]))
87
+
88
+ # Partition on the first wildcard of "orders.*", over 10 partitions.
89
+ await create_elastic(js, "ORDERS", "cg", 10, [PartitioningFilter(filter="orders.*", partitioning_wildcards=[1])])
90
+
91
+ # Members may join before they are in the membership: they simply wait.
92
+ context = await elastic_consume(js, "ORDERS", "cg", "m1", handle, ConsumerConfig(ack_policy=AckPolicy.EXPLICIT))
93
+
94
+ await add_members(js, "ORDERS", "cg", ["m1", "m2"]) # m1 starts consuming
95
+ await context.wait_for_consuming() # optional: block until it does
96
+ await delete_members(js, "ORDERS", "cg", ["m1"]) # ...and stops again
97
+ ```
98
+
99
+ With no `partitioning_filters` the group covers the whole stream and partitions
100
+ on the entire subject.
101
+
102
+ ## API
103
+
104
+ ### Static
105
+
106
+ | Symbol | Purpose |
107
+ |---|---|
108
+ | `create_static(js, stream, group, max_members, filters=None, members=None, member_mappings=None)` | Create the group (idempotent for an identical config). |
109
+ | `get_static_config(js, stream, group)` | The stored `StaticConsumerGroupConfig`. |
110
+ | `delete_static(js, stream, group)` | Delete the config, then sweep the member consumers. |
111
+ | `list_static_groups(js, stream)` | Group names defined on a stream. |
112
+ | `list_static_active_members(js, stream, group)` | Members whose consumer has a waiting pull. |
113
+ | `static_member_step_down(js, stream, group, member)` | Unpin the member's active instance. |
114
+ | `static_consume(js, stream, group, member, handler, config=None)` | Join and consume → `StaticConsumeContext`. |
115
+
116
+ ### Elastic
117
+
118
+ | Symbol | Purpose |
119
+ |---|---|
120
+ | `create_elastic(js, stream, group, max_members, partitioning_filters=None, max_buffered_msgs=-1, max_buffered_bytes=-1)` | Create the group and its work-queue stream. |
121
+ | `get_elastic_config(js, stream, group)` | The stored `ElasticConsumerGroupConfig`. |
122
+ | `delete_elastic(js, stream, group)` | Delete the config and the group's stream. |
123
+ | `list_elastic_groups(js, stream)` | Group names defined on a stream. |
124
+ | `add_members(js, stream, group, members)` / `delete_members(...)` | Change the membership (CAS-guarded). |
125
+ | `set_member_mappings(js, stream, group, mappings)` / `delete_member_mappings(...)` | Explicit partition assignment. |
126
+ | `list_elastic_active_members(js, stream, group)` | Members that currently have a consumer. |
127
+ | `elastic_is_in_membership_and_active(js, stream, group, member)` | `(in_membership, is_active)` for one member. |
128
+ | `elastic_member_step_down(js, stream, group, member)` | Unpin the member's active instance. |
129
+ | `elastic_consume(js, stream, group, member, handler, config=None)` | Join and consume → `ElasticConsumeContext`. |
130
+
131
+ ### Consume context
132
+
133
+ Both `*_consume` calls perform I/O, so they are plain coroutines (no optional
134
+ `await` no-op — there is nothing to make optional). The context they return:
135
+
136
+ | Member | Purpose |
137
+ |---|---|
138
+ | `await ctx.stop()` | End the session. Idempotent, deterministic, safe to call **from inside a handler**. |
139
+ | `await ctx.wait()` | Block until the session ends; re-raises whatever terminated it. |
140
+ | `await ctx.wait_for_consuming(consuming=True, *, timeout=None)` | Block until this instance is (or stops being) attached to a consumer. |
141
+ | `async with ctx: ...` | Stops on exit. |
142
+ | `ctx.consuming` | Whether this instance holds a consumer (not whether it is the *pinned* one). |
143
+ | `ctx.error` | What terminated the session, or `None`. |
144
+ | `ctx.recovered_errors` / `ctx.last_recovered_error` | Recoverable faults absorbed while (re)joining — counted, never silent. |
145
+
146
+ `handler` is `async (PartitionedMsg) -> None`, invoked **serially**. A handler
147
+ exception terminates the session and surfaces from `wait()` / `error` — it is
148
+ never swallowed. `PartitionedMsg` exposes `subject` (partition token stripped),
149
+ `partitioned_subject`, `partition`, `data`/`payload`, `headers`, `metadata`,
150
+ `reply`, `message` (the underlying `JsMsg`) and the full ack surface
151
+ (`ack`, `ack_sync`, `nak`, `term`, `in_progress`).
152
+
153
+ ### Pure logic (importable, unit-tested against the oracle)
154
+
155
+ `generate_partition_filters`, `static_get_partition_filters`,
156
+ `elastic_get_partition_filters`, `partitioning_transform_destination`,
157
+ `compose_key`, `compose_static_consumer_name`, `compose_group_stream_name`,
158
+ `strip_partition`, `validate_static_config`, `validate_elastic_config`.
159
+
160
+ Errors (all subclass `natsio.jetstream.JetStreamError`): `ConsumerGroupError`,
161
+ `ConsumerGroupConfigError`, `ConsumerGroupNotFoundError`,
162
+ `ConsumerGroupExistsError`, `MemberNotInGroupError`, `GroupConfigChangedError`.
163
+
164
+ ## Wire contract
165
+
166
+ Everything below is pinned byte-for-byte to orbit.go so a group created by
167
+ either library is administered and consumed by the other.
168
+
169
+ - **KV buckets** `static-consumer-groups` / `elastic-consumer-groups`, key
170
+ `"<stream>.<group>"`.
171
+ - **Static config JSON**: `max_members`, `filters`, `members`, `member_mappings`
172
+ (the last three omitted when empty — Go tags them `omitempty` and compares
173
+ `member_mappings` with `reflect.DeepEqual`, where `[]` ≠ `nil`).
174
+ - **Elastic config JSON**: `max_members`, `partitioning_filters`
175
+ (`{filter, partitioning_wildcards}`), **`max_buffered_msg`** (singular — that
176
+ is the tag orbit.go puts on `MaxBufferedMsgs`), `max_buffered_bytes`,
177
+ `members`, `member_mappings`.
178
+ - **Static consumer name** `"<group>-<member>"`; **elastic consumer name**
179
+ `"<member>"` on the group's stream `"<stream>-<group>"`.
180
+ - **Priority group** `"PCG"`, policy `pinned_client`, `priority_timeout`
181
+ (nats.go's `PinnedTTL`) `= max(ack_wait, 1s)`; pulls carry `group=PCG` and
182
+ expire after `max(ack_wait/2, 1s)`.
183
+ - **Elastic group stream**: work-queue retention, `discard=new`,
184
+ `allow_direct`, same replicas/storage as the origin, one source with one
185
+ subject transform per partitioning filter, destination
186
+ `{{Partition(N[,w…])}}.<filter with {{Wildcard(i)}} substituted>`.
187
+ - **Partition assignment**: membership deduplicated, sorted, capped to
188
+ `max_members`; each member gets a contiguous run of `max_members //
189
+ len(members)` partitions and the remainder is dealt round-robin from the
190
+ front. Member mappings always yield `"<partition>.>"` filters (the configured
191
+ subject filters are ignored — the oracle's behaviour, load-bearing for
192
+ interop).
193
+
194
+ ## Ack wait drives failover
195
+
196
+ Every reactivity timer derives from the `ack_wait` in the `ConsumerConfig` you
197
+ pass (default **5s**): pinned TTL `max(ack_wait, 1s)`, pull expiry
198
+ `max(ack_wait/2, 1s)`, elastic inactive threshold and self-correction tick
199
+ `ack_wait` (+0.5s). **Use `ack_wait >= 2s`**: below that the pull-expiry floor
200
+ of 1s collides with the pinned TTL and the pinned instance can flap between
201
+ instances of the same member (harmless but noisy — the same caveat the oracle
202
+ documents).
203
+
204
+ ## Deliberate divergences from the oracle
205
+
206
+ Everything on the wire matches; these are local behaviours where the oracle is
207
+ either buggy or silent:
208
+
209
+ 1. **Duplicate partition filters are deduplicated.** The oracle emits one
210
+ `"<partition>.>"` per configured filter when member mappings are used, so a
211
+ group with mappings *and* more than one filter asks the server for the same
212
+ filter twice and is rejected with err_code **10138**
213
+ (`consumer subject filters cannot overlap`) — that combination can never
214
+ create a consumer. We drop the duplicate; the requested subjects are
215
+ identical, so nothing else changes. Live-regression-tested.
216
+ 2. **`elastic_is_in_membership_and_active` answers about the member you asked
217
+ about.** The oracle's version never compares against the member name, so it
218
+ reports "active" whenever *any* member has a consumer.
219
+ 3. **Creates are compare-and-set.** `create_static` / `create_elastic` write
220
+ with `create()` (not `put()`), and every elastic membership/mapping update
221
+ is CAS'd on the entry's revision, so a concurrent administrator loses loudly
222
+ (`WrongLastSequenceError`) instead of silently clobbering. The oracle CASes
223
+ only `AddMembers`/`DeleteMembers`.
224
+ 4. **Membership is stored sorted and deduplicated.** The oracle stores it in Go
225
+ map order (random). The assignment sorts anyway; this only makes the stored
226
+ bytes stable.
227
+ 5. **A deleted group is a normal end even if the consumer sweep wins the race.**
228
+ `delete_static` deletes the config and then the consumers; if a member sees
229
+ its consumer vanish first, it re-reads the config before deciding, so
230
+ `wait()` still returns cleanly rather than raising `ConsumerDeletedError`.
231
+ 6. **Purge counts as deletion.** A purged config key ends the session the same
232
+ way a deleted one does; the oracle checks only for the delete operation.
233
+ 7. **A handler exception is fatal and reported — for both flavours.** It ends
234
+ the session and surfaces from `wait()` / `error`; it is never folded into
235
+ the recoverable-fault path, so a poison message cannot be redelivered
236
+ forever (with `max_ack_pending=1` that would head-of-line block the whole
237
+ partition). Only transport-side churn — the consumer deleted underneath a
238
+ member during elastic membership changes — is recoverable. Go's callback
239
+ would just panic in its own goroutine.
240
+
241
+ ## Scope limits
242
+
243
+ - **No CLI.** The oracle ships a `cg` command-line tool; this package is a
244
+ library only.
245
+ - **Static groups are not elastic, by design.** Any change to a static group's
246
+ stored config stops every member instance — including changes made by another
247
+ tool. That is the oracle's contract, not an implementation shortcut.
248
+ - **`max_members` is the partition count** and cannot be changed for the life
249
+ of a group (it is baked into the elastic stream's subject transform).
250
+ - **Members beyond `max_members` get nothing.** The membership is capped after
251
+ sorting, so the extra names are silently unassigned — oracle parity.
252
+ - **Elastic membership changes are eventually consistent.** Members apply a
253
+ change independently, so during the transition a message can still be served
254
+ to the member that is about to lose that partition. Nothing is lost or
255
+ duplicated (the work-queue stream and the ack guarantee that), but the split
256
+ of a batch published *during* the change is not deterministic.
257
+ - **`max_buffered_msgs` pauses sourcing** for at least a second when the
258
+ work-queue stream fills. Size it above a second of consumption or expect
259
+ small delivery gaps (oracle behaviour, inherited from the server).
260
+ - **Single-replica testing.** The live suite runs against one server; the
261
+ replica/storage inheritance for the KV bucket and the group stream follows
262
+ the origin stream but is not exercised in a cluster here.
263
+
264
+ ## Example
265
+
266
+ A runnable script is at [`examples/basic.py`](https://github.com/corruptmane/natsio/blob/main/extensions/natsio-pcgroups/examples/basic.py) — start a server with `just server`, then:
267
+
268
+ ```bash
269
+ python extensions/natsio-pcgroups/examples/basic.py
270
+ ```
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.10,<0.11"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "natsio-pcgroups"
7
+ version = "0.1.0"
8
+ description = "Partitioned consumer groups for natsio — static and elastic, with pinned-member failover"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.13"
12
+ authors = [{ name = "Corrupt Mane", email = "corruptmane@gmail.com" }]
13
+ keywords = ["nats", "nats.io", "jetstream", "consumer-groups", "partitioning"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Framework :: AsyncIO",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = ["natsio"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/corruptmane/natsio"
26
+ Repository = "https://github.com/corruptmane/natsio"
27
+ Changelog = "https://github.com/corruptmane/natsio/blob/main/extensions/natsio-pcgroups/CHANGELOG.md"
28
+
29
+ [tool.uv.sources]
30
+ natsio = { workspace = true }
31
+
32
+ [tool.uv.build-backend]
33
+ module-name = "natsio.pcgroups"
34
+ namespace = true