continuum-task-server-sdk 1.2.0__tar.gz → 1.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: continuum-task-server-sdk
3
- Version: 1.2.0
3
+ Version: 1.4.0
4
4
  Summary: Python SDK for the Continuum Task Server
5
5
  Project-URL: Homepage, https://github.com/ContinuumWorkflow/continuum-task-server-sdk-python
6
6
  Project-URL: Issues, https://github.com/ContinuumWorkflow/continuum-task-server-sdk-python/issues
@@ -112,6 +112,14 @@ for row in db.outstanding_rows():
112
112
 
113
113
  Standalone process (no `TaskServer`): build a `ContinuumClient` with the worker key and call `client.queue.heartbeat` / `client.queue.update_status` the same way.
114
114
 
115
+ By default the deferred claim still occupies the per-task ``concurrency`` slot until ``complete_queue_item``. Set ``occupy_slot_until_complete=False`` so the handler is still gated (you will not insert 10k rows at once) but the slot is freed when the function returns:
116
+
117
+ ```python
118
+ @server.task("discord", auto_complete=False, occupy_slot_until_complete=False)
119
+ def discord(item):
120
+ db.insert_await(queue_item_id=str(item.id), payload=item.input_data_json)
121
+ ```
122
+
115
123
  ### TaskServer options
116
124
 
117
125
  ```python
@@ -83,6 +83,14 @@ for row in db.outstanding_rows():
83
83
 
84
84
  Standalone process (no `TaskServer`): build a `ContinuumClient` with the worker key and call `client.queue.heartbeat` / `client.queue.update_status` the same way.
85
85
 
86
+ By default the deferred claim still occupies the per-task ``concurrency`` slot until ``complete_queue_item``. Set ``occupy_slot_until_complete=False`` so the handler is still gated (you will not insert 10k rows at once) but the slot is freed when the function returns:
87
+
88
+ ```python
89
+ @server.task("discord", auto_complete=False, occupy_slot_until_complete=False)
90
+ def discord(item):
91
+ db.insert_await(queue_item_id=str(item.id), payload=item.input_data_json)
92
+ ```
93
+
86
94
  ### TaskServer options
87
95
 
88
96
  ```python
