ringo-task-queue 0.1.0.dev6__py3-none-win_amd64.whl → 0.1.0.dev8__py3-none-win_amd64.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.
@@ -0,0 +1,429 @@
1
+ """Opt-in, event-loop-local Worker observation; never owns business capacity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import logging
8
+ from dataclasses import dataclass, field
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from ._proto.ringo.v1 import queue_pb2
12
+ from ._supervisor_policy import TaskTimeouts
13
+ from .worker import _WatchdogFenced
14
+
15
+ if TYPE_CHECKING:
16
+ from .worker import TaskContext, Worker, _Assignment, _WorkSource
17
+
18
+ _LOG = logging.getLogger(__name__)
19
+ _WATCHDOG_SAMPLE_SECONDS = 0.1
20
+ _STALL_PROBE_SECONDS = 5.0
21
+ _STALL_MIN_PROTOCOL_MINOR = 7
22
+
23
+
24
+ @dataclass
25
+ class _Execution:
26
+ assignment: _Assignment
27
+ started: float
28
+ deadline: float | None
29
+ baseline_cancels: int
30
+ progress: float | None = None
31
+ expired: bool = False
32
+ reason: str = ""
33
+ cancelled_at: float | None = None
34
+ cancel_count: int = 0
35
+ cancel_marker: object = field(default_factory=object)
36
+ escalated: bool = False
37
+
38
+
39
+ class _WorkerWatchdog:
40
+ def __init__(self, worker: Worker, timeouts: TaskTimeouts, runtime: Any) -> None:
41
+ self.worker = worker
42
+ self.timeouts = timeouts
43
+ self.runtime = runtime
44
+ self.executions: dict[tuple[str, int, str], _Execution] = {}
45
+ self.last_started: float | None = None
46
+ self.start_generation = 0
47
+ self._task: asyncio.Task[None] | None = None
48
+ self._probe_task: asyncio.Task[None] | None = None
49
+ self._stall_source: _WorkSource | None = None
50
+ self._stall_key: tuple[int, ...] | None = None
51
+ self._eligible_since: float | None = None
52
+ self._true_since: float | None = None
53
+ self._reported = False
54
+ self._next_probe = 0.0
55
+ self._old_protocol_source: _WorkSource | None = None
56
+
57
+ def start(self) -> None:
58
+ if self._task is None:
59
+ self._task = asyncio.create_task(
60
+ self._run(), name=f"ringo-watchdog-{self.worker.worker_id}"
61
+ )
62
+
63
+ async def stop(self) -> None:
64
+ tasks = [task for task in (self._task, self._probe_task) if task is not None]
65
+ for task in tasks:
66
+ task.cancel()
67
+ if tasks:
68
+ await asyncio.gather(*tasks, return_exceptions=True)
69
+
70
+ async def invoke(self, assignment: _Assignment, context: TaskContext) -> None:
71
+ """The budget wraps *only* the actual business invocation/await."""
72
+ task = asyncio.current_task()
73
+ assert task is not None
74
+ now = asyncio.get_running_loop().time()
75
+ budget = self.timeouts.for_type(assignment.task.task_type)
76
+ execution = _Execution(
77
+ assignment,
78
+ now,
79
+ None if budget is None else now + budget.total_seconds(),
80
+ task.cancelling(),
81
+ )
82
+ self.executions[assignment.identity] = execution
83
+ self.last_started = now
84
+ self.start_generation += 1
85
+ self._reset_stall()
86
+ try:
87
+ try:
88
+ result = self.worker._handler(assignment.task, context)
89
+ if inspect.isawaitable(result):
90
+ await result
91
+ except BaseException as exc:
92
+ self._check_return_deadline(execution)
93
+ if execution.expired:
94
+ if self._owns_timeout(execution, exc):
95
+ raise TimeoutError(execution.reason) from None
96
+ if self.worker._close_started.is_set():
97
+ raise _WatchdogFenced() from None
98
+ if not isinstance(exc, asyncio.CancelledError):
99
+ raise asyncio.CancelledError(
100
+ "expired task execution fenced"
101
+ ) from None
102
+ raise
103
+ else:
104
+ self._check_return_deadline(execution)
105
+ if execution.expired:
106
+ if self._owns_timeout(execution, None):
107
+ raise TimeoutError(execution.reason)
108
+ if self.worker._close_started.is_set():
109
+ raise _WatchdogFenced()
110
+ # Swallowing SDK cancellation never permits success after
111
+ # expiry, including a simultaneous external cancellation.
112
+ raise asyncio.CancelledError("expired task execution fenced")
113
+ finally:
114
+ # No terminal receipt or capacity-release wait is budgeted.
115
+ self.executions.pop(assignment.identity, None)
116
+
117
+ def _check_return_deadline(self, execution: _Execution) -> None:
118
+ # A synchronous handler can keep this event loop from sampling. Once it
119
+ # really returns, classify its elapsed budget without cancelling an
120
+ # already stopped invocation (which would suppress the timeout NACK).
121
+ if execution.expired:
122
+ return
123
+ now = asyncio.get_running_loop().time()
124
+ if execution.deadline is not None and now >= execution.deadline:
125
+ execution.expired = True
126
+ execution.reason = "task execution deadline exceeded"
127
+ elif (
128
+ self.timeouts.progress_timeout is not None
129
+ and execution.progress is not None
130
+ and now - execution.progress
131
+ >= self.timeouts.progress_timeout.total_seconds()
132
+ ):
133
+ execution.expired = True
134
+ execution.reason = "task business progress deadline exceeded"
135
+ if execution.expired:
136
+ execution.assignment.watchdog_expired = True
137
+ execution.cancel_count = execution.baseline_cancels
138
+
139
+ def external_snapshot(self) -> dict[str, Any] | None:
140
+ """Read-only health-thread view, including a blocked event loop.
141
+
142
+ No event-loop API, locks, RPC, lease token or payload participates. A
143
+ failed/racing sample is unknown, never proof of an idle healthy process.
144
+ """
145
+ try:
146
+ earliest: tuple[float, _Execution] | None = None
147
+ progress_budget = self.timeouts.progress_timeout
148
+ for execution in self.executions.copy().values():
149
+ deadline = execution.deadline
150
+ progress = execution.progress
151
+ if progress_budget is not None and progress is not None:
152
+ progress_deadline = progress + progress_budget.total_seconds()
153
+ deadline = (
154
+ progress_deadline
155
+ if deadline is None
156
+ else min(deadline, progress_deadline)
157
+ )
158
+ if deadline is not None and (
159
+ earliest is None or deadline < earliest[0]
160
+ ):
161
+ earliest = (deadline, execution)
162
+ if earliest is None:
163
+ return None
164
+ deadline, execution = earliest
165
+ assignment = execution.assignment
166
+ return {
167
+ "execution_id": (
168
+ f"{id(self.worker)}:{assignment.session_epoch}:"
169
+ f"{assignment.task.id}:{assignment.attempt}:{execution.started:.9f}"
170
+ ),
171
+ "deadline": deadline,
172
+ "cancel_grace": self.timeouts.cancel_grace.total_seconds(),
173
+ }
174
+ except (AttributeError, RuntimeError, TypeError, ValueError):
175
+ return {"unknown": True}
176
+
177
+ def mark_progress(self, assignment: _Assignment) -> None:
178
+ execution = self.executions.get(assignment.identity)
179
+ if execution is not None and not execution.expired and assignment.alive:
180
+ execution.progress = asyncio.get_running_loop().time()
181
+
182
+ def _owns_timeout(self, execution: _Execution, exc: BaseException | None) -> bool:
183
+ assignment = execution.assignment
184
+ task = assignment.handler_task
185
+ worker = self.worker
186
+ source = worker._source
187
+ now = asyncio.get_running_loop().time()
188
+ return bool(
189
+ not execution.escalated
190
+ and assignment.alive
191
+ and not worker._close_started.is_set()
192
+ and not worker._force_cancel.is_set()
193
+ and source is not None
194
+ and not source.done.is_set()
195
+ and assignment.session is source.done
196
+ and assignment.session_epoch == source.epoch
197
+ and (assignment.renew_deadline is None or now < assignment.renew_deadline)
198
+ and task is not None
199
+ # An additional application/close/lease cancellation is not ours.
200
+ and task.cancelling() <= execution.cancel_count
201
+ and execution.cancel_count
202
+ == execution.baseline_cancels + int(execution.cancelled_at is not None)
203
+ and (
204
+ not isinstance(exc, asyncio.CancelledError)
205
+ or exc.args == (execution.cancel_marker,)
206
+ )
207
+ )
208
+
209
+ async def _run(self) -> None:
210
+ while True:
211
+ self.sample()
212
+ await asyncio.sleep(_WATCHDOG_SAMPLE_SECONDS)
213
+
214
+ def sample(self) -> None:
215
+ now = asyncio.get_running_loop().time()
216
+ progress_budget = self.timeouts.progress_timeout
217
+ grace = self.timeouts.cancel_grace.total_seconds()
218
+ for execution in tuple(self.executions.values()):
219
+ assignment = execution.assignment
220
+ task = assignment.handler_task
221
+ if task is None or task.done():
222
+ continue
223
+ if not execution.expired:
224
+ # A dead lease/source does not prove the old business coroutine
225
+ # stopped. Its finite execution budget still requires escalation.
226
+ reason = ""
227
+ if execution.deadline is not None and now >= execution.deadline:
228
+ reason = "task execution deadline exceeded"
229
+ elif (
230
+ progress_budget is not None
231
+ and execution.progress is not None
232
+ and now - execution.progress >= progress_budget.total_seconds()
233
+ ):
234
+ reason = "task business progress deadline exceeded"
235
+ if not reason:
236
+ continue
237
+ # Expiry is independent of Task cancellation: a handler may
238
+ # consume CancelledError, uncancel itself or return normally.
239
+ execution.expired = True
240
+ assignment.watchdog_expired = True
241
+ execution.reason = reason
242
+ execution.cancelled_at = now
243
+ execution.cancel_count = task.cancelling() + 1
244
+ task.cancel(execution.cancel_marker)
245
+ elif (
246
+ not execution.escalated
247
+ and execution.cancelled_at is not None
248
+ and now - execution.cancelled_at >= grace
249
+ ):
250
+ execution.escalated = True
251
+ self.worker._watchdog_unresponsive(assignment, execution.reason)
252
+ runtime = self.runtime
253
+ if runtime is not None:
254
+ runtime.update_worker(
255
+ str(id(self.worker)),
256
+ {
257
+ "queue": self.worker._queue.name[:32],
258
+ "source_epoch": self.worker._session_epoch,
259
+ "running": len(self.executions),
260
+ "inflight": len(self.worker._inflight),
261
+ "last_handler_started": self.last_started,
262
+ "expired": sum(item.expired for item in self.executions.values()),
263
+ "stall_suspected": self._reported,
264
+ },
265
+ )
266
+ self._sample_stall(now)
267
+
268
+ def _reset_stall(self) -> None:
269
+ self._eligible_since = None
270
+ self._true_since = None
271
+ self._reported = False
272
+
273
+ def _eligible_source(self) -> _WorkSource | None:
274
+ worker = self.worker
275
+ source = worker._source
276
+ if (
277
+ source is None
278
+ or source.done.is_set()
279
+ or not source.registered.is_set()
280
+ or worker._close_started.is_set()
281
+ or worker._force_cancel.is_set()
282
+ or worker._publisher_closed
283
+ or worker._client._require_conn()._session_token != source.token
284
+ or source.accepted_credit <= 0
285
+ ):
286
+ return None
287
+ if worker._controller is None:
288
+ if (
289
+ worker._concurrency
290
+ - worker._fixed_running
291
+ - len(worker._waiting_assignments)
292
+ <= 0
293
+ or source.credit_generation != worker._fixed_capacity_generation
294
+ ):
295
+ return None
296
+ else:
297
+ member = worker._member
298
+ if (
299
+ member is None
300
+ or not member.attached
301
+ or not member.session_ready
302
+ or not member.probe
303
+ or member.revoking
304
+ or worker._controller._grant_paused
305
+ or worker._controller._closed
306
+ or source.credit_generation != member.probe_generation
307
+ ):
308
+ return None
309
+ return source
310
+
311
+ def _qualification_key(self, source: _WorkSource) -> tuple[int, ...]:
312
+ # Successful credit/probe generations change even if pause/revoke and
313
+ # resume/regrant both happen between sampler ticks. Old evidence cannot
314
+ # survive that gap, nor can an RPC begun under the old qualification.
315
+ member = self.worker._member
316
+ return (
317
+ id(source),
318
+ source.epoch,
319
+ source.credit_generation,
320
+ source.accepted_credit,
321
+ self.worker._fixed_capacity_generation
322
+ if member is None
323
+ else member.probe_generation,
324
+ )
325
+
326
+ def _sample_stall(self, now: float) -> None:
327
+ runtime = self.runtime
328
+ timeout = None if runtime is None else runtime.policy.no_start_timeout
329
+ if timeout is None:
330
+ return
331
+ source = self._eligible_source()
332
+ if source is None:
333
+ self._stall_source = None
334
+ self._stall_key = None
335
+ self._reset_stall()
336
+ return
337
+ if source.minor < _STALL_MIN_PROTOCOL_MINOR:
338
+ if self._old_protocol_source is not source:
339
+ self._old_protocol_source = source
340
+ _LOG.warning(
341
+ "Worker %s no-start observation skipped: negotiated protocol "
342
+ "minor %s is older than %s",
343
+ self.worker.worker_id,
344
+ source.minor,
345
+ _STALL_MIN_PROTOCOL_MINOR,
346
+ )
347
+ self._reset_stall()
348
+ return
349
+ key = self._qualification_key(source)
350
+ if source is not self._stall_source or key != self._stall_key:
351
+ self._stall_source = source
352
+ self._stall_key = key
353
+ self._reset_stall()
354
+ if self._eligible_since is None:
355
+ self._eligible_since = now
356
+ if (
357
+ now - self._eligible_since >= timeout.total_seconds()
358
+ and now >= self._next_probe
359
+ and (self._probe_task is None or self._probe_task.done())
360
+ and not self._reported
361
+ ):
362
+ self._next_probe = now + _STALL_PROBE_SECONDS
363
+ self._probe_task = asyncio.create_task(
364
+ self._probe_once(source, self.start_generation, key),
365
+ name=f"ringo-stall-probe-{self.worker.worker_id}",
366
+ )
367
+
368
+ async def _probe_once(
369
+ self, source: _WorkSource, generation: int, key: tuple[int, ...]
370
+ ) -> None:
371
+ if (
372
+ self._eligible_source() is not source
373
+ or self._qualification_key(source) != key
374
+ or self._stall_key != key
375
+ or self.start_generation != generation
376
+ ):
377
+ if self._stall_key == key:
378
+ self._reset_stall()
379
+ return
380
+ try:
381
+ response = await self.worker._client._require_conn().call(
382
+ "ProbeQueue",
383
+ queue_pb2.ProbeQueueRequest(
384
+ queue=self.worker._queue.name, task_types=self.worker._task_types
385
+ ),
386
+ retry_safe=True,
387
+ )
388
+ except asyncio.CancelledError:
389
+ raise
390
+ except Exception: # noqa: BLE001 - any failed observation is inconclusive
391
+ _LOG.debug(
392
+ "Worker queue probe inconclusive; resetting stall evidence",
393
+ exc_info=True,
394
+ )
395
+ if self._stall_key == key:
396
+ self._reset_stall()
397
+ return
398
+ if (
399
+ self._eligible_source() is not source
400
+ or self._stall_source is not source
401
+ or self._qualification_key(source) != key
402
+ or self._stall_key != key
403
+ or self.start_generation != generation
404
+ or not response.has_eligible_tasks
405
+ ):
406
+ if self._stall_key == key:
407
+ self._reset_stall()
408
+ return
409
+ now = asyncio.get_running_loop().time()
410
+ if self._true_since is None:
411
+ self._true_since = now
412
+ return
413
+ timeout = self.runtime.policy.no_start_timeout.total_seconds()
414
+ if now - self._true_since < timeout:
415
+ return
416
+ self._reported = True
417
+ detail = {
418
+ "worker_id": self.worker.worker_id,
419
+ "queue": self.worker._queue.name,
420
+ "source_epoch": source.epoch,
421
+ "confirmed_seconds": now - self._true_since,
422
+ }
423
+ _LOG.warning(
424
+ "Worker %s suspected consumption stall: eligible tasks repeatedly "
425
+ "observed without a handler start (competing consumers may race)",
426
+ self.worker.worker_id,
427
+ )
428
+ if self.runtime.policy.restart_on_stall:
429
+ self.runtime.request_restart("worker consumption stalled", detail)
@@ -1,17 +1,17 @@
1
- """Release-line constants shared by runtime packaging checks."""
2
-
3
- SDK_VERSION = "0.1.0.dev6"
4
- PROTOCOL_MAJOR = 1
5
- PROTOCOL_MINOR = 3
6
- SCHEMA_VERSION = 3
7
-
8
-
9
- def daemon_version_for_sdk(version: str = SDK_VERSION) -> str:
10
- """Map the Python package line to the canonical Go daemon line."""
11
- if ".dev" in version:
12
- base, _, suffix = version.rpartition(".dev")
13
- if suffix.isdigit():
14
- # Keep the historical dev0 daemon spelling while preserving the
15
- # release-candidate sequence used by later development wheels.
16
- return f"{base}-dev" if suffix == "0" else f"{base}-dev.{suffix}"
17
- return version
1
+ """Release-line constants shared by runtime packaging checks."""
2
+
3
+ SDK_VERSION = "0.1.0.dev8"
4
+ PROTOCOL_MAJOR = 1
5
+ PROTOCOL_MINOR = 7
6
+ SCHEMA_VERSION = 3
7
+
8
+
9
+ def daemon_version_for_sdk(version: str = SDK_VERSION) -> str:
10
+ """Map the Python package line to the canonical Go daemon line."""
11
+ if ".dev" in version:
12
+ base, _, suffix = version.rpartition(".dev")
13
+ if suffix.isdigit():
14
+ # Keep the historical dev0 daemon spelling while preserving the
15
+ # release-candidate sequence used by later development wheels.
16
+ return f"{base}-dev" if suffix == "0" else f"{base}-dev.{suffix}"
17
+ return version
@@ -4,17 +4,17 @@
4
4
  "arch": "amd64",
5
5
  "filename": "ringo-task-queue-windows-amd64.exe",
6
6
  "os": "windows",
7
- "sha256": "da3910df5fa8e2576e5228e90bcc1c2ece9d04fb0c7982fe1b3d396528cc3513",
8
- "size": 22962688
7
+ "sha256": "f3383f5d2d23c234aae02c7227b388b044eeab047161c29e7439e8d865451c59",
8
+ "size": 23455744
9
9
  }
10
10
  ],
11
- "buildCommit": "9bc23a8cc1cce1425b2a4be2ff013a4ee44d3f71",
12
- "buildDate": "2026-09-23T12:29:11Z",
11
+ "buildCommit": "51e4b33d658033555b2d3e80dbd3fa8798703236+dirty.952661cfb73eb399664f4cf815c76e7eea3a66513442cea31c2c2acdfc4d8838",
12
+ "buildDate": "2026-10-15T06:19:37Z",
13
13
  "manifestVersion": 1,
14
14
  "name": "ringo-task-queue",
15
15
  "protocolMajor": 1,
16
- "protocolMinor": 3,
17
- "releaseManifestSha256": "e448e76e819b1be9af12874f97598afeeee986cc95abc00774dd06fc2d12def4",
16
+ "protocolMinor": 7,
17
+ "releaseManifestSha256": "27ffcd2b578c852a220c8353624902e1643c0cd0d40d577dca9d041ddca3a954",
18
18
  "schemaVersion": 3,
19
- "version": "0.1.0-dev.6"
19
+ "version": "0.1.0-dev.8"
20
20
  }