ringo-task-queue 0.1.0.dev7__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.
- ringo_task_queue/_proto/ringo/v1/queue_pb2.py +26 -22
- ringo_task_queue/_proto/ringo/v1/queue_pb2_grpc.py +45 -0
- ringo_task_queue/_supervisor_child.py +91 -0
- ringo_task_queue/_supervisor_ipc.py +81 -0
- ringo_task_queue/_supervisor_policy.py +182 -0
- ringo_task_queue/_supervisor_process.py +562 -0
- ringo_task_queue/_supervisor_runtime.py +420 -0
- ringo_task_queue/_supervisor_watchdog.py +429 -0
- ringo_task_queue/_version.py +2 -2
- ringo_task_queue/bin/manifest.json +7 -7
- ringo_task_queue/bin/ringo-task-queue-windows-amd64.exe +0 -0
- ringo_task_queue/client.py +135 -72
- ringo_task_queue/shared.py +60 -8
- ringo_task_queue/supervisor.py +575 -0
- ringo_task_queue/worker.py +609 -272
- {ringo_task_queue-0.1.0.dev7.dist-info → ringo_task_queue-0.1.0.dev8.dist-info}/METADATA +98 -9
- ringo_task_queue-0.1.0.dev8.dist-info/RECORD +27 -0
- ringo_task_queue-0.1.0.dev7.dist-info/RECORD +0 -20
- {ringo_task_queue-0.1.0.dev7.dist-info → ringo_task_queue-0.1.0.dev8.dist-info}/WHEEL +0 -0
- {ringo_task_queue-0.1.0.dev7.dist-info → ringo_task_queue-0.1.0.dev8.dist-info}/licenses/LICENSE +0 -0
|
@@ -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)
|
ringo_task_queue/_version.py
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
"arch": "amd64",
|
|
5
5
|
"filename": "ringo-task-queue-windows-amd64.exe",
|
|
6
6
|
"os": "windows",
|
|
7
|
-
"sha256": "
|
|
8
|
-
"size":
|
|
7
|
+
"sha256": "f3383f5d2d23c234aae02c7227b388b044eeab047161c29e7439e8d865451c59",
|
|
8
|
+
"size": 23455744
|
|
9
9
|
}
|
|
10
10
|
],
|
|
11
|
-
"buildCommit": "
|
|
12
|
-
"buildDate": "2026-10-
|
|
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":
|
|
17
|
-
"releaseManifestSha256": "
|
|
16
|
+
"protocolMinor": 7,
|
|
17
|
+
"releaseManifestSha256": "27ffcd2b578c852a220c8353624902e1643c0cd0d40d577dca9d041ddca3a954",
|
|
18
18
|
"schemaVersion": 3,
|
|
19
|
-
"version": "0.1.0-dev.
|
|
19
|
+
"version": "0.1.0-dev.8"
|
|
20
20
|
}
|
|
Binary file
|