@@ -22,6 +22,7 @@ from .models import (
22
22
  Content,
23
23
  EnqueueAndSubscribeWaitResult,
24
24
  EventAckResult,
25
+ QueueDescendantsCancelResult,
25
26
  QueueEvent,
26
27
  QueueEventType,
27
28
  QueueItem,
@@ -53,6 +54,7 @@ __all__ = [
53
54
  "ForbiddenError",
54
55
  "NotFoundError",
55
56
  "ProtocolError",
57
+ "QueueDescendantsCancelResult",
56
58
  "QueueEvent",
57
59
  "QueueEventType",
58
60
  "QueueItem",
@@ -75,4 +77,4 @@ __all__ = [
75
77
  "WorkSubscriptionResult",
76
78
  ]
77
79
 
78
- __version__ = "0.1.0"
80
+ __version__ = "1.4.0"
@@ -12,7 +12,15 @@ import httpx
12
12
 
13
13
  from ._http import HttpClient, drop_none
14
14
  from .exceptions import ContinuumError
15
- from .models import Content, QueueItem, TaskItem, TaskItemVersion, TaskStatus, TaskType
15
+ from .models import (
16
+ Content,
17
+ QueueDescendantsCancelResult,
18
+ QueueItem,
19
+ TaskItem,
20
+ TaskItemVersion,
21
+ TaskStatus,
22
+ TaskType,
23
+ )
16
24
 
17
25
 
18
26
  def _encode_input(data: Any) -> str | None:
@@ -326,6 +334,24 @@ class QueueApi:
326
334
  raise ContinuumError(message or "control-complete failed")
327
335
  return QueueItem.model_validate(result["item"])
328
336
 
337
+ def cancel_descendants(
338
+ self,
339
+ queue_item_id: UUID | str,
340
+ *,
341
+ claim_token: UUID | str | None = None,
342
+ ) -> QueueDescendantsCancelResult:
343
+ """Cancel descendants of a claimed item without cancelling the root."""
344
+ body = drop_none({"claimToken": str(claim_token) if claim_token else None})
345
+ result = self._http.post_json(
346
+ f"{self._QUEUE}/queue-items/{queue_item_id}/cancel-descendants",
347
+ body,
348
+ )
349
+ status = 0 if result is None else int(result.get("status", 0))
350
+ if result is None or status != 0:
351
+ message = None if result is None else result.get("message")
352
+ raise ContinuumError(message or "cancel-descendants failed")
353
+ return QueueDescendantsCancelResult.model_validate(result)
354
+
329
355
 
330
356
  class ContentStoreApi:
331
357
  _PATH = "/api/management/content-store"
@@ -125,6 +125,14 @@ class QueueItem(_Base):
125
125
  return self.output_data
126
126
 
127
127
 
128
+ class QueueDescendantsCancelResult(_Base):
129
+ """Result of ``POST /api/queue/queue-items/{id}/cancel-descendants``."""
130
+
131
+ status: int = 0
132
+ message: str | None = None
133
+ cancelled_count: int | None = Field(default=None, alias="cancelledCount")
134
+
135
+
128
136
  class Content(_Base):
129
137
  """Raw content returned from a content-store or queue-item content endpoint."""
130
138
 
@@ -9,6 +9,10 @@ elsewhere and you will call ``complete_queue_item`` / ``fail_queue_item`` later.
9
9
  The SDK does **not** heartbeat after the handler returns; you must call
10
10
  ``client.queue.heartbeat`` yourself (for example on each pass of your DB poll) so
11
11
  the claim stays alive across process restarts.
12
+
13
+ By default a deferred claim still occupies the per-task ``concurrency`` slot
14
+ until complete. Pass ``occupy_slot_until_complete=False`` to free the slot when
15
+ the handler returns so another item can be claimed while the first stays open.
12
16
  """
13
17
 
14
18
  from __future__ import annotations
@@ -45,6 +49,7 @@ class _Registration:
45
49
  handler: Handler
46
50
  concurrency: int
47
51
  auto_complete: bool
52
+ occupy_slot_until_complete: bool
48
53
  semaphore: threading.Semaphore = field(init=False)
49
54
 
50
55
  def __post_init__(self) -> None:
@@ -67,7 +72,8 @@ class TaskServer:
67
72
  With ``auto_complete=False``, the handler returns without ENDED. Automatic
68
73
  heartbeats stop when the handler returns. Heartbeat from the completing
69
74
  process with ``client.queue.heartbeat`` until ``complete_queue_item`` or
70
- ``fail_queue_item``.
75
+ ``fail_queue_item``. The concurrency slot stays occupied until complete
76
+ unless ``occupy_slot_until_complete=False``.
71
77
  """
72
78
 
73
79
  def __init__(
@@ -135,6 +141,7 @@ class TaskServer:
135
141
  *,
136
142
  concurrency: int = 1,
137
143
  auto_complete: bool = True,
144
+ occupy_slot_until_complete: bool = True,
138
145
  ) -> Callable[[Handler], Handler]:
139
146
  """Decorator registering ``handler`` for queue items of task-type ``name``.
140
147
 
@@ -143,6 +150,10 @@ class TaskServer:
143
150
  If ``auto_complete`` is False, the handler returns without the server sending
144
151
  ENDED; call ``complete_queue_item`` or ``fail_queue_item`` when done. You must
145
152
  heartbeat the queue item yourself until then (see README).
153
+
154
+ ``occupy_slot_until_complete`` (default True) keeps the concurrency slot until
155
+ that complete/fail. Set False so the slot is freed when the handler returns;
156
+ the claim stays in ``_active`` for complete/cancel.
146
157
  """
147
158
  if concurrency < 1:
148
159
  raise ValueError("concurrency must be >= 1")
@@ -155,6 +166,7 @@ class TaskServer:
155
166
  handler=handler,
156
167
  concurrency=concurrency,
157
168
  auto_complete=auto_complete,
169
+ occupy_slot_until_complete=occupy_slot_until_complete,
158
170
  )
159
171
  return handler
160
172
 
@@ -167,9 +179,15 @@ class TaskServer:
167
179
  *,
168
180
  concurrency: int = 1,
169
181
  auto_complete: bool = True,
182
+ occupy_slot_until_complete: bool = True,
170
183
  ) -> None:
171
184
  """Imperative alternative to ``@task``."""
172
- self.task(name, concurrency=concurrency, auto_complete=auto_complete)(handler)
185
+ self.task(
186
+ name,
187
+ concurrency=concurrency,
188
+ auto_complete=auto_complete,
189
+ occupy_slot_until_complete=occupy_slot_until_complete,
190
+ )(handler)
173
191
 
