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.
- natsio_pcgroups-0.1.0/PKG-INFO +291 -0
- natsio_pcgroups-0.1.0/README.md +270 -0
- natsio_pcgroups-0.1.0/pyproject.toml +34 -0
- natsio_pcgroups-0.1.0/src/natsio/pcgroups/__init__.py +147 -0
- natsio_pcgroups-0.1.0/src/natsio/pcgroups/buckets.py +45 -0
- natsio_pcgroups-0.1.0/src/natsio/pcgroups/elastic.py +561 -0
- natsio_pcgroups-0.1.0/src/natsio/pcgroups/entities.py +222 -0
- natsio_pcgroups-0.1.0/src/natsio/pcgroups/errors.py +65 -0
- natsio_pcgroups-0.1.0/src/natsio/pcgroups/partitions.py +170 -0
- natsio_pcgroups-0.1.0/src/natsio/pcgroups/py.typed +0 -0
- natsio_pcgroups-0.1.0/src/natsio/pcgroups/session.py +595 -0
- natsio_pcgroups-0.1.0/src/natsio/pcgroups/static.py +321 -0
|
@@ -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
|