flo-python 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev3__py3-none-any.whl
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.
- flo/__init__.py +80 -8
- flo/actions.py +44 -15
- flo/client.py +137 -16
- flo/exceptions.py +21 -0
- flo/kv.py +6 -6
- flo/processing.py +341 -0
- flo/streams.py +17 -16
- flo/types.py +440 -190
- flo/wire.py +107 -49
- flo/worker.py +610 -42
- flo/workflows.py +463 -0
- {flo_python-0.1.0.dev2.dist-info → flo_python-0.1.0.dev3.dist-info}/METADATA +29 -4
- flo_python-0.1.0.dev3.dist-info/RECORD +16 -0
- {flo_python-0.1.0.dev2.dist-info → flo_python-0.1.0.dev3.dist-info}/WHEEL +1 -1
- flo_python-0.1.0.dev2.dist-info/RECORD +0 -14
- {flo_python-0.1.0.dev2.dist-info → flo_python-0.1.0.dev3.dist-info}/licenses/LICENSE +0 -0
flo/processing.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Flo Processing Operations
|
|
2
|
+
|
|
3
|
+
Stream processing operations: submit, status, list, stop, cancel,
|
|
4
|
+
savepoint, restore, rescale, sync.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import builtins
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import struct
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from .types import (
|
|
16
|
+
OpCode,
|
|
17
|
+
ProcessingCancelOptions,
|
|
18
|
+
ProcessingListEntry,
|
|
19
|
+
ProcessingListOptions,
|
|
20
|
+
ProcessingRescaleOptions,
|
|
21
|
+
ProcessingRestoreOptions,
|
|
22
|
+
ProcessingSavepointOptions,
|
|
23
|
+
ProcessingStatusOptions,
|
|
24
|
+
ProcessingStatusResult,
|
|
25
|
+
ProcessingStopOptions,
|
|
26
|
+
ProcessingSubmitOptions,
|
|
27
|
+
ProcessingSyncOptions,
|
|
28
|
+
ProcessingSyncResult,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from .client import FloClient
|
|
33
|
+
|
|
34
|
+
_PROCESSING_STATUS_NAMES = ["running", "stopped", "cancelled", "failed", "completed"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProcessingOperations:
|
|
38
|
+
"""Processing operations for the Flo client."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, client: FloClient) -> None:
|
|
41
|
+
self._client = client
|
|
42
|
+
|
|
43
|
+
# =========================================================================
|
|
44
|
+
# Core Operations
|
|
45
|
+
# =========================================================================
|
|
46
|
+
|
|
47
|
+
async def submit(
|
|
48
|
+
self, yaml: str | bytes, options: ProcessingSubmitOptions | None = None
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Submit a processing job from a YAML definition. Returns the job ID."""
|
|
51
|
+
opts = options or ProcessingSubmitOptions()
|
|
52
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
53
|
+
|
|
54
|
+
yaml_bytes = yaml.encode("utf-8") if isinstance(yaml, str) else yaml
|
|
55
|
+
|
|
56
|
+
resp = await self._client._send_and_check(
|
|
57
|
+
OpCode.PROCESSING_SUBMIT,
|
|
58
|
+
namespace,
|
|
59
|
+
b"",
|
|
60
|
+
yaml_bytes,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return resp.data.decode("utf-8")
|
|
64
|
+
|
|
65
|
+
async def status(
|
|
66
|
+
self, job_id: str, options: ProcessingStatusOptions | None = None
|
|
67
|
+
) -> ProcessingStatusResult | None:
|
|
68
|
+
"""Get the status of a processing job. Returns None if not found."""
|
|
69
|
+
opts = options or ProcessingStatusOptions()
|
|
70
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
71
|
+
|
|
72
|
+
resp = await self._client._send_and_check(
|
|
73
|
+
OpCode.PROCESSING_STATUS,
|
|
74
|
+
namespace,
|
|
75
|
+
job_id.encode("utf-8"),
|
|
76
|
+
b"",
|
|
77
|
+
allow_not_found=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
from .types import StatusCode
|
|
81
|
+
|
|
82
|
+
if resp.status == StatusCode.NOT_FOUND:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
return _parse_processing_status(resp.data)
|
|
86
|
+
|
|
87
|
+
async def list(self, options: ProcessingListOptions | None = None) -> list[ProcessingListEntry]:
|
|
88
|
+
"""List processing jobs."""
|
|
89
|
+
opts = options or ProcessingListOptions()
|
|
90
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
91
|
+
|
|
92
|
+
# Wire format: [limit:u32][cursor...]
|
|
93
|
+
cursor = opts.cursor or b""
|
|
94
|
+
value = struct.pack("<I", opts.limit) + cursor
|
|
95
|
+
|
|
96
|
+
resp = await self._client._send_and_check(
|
|
97
|
+
OpCode.PROCESSING_LIST,
|
|
98
|
+
namespace,
|
|
99
|
+
b"",
|
|
100
|
+
value,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return _parse_processing_list(resp.data)
|
|
104
|
+
|
|
105
|
+
async def stop(self, job_id: str, options: ProcessingStopOptions | None = None) -> None:
|
|
106
|
+
"""Gracefully stop a processing job."""
|
|
107
|
+
opts = options or ProcessingStopOptions()
|
|
108
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
109
|
+
|
|
110
|
+
await self._client._send_and_check(
|
|
111
|
+
OpCode.PROCESSING_STOP,
|
|
112
|
+
namespace,
|
|
113
|
+
job_id.encode("utf-8"),
|
|
114
|
+
b"",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def cancel(self, job_id: str, options: ProcessingCancelOptions | None = None) -> None:
|
|
118
|
+
"""Force-cancel a processing job."""
|
|
119
|
+
opts = options or ProcessingCancelOptions()
|
|
120
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
121
|
+
|
|
122
|
+
await self._client._send_and_check(
|
|
123
|
+
OpCode.PROCESSING_CANCEL,
|
|
124
|
+
namespace,
|
|
125
|
+
job_id.encode("utf-8"),
|
|
126
|
+
b"",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def savepoint(
|
|
130
|
+
self, job_id: str, options: ProcessingSavepointOptions | None = None
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Trigger a savepoint for a processing job. Returns the savepoint ID."""
|
|
133
|
+
opts = options or ProcessingSavepointOptions()
|
|
134
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
135
|
+
|
|
136
|
+
resp = await self._client._send_and_check(
|
|
137
|
+
OpCode.PROCESSING_SAVEPOINT,
|
|
138
|
+
namespace,
|
|
139
|
+
job_id.encode("utf-8"),
|
|
140
|
+
b"",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return resp.data.decode("utf-8")
|
|
144
|
+
|
|
145
|
+
async def restore(
|
|
146
|
+
self,
|
|
147
|
+
job_id: str,
|
|
148
|
+
savepoint_id: str,
|
|
149
|
+
options: ProcessingRestoreOptions | None = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Restore a processing job from a savepoint."""
|
|
152
|
+
opts = options or ProcessingRestoreOptions()
|
|
153
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
154
|
+
|
|
155
|
+
await self._client._send_and_check(
|
|
156
|
+
OpCode.PROCESSING_RESTORE,
|
|
157
|
+
namespace,
|
|
158
|
+
job_id.encode("utf-8"),
|
|
159
|
+
savepoint_id.encode("utf-8"),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async def rescale(
|
|
163
|
+
self,
|
|
164
|
+
job_id: str,
|
|
165
|
+
parallelism: int,
|
|
166
|
+
options: ProcessingRescaleOptions | None = None,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Change the parallelism of a processing job."""
|
|
169
|
+
opts = options or ProcessingRescaleOptions()
|
|
170
|
+
namespace = self._client.get_namespace(opts.namespace)
|
|
171
|
+
|
|
172
|
+
value = struct.pack("<I", parallelism)
|
|
173
|
+
|
|
174
|
+
await self._client._send_and_check(
|
|
175
|
+
OpCode.PROCESSING_RESCALE,
|
|
176
|
+
namespace,
|
|
177
|
+
job_id.encode("utf-8"),
|
|
178
|
+
value,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# =========================================================================
|
|
182
|
+
# Declarative Sync
|
|
183
|
+
# =========================================================================
|
|
184
|
+
|
|
185
|
+
async def sync(
|
|
186
|
+
self, yaml_path: str, options: ProcessingSyncOptions | None = None
|
|
187
|
+
) -> ProcessingSyncResult:
|
|
188
|
+
"""Sync a processing job from a YAML file. Returns name + job ID."""
|
|
189
|
+
with open(yaml_path) as f:
|
|
190
|
+
yaml_content = f.read()
|
|
191
|
+
return await self.sync_bytes(yaml_content.encode("utf-8"), options)
|
|
192
|
+
|
|
193
|
+
async def sync_bytes(
|
|
194
|
+
self, yaml: bytes, options: ProcessingSyncOptions | None = None
|
|
195
|
+
) -> ProcessingSyncResult:
|
|
196
|
+
"""Sync raw YAML bytes. Submits a new job and returns name + job ID."""
|
|
197
|
+
opts = options or ProcessingSyncOptions()
|
|
198
|
+
name = _extract_processing_meta(yaml)
|
|
199
|
+
|
|
200
|
+
submit_opts = ProcessingSubmitOptions(namespace=opts.namespace)
|
|
201
|
+
job_id = await self.submit(yaml, submit_opts)
|
|
202
|
+
|
|
203
|
+
return ProcessingSyncResult(name=name, job_id=job_id)
|
|
204
|
+
|
|
205
|
+
async def sync_dir(
|
|
206
|
+
self, dir_path: str, options: ProcessingSyncOptions | None = None
|
|
207
|
+
) -> builtins.list[ProcessingSyncResult]:
|
|
208
|
+
"""Sync all YAML files in a directory."""
|
|
209
|
+
results: builtins.list[ProcessingSyncResult] = []
|
|
210
|
+
for entry in sorted(os.listdir(dir_path)):
|
|
211
|
+
if entry.endswith((".yaml", ".yml")):
|
|
212
|
+
file_path = os.path.join(dir_path, entry)
|
|
213
|
+
result = await self.sync(file_path, options)
|
|
214
|
+
results.append(result)
|
|
215
|
+
return results
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# =============================================================================
|
|
219
|
+
# Wire Format Parsers
|
|
220
|
+
# =============================================================================
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _parse_processing_status(data: bytes) -> ProcessingStatusResult:
|
|
224
|
+
"""Parse binary wire format for processing job status.
|
|
225
|
+
|
|
226
|
+
Wire format: [job_id_len:u16][job_id][name_len:u16][name][status:u8]
|
|
227
|
+
[parallelism:u32][batch_size:u32][records_processed:u64][created_at:i64]
|
|
228
|
+
"""
|
|
229
|
+
pos = 0
|
|
230
|
+
|
|
231
|
+
def read_u16() -> int:
|
|
232
|
+
nonlocal pos
|
|
233
|
+
v: int = struct.unpack_from("<H", data, pos)[0]
|
|
234
|
+
pos += 2
|
|
235
|
+
return v
|
|
236
|
+
|
|
237
|
+
def read_str() -> str:
|
|
238
|
+
n = read_u16()
|
|
239
|
+
nonlocal pos
|
|
240
|
+
s = data[pos : pos + n].decode("utf-8")
|
|
241
|
+
pos += n
|
|
242
|
+
return s
|
|
243
|
+
|
|
244
|
+
job_id = read_str()
|
|
245
|
+
name = read_str()
|
|
246
|
+
|
|
247
|
+
status_byte = data[pos]
|
|
248
|
+
pos += 1
|
|
249
|
+
status_str = (
|
|
250
|
+
_PROCESSING_STATUS_NAMES[status_byte]
|
|
251
|
+
if status_byte < len(_PROCESSING_STATUS_NAMES)
|
|
252
|
+
else f"unknown({status_byte})"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
(parallelism,) = struct.unpack_from("<I", data, pos)
|
|
256
|
+
pos += 4
|
|
257
|
+
|
|
258
|
+
(batch_size,) = struct.unpack_from("<I", data, pos)
|
|
259
|
+
pos += 4
|
|
260
|
+
|
|
261
|
+
(records_processed,) = struct.unpack_from("<Q", data, pos)
|
|
262
|
+
pos += 8
|
|
263
|
+
|
|
264
|
+
(created_at,) = struct.unpack_from("<q", data, pos)
|
|
265
|
+
|
|
266
|
+
return ProcessingStatusResult(
|
|
267
|
+
job_id=job_id,
|
|
268
|
+
name=name,
|
|
269
|
+
status=status_str,
|
|
270
|
+
parallelism=parallelism,
|
|
271
|
+
batch_size=batch_size,
|
|
272
|
+
records_processed=records_processed,
|
|
273
|
+
created_at=created_at,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _parse_processing_list(data: bytes) -> list[ProcessingListEntry]:
|
|
278
|
+
"""Parse binary wire format for processing job list.
|
|
279
|
+
|
|
280
|
+
Wire format: [count:u32]([name_len:u16][name][job_id_len:u16][job_id]
|
|
281
|
+
[status_len:u16][status][parallelism:u32][created_at:i64])*
|
|
282
|
+
"""
|
|
283
|
+
if len(data) < 4:
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
(count,) = struct.unpack_from("<I", data, 0)
|
|
287
|
+
pos = 4
|
|
288
|
+
results: list[ProcessingListEntry] = []
|
|
289
|
+
|
|
290
|
+
def read_u16() -> int:
|
|
291
|
+
nonlocal pos
|
|
292
|
+
v: int = struct.unpack_from("<H", data, pos)[0]
|
|
293
|
+
pos += 2
|
|
294
|
+
return v
|
|
295
|
+
|
|
296
|
+
def read_str() -> str:
|
|
297
|
+
n = read_u16()
|
|
298
|
+
nonlocal pos
|
|
299
|
+
s = data[pos : pos + n].decode("utf-8")
|
|
300
|
+
pos += n
|
|
301
|
+
return s
|
|
302
|
+
|
|
303
|
+
for _ in range(count):
|
|
304
|
+
name = read_str()
|
|
305
|
+
job_id = read_str()
|
|
306
|
+
status = read_str()
|
|
307
|
+
|
|
308
|
+
(parallelism,) = struct.unpack_from("<I", data, pos)
|
|
309
|
+
pos += 4
|
|
310
|
+
|
|
311
|
+
(created_at,) = struct.unpack_from("<q", data, pos)
|
|
312
|
+
pos += 8
|
|
313
|
+
|
|
314
|
+
results.append(
|
|
315
|
+
ProcessingListEntry(
|
|
316
|
+
name=name,
|
|
317
|
+
job_id=job_id,
|
|
318
|
+
status=status,
|
|
319
|
+
parallelism=parallelism,
|
|
320
|
+
created_at=created_at,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return results
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# =============================================================================
|
|
328
|
+
# YAML Metadata Extraction
|
|
329
|
+
# =============================================================================
|
|
330
|
+
|
|
331
|
+
_YAML_FIELD_RE = re.compile(r"""^\s*['"]?(\w+)['"]?\s*:\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$""")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _extract_processing_meta(data: bytes) -> str:
|
|
335
|
+
"""Extract the job name from processing YAML."""
|
|
336
|
+
text = data.decode("utf-8")
|
|
337
|
+
for line in text.split("\n"):
|
|
338
|
+
m = _YAML_FIELD_RE.match(line)
|
|
339
|
+
if m and m.group(1) == "name":
|
|
340
|
+
return m.group(2).strip()
|
|
341
|
+
raise ValueError("flo: processing YAML missing required 'name' field")
|
flo/streams.py
CHANGED
|
@@ -15,6 +15,7 @@ from .types import (
|
|
|
15
15
|
StreamGroupJoinOptions,
|
|
16
16
|
StreamGroupNackOptions,
|
|
17
17
|
StreamGroupReadOptions,
|
|
18
|
+
StreamID,
|
|
18
19
|
StreamInfo,
|
|
19
20
|
StreamInfoOptions,
|
|
20
21
|
StreamReadOptions,
|
|
@@ -54,11 +55,11 @@ class StreamOperations:
|
|
|
54
55
|
options: Optional append options.
|
|
55
56
|
|
|
56
57
|
Returns:
|
|
57
|
-
StreamAppendResult with
|
|
58
|
+
StreamAppendResult with id (StreamID).
|
|
58
59
|
|
|
59
60
|
Example:
|
|
60
61
|
result = await client.stream.append("events", b'{"event": "click"}')
|
|
61
|
-
print(f"Appended:
|
|
62
|
+
print(f"Appended: id={result.id}")
|
|
62
63
|
"""
|
|
63
64
|
opts = options or StreamAppendOptions()
|
|
64
65
|
namespace = self._client.get_namespace(opts.namespace)
|
|
@@ -99,12 +100,12 @@ class StreamOperations:
|
|
|
99
100
|
# Read from specific StreamID
|
|
100
101
|
from flo.types import StreamID
|
|
101
102
|
result = await client.stream.read("events", StreamReadOptions(
|
|
102
|
-
start=StreamID
|
|
103
|
+
start=StreamID(timestamp_ms=0, sequence=100), count=10
|
|
103
104
|
))
|
|
104
105
|
|
|
105
106
|
# Blocking read (long polling)
|
|
106
107
|
result = await client.stream.read("events", StreamReadOptions(
|
|
107
|
-
start=StreamID
|
|
108
|
+
start=StreamID(timestamp_ms=0, sequence=100), block_ms=30000
|
|
108
109
|
))
|
|
109
110
|
"""
|
|
110
111
|
opts = options or StreamReadOptions()
|
|
@@ -158,7 +159,7 @@ class StreamOperations:
|
|
|
158
159
|
options: Optional info options.
|
|
159
160
|
|
|
160
161
|
Returns:
|
|
161
|
-
StreamInfo with
|
|
162
|
+
StreamInfo with first_id, last_id, count, bytes_size.
|
|
162
163
|
|
|
163
164
|
Example:
|
|
164
165
|
info = await client.stream.info("events")
|
|
@@ -312,7 +313,7 @@ class StreamOperations:
|
|
|
312
313
|
result = await client.stream.group_read("events", "processors", "worker-1")
|
|
313
314
|
for record in result.records:
|
|
314
315
|
process(record.payload)
|
|
315
|
-
await client.stream.group_ack("events", "processors", [record.
|
|
316
|
+
await client.stream.group_ack("events", "processors", [record.id])
|
|
316
317
|
"""
|
|
317
318
|
opts = options or StreamGroupReadOptions()
|
|
318
319
|
namespace = self._client.get_namespace(opts.namespace)
|
|
@@ -343,7 +344,7 @@ class StreamOperations:
|
|
|
343
344
|
self,
|
|
344
345
|
stream: str,
|
|
345
346
|
group: str,
|
|
346
|
-
|
|
347
|
+
ids: list[StreamID],
|
|
347
348
|
options: StreamGroupAckOptions | None = None,
|
|
348
349
|
) -> None:
|
|
349
350
|
"""Acknowledge records in a consumer group.
|
|
@@ -351,7 +352,7 @@ class StreamOperations:
|
|
|
351
352
|
Args:
|
|
352
353
|
stream: Stream name.
|
|
353
354
|
group: Consumer group name.
|
|
354
|
-
|
|
355
|
+
ids: StreamIDs of records to acknowledge.
|
|
355
356
|
options: Optional ack options (including consumer name).
|
|
356
357
|
|
|
357
358
|
Example:
|
|
@@ -359,18 +360,18 @@ class StreamOperations:
|
|
|
359
360
|
for record in result.records:
|
|
360
361
|
try:
|
|
361
362
|
process(record.payload)
|
|
362
|
-
await client.stream.group_ack("events", "processors", [record.
|
|
363
|
+
await client.stream.group_ack("events", "processors", [record.id],
|
|
363
364
|
StreamGroupAckOptions(consumer="worker-1"))
|
|
364
365
|
except Exception:
|
|
365
366
|
pass # Record will be redelivered
|
|
366
367
|
"""
|
|
367
|
-
if not
|
|
368
|
+
if not ids:
|
|
368
369
|
return
|
|
369
370
|
|
|
370
371
|
opts = options or StreamGroupAckOptions()
|
|
371
372
|
namespace = self._client.get_namespace(opts.namespace)
|
|
372
373
|
|
|
373
|
-
value = serialize_group_ack_value(group,
|
|
374
|
+
value = serialize_group_ack_value(group, ids, consumer=opts.consumer)
|
|
374
375
|
|
|
375
376
|
await self._client._send_and_check(
|
|
376
377
|
OpCode.STREAM_GROUP_ACK,
|
|
@@ -384,7 +385,7 @@ class StreamOperations:
|
|
|
384
385
|
self,
|
|
385
386
|
stream: str,
|
|
386
387
|
group: str,
|
|
387
|
-
|
|
388
|
+
ids: list[StreamID],
|
|
388
389
|
options: StreamGroupNackOptions | None = None,
|
|
389
390
|
) -> None:
|
|
390
391
|
"""Negatively acknowledge records in a consumer group for redelivery.
|
|
@@ -392,7 +393,7 @@ class StreamOperations:
|
|
|
392
393
|
Args:
|
|
393
394
|
stream: Stream name.
|
|
394
395
|
group: Consumer group name.
|
|
395
|
-
|
|
396
|
+
ids: StreamIDs of records to negatively acknowledge.
|
|
396
397
|
options: Optional nack options (consumer name, redelivery delay).
|
|
397
398
|
|
|
398
399
|
Example:
|
|
@@ -401,16 +402,16 @@ class StreamOperations:
|
|
|
401
402
|
try:
|
|
402
403
|
process(record.payload)
|
|
403
404
|
except Exception:
|
|
404
|
-
await client.stream.group_nack("events", "processors", [record.
|
|
405
|
+
await client.stream.group_nack("events", "processors", [record.id],
|
|
405
406
|
StreamGroupNackOptions(consumer="worker-1", redelivery_delay_ms=5000))
|
|
406
407
|
"""
|
|
407
|
-
if not
|
|
408
|
+
if not ids:
|
|
408
409
|
return
|
|
409
410
|
|
|
410
411
|
opts = options or StreamGroupNackOptions()
|
|
411
412
|
namespace = self._client.get_namespace(opts.namespace)
|
|
412
413
|
|
|
413
|
-
value = serialize_group_ack_value(group,
|
|
414
|
+
value = serialize_group_ack_value(group, ids, consumer=opts.consumer)
|
|
414
415
|
|
|
415
416
|
builder = OptionsBuilder()
|
|
416
417
|
if opts.redelivery_delay_ms is not None:
|