174
192
  def complete_queue_item(
175
193
  self,
@@ -191,6 +209,7 @@ class TaskServer:
191
209
  self._safe_update_status_by_id(qid, TaskStatus.ENDED, output=output_data, claim_token=token)
192
210
  if claim is not None:
193
211
  claim.mark_completed()
212
+ self._finish_claim(claim)
194
213
 
195
214
  def fail_queue_item(
196
215
  self,
@@ -223,6 +242,7 @@ class TaskServer:
223
242
  self._safe_update_status_by_id(qid, TaskStatus.KILLED, output=payload, claim_token=token)
224
243
  if claim is not None:
225
244
  claim.mark_completed()
245
+ self._finish_claim(claim)
226
246
 
227
247
  @staticmethod
228
248
  def _normalize_queue_id(queue_item_id: uuid.UUID | str) -> uuid.UUID:
@@ -428,8 +448,13 @@ class TaskServer:
428
448
  self._inflight.discard(future)
429
449
 
430
450
  def _run_item(self, registration: _Registration, item: QueueItem) -> None:
431
- claim = _ActiveClaim(item=item, server=self)
451
+ claim = _ActiveClaim(
452
+ item=item,
453
+ server=self,
454
+ occupy_slot_until_complete=registration.occupy_slot_until_complete,
455
+ )
432
456
  self._active[item.id] = claim
457
+ slot_held = True
433
458
  try:
434
459
  logger.info("Claimed queue item %s (task=%s)", item.id, registration.name)
435
460
  heartbeat_thread = threading.Thread(
@@ -482,15 +507,29 @@ class TaskServer:
482
507
  item.id,
483
508
  )
484
509
  self._stop_heartbeat(claim, heartbeat_thread)
485
- self._wait_for_deferred(claim)
510
+ claim.handler_returned.set()
511
+ if registration.occupy_slot_until_complete:
512
+ self._wait_for_deferred(claim)
513
+ else:
514
+ slot_held = False
515
+ self._release_slot(registration)
486
516
  finally:
487
517
  self._stop_heartbeat(claim, heartbeat_thread)
488
- self._unsubscribe_control(claim)
518
+ if slot_held:
519
+ self._unsubscribe_control(claim)
489
520
  finally:
490
- self._active.pop(item.id, None)
491
- registration.semaphore.release()
492
- if self._mode is TransportMode.WEBSOCKET:
493
- self._try_claim_pending()
521
+ if slot_held:
522
+ self._active.pop(item.id, None)
523
+ self._release_slot(registration)
524
+
525
+ def _release_slot(self, registration: _Registration) -> None:
526
+ registration.semaphore.release()
527
+ if self._mode is TransportMode.WEBSOCKET:
528
+ self._try_claim_pending()
529
+
530
+ def _finish_claim(self, claim: _ActiveClaim) -> None:
531
+ self._unsubscribe_control(claim)
532
+ self._active.pop(claim.item.id, None)
494
533
 
495
534
  def _wait_for_deferred(self, claim: _ActiveClaim) -> None:
496
535
  while (
@@ -612,13 +651,22 @@ class TaskServer:
612
651
  self._ws = None
613
652
  if self._owns_client:
614
653
  self._client.close()
654
+ for leftover in list(self._active.values()):
655
+ self._finish_claim(leftover)
615
656
  logger.info("Continuum task server stopped")
616
657
 
617
658
 
618
659
  class _ActiveClaim:
619
- def __init__(self, *, item: QueueItem, server: TaskServer) -> None:
660
+ def __init__(
661
+ self,
662
+ *,
663
+ item: QueueItem,
664
+ server: TaskServer,
665
+ occupy_slot_until_complete: bool = True,
666
+ ) -> None:
620
667
  self.item = item
621
668
  self.server = server
669
+ self.occupy_slot_until_complete = occupy_slot_until_complete
622
670
  self.context = TaskExecutionContext(item)
623
671
  self.stop_heartbeat = threading.Event()
624
672
  self.control_seen = threading.Event()
@@ -626,6 +674,7 @@ class _ActiveClaim:
626
674
  self.deferred_gate = threading.Event()
627
675
  self.control_subscribed = False
628
676
  self._control_acked = threading.Event()
677
+ self.handler_returned = threading.Event()
629
678
 
630
679
  @property
631
680
  def claim_token(self) -> uuid.UUID | None:
@@ -637,6 +686,12 @@ class _ActiveClaim:
637
686
  self.control_seen.set()
638
687
  self.context.request_cancel()
639
688
  self.deferred_gate.set()
689
+ if self.handler_returned.is_set() and not self.occupy_slot_until_complete:
690
+ threading.Thread(
691
+ target=self.acknowledge_control,
692
+ name=f"control-ack-{self.item.id}",
693
+ daemon=True,
694
+ ).start()
640
695
 
641
696
  def mark_completed(self) -> None:
642
697
  self.completed.set()
@@ -659,6 +714,7 @@ class _ActiveClaim:
659
714
  except ContinuumError as e:
660
715
  logger.warning("completeControl(%s) failed: %s", self.item.id, e)
661
716
  self.mark_completed()
717
+ self.server._finish_claim(self)
662
718
 
663
719
 
664
720
  def _accepts_context(handler: Handler) -> bool:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "continuum-task-server-sdk"
7
- version = "1.2.0"
7
+ version = "1.4.0"
8
8
  description = "Python SDK for the Continuum Task Server"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"