matrx-graph 0.2.330__py3-none-any.whl → 0.2.332__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.
- matrx_graph/compile.py +42 -1
- matrx_graph/executor/scheduler.py +15 -1
- matrx_graph/knobs.py +25 -3
- matrx_graph/types/compiled.py +21 -0
- matrx_graph/workers/FEATURE.md +1 -0
- matrx_graph/workers/job_lease.py +155 -0
- matrx_graph/workers/queue.py +75 -5
- matrx_graph/workers/runner.py +50 -9
- {matrx_graph-0.2.330.dist-info → matrx_graph-0.2.332.dist-info}/METADATA +2 -2
- {matrx_graph-0.2.330.dist-info → matrx_graph-0.2.332.dist-info}/RECORD +11 -10
- {matrx_graph-0.2.330.dist-info → matrx_graph-0.2.332.dist-info}/WHEEL +0 -0
matrx_graph/compile.py
CHANGED
|
@@ -18,7 +18,7 @@ from matrx_graph.errors import CompilationError
|
|
|
18
18
|
from matrx_graph.executor.schema_validation import schema_validity_errors
|
|
19
19
|
from matrx_graph.executor.registry import NodeRegistry, default_registry
|
|
20
20
|
from matrx_graph.types.channel import ChannelSpec
|
|
21
|
-
from matrx_graph.types.compiled import CompiledEdge, CompiledGraph, CompiledNode
|
|
21
|
+
from matrx_graph.types.compiled import CompiledEdge, CompiledGraph, CompiledNode, StepClock
|
|
22
22
|
from matrx_graph.types.input_surface import InputSpec, hint_values
|
|
23
23
|
from matrx_graph.types.definition import Definition
|
|
24
24
|
from matrx_graph.types.primitives import EdgeKind, RetryPolicy
|
|
@@ -285,6 +285,10 @@ def compile( # noqa: A001 — mirrors matplotlib-style top-level compile()
|
|
|
285
285
|
except Exception as e: # noqa: BLE001
|
|
286
286
|
raise CompilationError(f"node '{node.id}' has an invalid retry_policy: {e}") from e
|
|
287
287
|
|
|
288
|
+
# Per-node wall clock. Authored as data.step_clock = {seconds, knob?:
|
|
289
|
+
# {feature, key}}; a bad value fails compilation (loud), never a run.
|
|
290
|
+
step_clock = _compile_step_clock(node.id, node.data.get("step_clock"))
|
|
291
|
+
|
|
288
292
|
# Per-node output-channel binding. Authored as
|
|
289
293
|
# data.output_channel = "<channel name>": the scheduler writes the
|
|
290
294
|
# node's full output dump to that channel after success/mock, so
|
|
@@ -393,6 +397,7 @@ def compile( # noqa: A001 — mirrors matplotlib-style top-level compile()
|
|
|
393
397
|
writes_channels=writes,
|
|
394
398
|
mock_response=mock_response,
|
|
395
399
|
retry_policy=retry_policy,
|
|
400
|
+
step_clock=step_clock,
|
|
396
401
|
effective_output_schema=effective_output_schema,
|
|
397
402
|
output_channel=output_channel,
|
|
398
403
|
input_kind=input_kind,
|
|
@@ -562,3 +567,39 @@ def _build_input_surface(
|
|
|
562
567
|
declared_by[var.name] = "the workflow's declared variables"
|
|
563
568
|
|
|
564
569
|
return surface
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _compile_step_clock(node_id: str, raw: Any) -> StepClock | None:
|
|
573
|
+
"""``data.step_clock`` → :class:`StepClock`, or a CompilationError naming the node."""
|
|
574
|
+
if raw is None:
|
|
575
|
+
return None
|
|
576
|
+
from matrx_graph.knobs import MAX_STEP_SECONDS, MIN_STEP_SECONDS
|
|
577
|
+
|
|
578
|
+
if not isinstance(raw, dict):
|
|
579
|
+
raise CompilationError(f"node '{node_id}' has an invalid step_clock: expected an object, got {raw!r}")
|
|
580
|
+
seconds = raw.get("seconds")
|
|
581
|
+
if isinstance(seconds, bool) or not isinstance(seconds, int) or not (
|
|
582
|
+
MIN_STEP_SECONDS <= seconds <= MAX_STEP_SECONDS
|
|
583
|
+
):
|
|
584
|
+
raise CompilationError(
|
|
585
|
+
f"node '{node_id}' has an invalid step_clock.seconds {seconds!r}: a whole number "
|
|
586
|
+
f"from {MIN_STEP_SECONDS} to {MAX_STEP_SECONDS}"
|
|
587
|
+
)
|
|
588
|
+
knob = raw.get("knob")
|
|
589
|
+
unknown = set(raw) - {"seconds", "knob"}
|
|
590
|
+
if unknown:
|
|
591
|
+
raise CompilationError(f"node '{node_id}' step_clock has unknown keys {sorted(unknown)}")
|
|
592
|
+
if knob is None:
|
|
593
|
+
return StepClock(seconds=seconds)
|
|
594
|
+
if not (
|
|
595
|
+
isinstance(knob, dict)
|
|
596
|
+
and isinstance(knob.get("feature"), str)
|
|
597
|
+
and knob["feature"].strip()
|
|
598
|
+
and isinstance(knob.get("key"), str)
|
|
599
|
+
and knob["key"].strip()
|
|
600
|
+
and set(knob) <= {"feature", "key"}
|
|
601
|
+
):
|
|
602
|
+
raise CompilationError(
|
|
603
|
+
f"node '{node_id}' has an invalid step_clock.knob {knob!r}: expected {{feature, key}}"
|
|
604
|
+
)
|
|
605
|
+
return StepClock(seconds=seconds, knob_feature=knob["feature"], knob_key=knob["key"])
|
|
@@ -2181,6 +2181,11 @@ class Scheduler:
|
|
|
2181
2181
|
try:
|
|
2182
2182
|
while open_members:
|
|
2183
2183
|
task, result, result_step = await done_queue.get()
|
|
2184
|
+
if task.cancelled() and _job_lease().lost():
|
|
2185
|
+
# A member stopped because this copy lost its job lease (``JobLeaseLost`` is a cancellation):
|
|
2186
|
+
# the whole copy stops here, never routing or checkpointing on (lane K1b, B2).
|
|
2187
|
+
lease = _job_lease().current()
|
|
2188
|
+
raise _job_lease().JobLeaseLost(f"job lease lost: {lease.why if lease else ''}")
|
|
2184
2189
|
if id(result) in member_order and result_step == step:
|
|
2185
2190
|
# Kept in frontier order by insertion — O(log n) search per finished member, never a re-sort.
|
|
2186
2191
|
bisect.insort(finished_members, result, key=lambda r: member_order[id(r)])
|
|
@@ -3261,7 +3266,9 @@ class Scheduler:
|
|
|
3261
3266
|
# ``matrx_graph.knobs`` § THE STEP WALL CLOCK.
|
|
3262
3267
|
from matrx_graph.knobs import step_timeout_seconds
|
|
3263
3268
|
|
|
3264
|
-
clock_seconds, clock_source = await step_timeout_seconds(
|
|
3269
|
+
clock_seconds, clock_source = await step_timeout_seconds(
|
|
3270
|
+
spec.timeout_ms, node_clock=node.step_clock
|
|
3271
|
+
)
|
|
3265
3272
|
try:
|
|
3266
3273
|
try:
|
|
3267
3274
|
output = await asyncio.wait_for(
|
|
@@ -6488,3 +6495,10 @@ def _settle_default_on_decision_gate(
|
|
|
6488
6495
|
logger.info("decision gate settled by its default %r → %s", raw, shaped["answer"])
|
|
6489
6496
|
value["answer"] = shaped["answer"]
|
|
6490
6497
|
return value
|
|
6498
|
+
|
|
6499
|
+
|
|
6500
|
+
def _job_lease():
|
|
6501
|
+
"""``matrx_graph.workers.job_lease``, imported on use (the workers package imports the executor)."""
|
|
6502
|
+
from matrx_graph.workers import job_lease
|
|
6503
|
+
|
|
6504
|
+
return job_lease
|
matrx_graph/knobs.py
CHANGED
|
@@ -432,12 +432,34 @@ async def default_step_seconds() -> int:
|
|
|
432
432
|
)
|
|
433
433
|
|
|
434
434
|
|
|
435
|
-
async def step_timeout_seconds(
|
|
435
|
+
async def step_timeout_seconds(
|
|
436
|
+
declared_timeout_ms: int | None, *, node_clock: Any = None
|
|
437
|
+
) -> tuple[float, str]:
|
|
436
438
|
"""``(seconds, source)`` for one node attempt.
|
|
437
439
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
+
Precedence: the node's own ``data.step_clock`` (its knob row when it
|
|
441
|
+
resolves, else its declared seconds) → the NodeSpec's ``timeout_ms`` → the
|
|
442
|
+
platform ceiling. ``source`` is the word the failure message uses, so a
|
|
443
|
+
person reading a timed-out run can tell a ceiling the workflow's own author
|
|
444
|
+
set from the platform's.
|
|
440
445
|
"""
|
|
446
|
+
if node_clock is not None:
|
|
447
|
+
seconds = int(node_clock.seconds)
|
|
448
|
+
if node_clock.knob_feature and node_clock.knob_key:
|
|
449
|
+
values = await _feature_values(node_clock.knob_feature)
|
|
450
|
+
if values is not None and node_clock.knob_key in values:
|
|
451
|
+
seconds = _coerce_int(
|
|
452
|
+
values[node_clock.knob_key], seconds, MIN_STEP_SECONDS, MAX_STEP_SECONDS
|
|
453
|
+
)
|
|
454
|
+
else:
|
|
455
|
+
logger.error(
|
|
456
|
+
"[matrx_graph] step clock knob %s.%s unresolved — the step keeps its "
|
|
457
|
+
"declared %ss. REMEDY: seed that platform.feature_knob row.",
|
|
458
|
+
node_clock.knob_feature,
|
|
459
|
+
node_clock.knob_key,
|
|
460
|
+
seconds,
|
|
461
|
+
)
|
|
462
|
+
return float(max(MIN_STEP_SECONDS, min(MAX_STEP_SECONDS, seconds))), "declared"
|
|
441
463
|
if declared_timeout_ms is not None and declared_timeout_ms > 0:
|
|
442
464
|
return declared_timeout_ms / 1000.0, "declared"
|
|
443
465
|
return float(await default_step_seconds()), "platform"
|
matrx_graph/types/compiled.py
CHANGED
|
@@ -11,6 +11,22 @@ from matrx_graph.types.review import ReviewMark
|
|
|
11
11
|
from matrx_graph.types.variables import VariableSpec
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
class StepClock(BaseModel):
|
|
15
|
+
"""One node's OWN wall-clock limit (``NodeDef.data.step_clock``).
|
|
16
|
+
|
|
17
|
+
``seconds`` is the declared starting limit; ``knob`` (``{feature, key}``)
|
|
18
|
+
names the ``platform.feature_knob`` row an admin turns. The row wins when it
|
|
19
|
+
resolves; an unreadable row falls back to ``seconds`` — never to the
|
|
20
|
+
platform ceiling and never to "no limit".
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
24
|
+
|
|
25
|
+
seconds: int = Field(ge=1)
|
|
26
|
+
knob_feature: str | None = None
|
|
27
|
+
knob_key: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
14
30
|
class CompiledNode(BaseModel):
|
|
15
31
|
"""Scheduler-ready view of a node.
|
|
16
32
|
|
|
@@ -48,6 +64,11 @@ class CompiledNode(BaseModel):
|
|
|
48
64
|
# inspector without editing the action's code. None = inherit the
|
|
49
65
|
# spec default.
|
|
50
66
|
retry_policy: RetryPolicy | None = None
|
|
67
|
+
# Per-node wall clock (``data.step_clock``). When set it wins over the
|
|
68
|
+
# NodeSpec's ``timeout_ms`` and the platform ceiling for THIS node only —
|
|
69
|
+
# a long step (a podcast produced end to end) declares its own limit
|
|
70
|
+
# instead of the platform default being raised for every step.
|
|
71
|
+
step_clock: StepClock | None = None
|
|
51
72
|
# Set when the node's NodeSpec has ``dynamic_output=True`` (e.g.
|
|
52
73
|
# io.user_input): the JSON Schema computed from this node's config at
|
|
53
74
|
# compile time. Used to generate the run form and for downstream type
|
matrx_graph/workers/FEATURE.md
CHANGED
|
@@ -17,6 +17,7 @@ and `workflow.job`'s post-reorg baseline schema on 2026-08-09.
|
|
|
17
17
|
- **Jobs run in parallel, bounded, each in its own task (2026-09-17).** `WorkerRunner` holds an `asyncio.Semaphore(max_concurrent)`: it keeps claiming while slots are free, waits on the *clock* (`poll_interval`) only when the queue is empty, and waits on a *slot* when every one is busy. Each claimed job runs as its own `asyncio.Task` with its own lease renewer, and `complete`/`fail`/cancel semantics are per job and unchanged. **`max_concurrent` defaults to 1** — a host that has not thought about the bound keeps exactly the behavior it had; aidream passes `workflow_worker.max_concurrent_jobs` (a `platform.feature_knob` row, currently 8, read once at worker start). Why: the loop was strictly serial, so ONE slow job held every other workflow on the platform. Measured in production 2026-09-17, five simultaneous Cloud Browser activations (each a workflow job that waits on a container coming up) came up as a 5.5-second-per-run ladder, and for those 22 seconds nothing else in the queue moved; at the 50-run fleet ceiling that is a ~4.5-minute tail. **Per-job isolation is structural, not conventional**: a job task is created from the claim loop's context, so `contextvars` it sets (an ambient `AppContext`, an ORM connection) belong to that task alone — they cannot reach a sibling job and cannot outlive the job in the loop. Under the old serial `await` they did: aidream's `_run_one` installs an ambient AppContext at the tail of every job, and that context stayed installed in the claim loop afterwards. Proof: `tests/test_workers.py` — the wall-clock test, the peak-concurrency test, the neighbour-isolation test, the two drain tests, and the context-isolation test (each demonstrated failing against the serial loop before the change).
|
|
18
18
|
- **Stopping is a bounded drain.** `stop()` takes no new claims; `run_forever()` then waits out the jobs already running for `stop_grace_seconds` (aidream passes its share of THE DEPLOY WINDOW) and cancels whatever is left. A canceled job fails retryable and its run resumes from its last checkpoint — the same outcome a serial worker's single job always had when the container was replaced.
|
|
19
19
|
- **A worker only claims what it can run (2026-10-12, `capability.py`).** Every server — deployed fleet and developer laptops — shares one database, so a job may belong to a graph whose node types this build has not shipped (live: a local build's `brand.setup.land_logo` run was resumed by production and errored "Unknown node type"). `enqueue()` / `enqueue_claimed()` stamp `workflow.job.metadata.required_node_types` (canonical spellings, removed types excluded) from the run's stored definition; `claim_one(capabilities=...)` adds `COALESCE(j.metadata->'required_node_types','[]') <@ caps` (`matrx_orm.JsonbContainedBy`), so an unstamped legacy job stays claimable by anyone. `WorkerRunner` and `run_single_job` pass `worker_capabilities()` (the default registry) on every claim. Belt two: a host job runner that finds a gap at run time raises `JobNeedsCapability`; the runner calls `defer_for_capability()` (un-claim, attempt refunded, requirement stamped, immediately visible) — never `fail()`, never `complete()`, never a run-status write. The run says what it waits for in `workflow.run.metadata._awaiting_capability` (`missing`, `message`, `since`, `checked_at`, `checked_by`) plus one `workflow.run_log` line (`run_awaiting_capability`); every incapable worker refreshes it via `stamp_runs_awaiting_capability()` and `checked_at` is a lifecycle-watchdog liveness signal, so a waiting run is not force-failed as stranded while a fleet is watching it. The first capable worker clears the marker. Proof: `tests/test_capability_aware_claim.py` (incl. the predicate evaluated in live Postgres; fails against the pre-change queue/runner).
|
|
20
|
+
- **A copy that loses its lease stops itself (lane K1, 2026-10-21).** A renewal that answers "not ours" (reaped — including a FALSE "dead" after a database outage longer than the lease, live job c1d89868 — or settled elsewhere) marks the copy's `job_lease.JobLease` lost and cancels its task; the host skips its terminal write (the copy that holds the job owns the run); many-item work calls `job_lease.check()` before each item (it also reads the job row, at most every tenth of a renewal beat). The reaper does not try to tell an outage from a death: a false "dead" is harmless by construction. Proof: aidream `aidream/services/tests/test_a_reaped_run_never_waits_on_itself.py`. **Lane K1b:** whoever notices the loss first (the renewal or the work's own `check`) cancels the copy's owner task, and run-ahead re-raises a member that ended cancelled on a lost lease (it used to swallow it and route on). A job the reaper only DEAD-LETTERED (a false "dead" on its last attempt) is taken back by the live copy (`queue.take_back_false_dead_letter`, compare-and-set) and finished — stopping would strand the run, since nothing claims a dead-lettered job; a requeued or re-claimed job still stops the old copy. A renewal landing after `complete()` is not a loss (`JobLease.settling`). Each copy's `JobLease.generation` (the job's attempt) fences what it holds (import slots). A platform-wide outage longer than the lease plus grace cancels EVERY in-flight job at once and they all restart from their checkpoints together: the same spend per job as before, but as one burst. Proof: `tests/test_a_lost_lease_never_strands_or_lingers.py`.
|
|
20
21
|
- **Recovery starts before work.** `WorkerRunner.run_forever()` calls `reap_expired_leases()` before its first claim. The host's recurring reaper remains a backstop.
|
|
21
22
|
- **A transient claim outage never kills the worker.** A database timeout or
|
|
22
23
|
connection failure while claiming `workflow.job` is captured durably and
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The job lease a running execution holds, as seen from inside the work (lane K1, 2026-10-21).
|
|
2
|
+
|
|
3
|
+
Live, 2026-10-21 (run 8749d414, job c1d89868): the database dropped every connection for ten minutes, the reaper
|
|
4
|
+
read the expired lease as a dead worker and requeued the job, and the SAME process started the run again while the
|
|
5
|
+
first execution was still alive (hung on a store call). The first copy logged "finishing the in-flight run anyway"
|
|
6
|
+
and kept its organization import slot, so the second copy waited on itself.
|
|
7
|
+
|
|
8
|
+
The rule now (Arman, 2026-10-21): **a copy that loses its job lease stops itself.** The runner publishes the lease
|
|
9
|
+
of the job it executes on this ContextVar; long, many-item work (an import writes item after item) calls
|
|
10
|
+
:func:`check` before each item. Once the lease is gone — the renewal answered "not ours", or the job row says
|
|
11
|
+
another claim holds it — ``check`` raises :class:`JobLeaseLost`, the copy stops without writing again, and the
|
|
12
|
+
host skips its terminal write (the new copy owns the run). The runner also cancels the lost copy's task, so a copy
|
|
13
|
+
parked between items stops too.
|
|
14
|
+
|
|
15
|
+
``JobLeaseLost`` is a ``CancelledError``: to the engine and the host a lost lease is a cancellation of THIS copy,
|
|
16
|
+
never a failure of the run (a failure would be written over the new copy's run).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import contextlib
|
|
23
|
+
import logging
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from contextvars import ContextVar
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from uuid import UUID
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
class JobLeaseLost(asyncio.CancelledError):
|
|
33
|
+
"""This copy's job lease was reaped or taken by another claim: stop now, write nothing more."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class JobLease:
|
|
38
|
+
job_id: str
|
|
39
|
+
run_id: str
|
|
40
|
+
claimed_by: str | None
|
|
41
|
+
#: fewest seconds between two reads of the job row by ``check`` (a many-item run checks before every item); the
|
|
42
|
+
#: runner sets it from its lease (a tenth of the renewal beat), and the renewal's own answer is used in between
|
|
43
|
+
look_every: float = 0.0
|
|
44
|
+
#: the task running this copy of the job: whoever notices the loss first (the renewal, or the work's own
|
|
45
|
+
#: ``check``) cancels it, so the WHOLE copy stops at once (lane K1b, B2: run-ahead swallowed a ``JobLeaseLost``
|
|
46
|
+
#: raised inside one node, and the copy kept checkpointing and routing until the next renewal)
|
|
47
|
+
owner: asyncio.Task | None = field(default=None, repr=False)
|
|
48
|
+
#: the copy finished its work and is settling the job (``complete``): a renewal that lands after the settle
|
|
49
|
+
#: commits answers "not ours", and that is not a loss (K1b, M1)
|
|
50
|
+
settling: bool = False
|
|
51
|
+
#: per-copy fencing token for anything this copy holds (an import slot): a newer copy's is always greater
|
|
52
|
+
generation: float = field(default_factory=time.time)
|
|
53
|
+
visibility_timeout: int = 600
|
|
54
|
+
lost: bool = False
|
|
55
|
+
why: str = ""
|
|
56
|
+
_looked: float = field(default=0.0, repr=False)
|
|
57
|
+
|
|
58
|
+
def mark_lost(self, why: str) -> None:
|
|
59
|
+
if self.settling:
|
|
60
|
+
log.info("job %s: settled while a renewal was in flight; not a lost lease", self.job_id[:8])
|
|
61
|
+
return
|
|
62
|
+
if not self.lost:
|
|
63
|
+
log.error("job %s (run %s) lost its lease (%s): this copy stops and writes nothing more",
|
|
64
|
+
self.job_id[:8], self.run_id[:8], why)
|
|
65
|
+
self.lost, self.why = True, why
|
|
66
|
+
owner = self.owner
|
|
67
|
+
if owner is not None and not owner.done() and owner is not _running_task():
|
|
68
|
+
owner.cancel(f"job {self.job_id[:8]} lease lost: {why}")
|
|
69
|
+
|
|
70
|
+
async def still_held(self) -> bool | None:
|
|
71
|
+
"""The job row's answer: True while this claim holds it, False once it does not, None when unreadable."""
|
|
72
|
+
from matrx_graph.db._registry import get_model
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
rows = await get_model("WfJob").filter(id=str(UUID(self.job_id))).all(use_cache=False)
|
|
76
|
+
except Exception: # noqa: BLE001 — unreadable (an outage): the lease is not judged on a store that is down
|
|
77
|
+
log.warning("could not read job %s to check its lease", self.job_id[:8], exc_info=True)
|
|
78
|
+
return None
|
|
79
|
+
if not rows:
|
|
80
|
+
return False
|
|
81
|
+
row = rows[0]
|
|
82
|
+
get = row.get if isinstance(row, dict) else lambda k: getattr(row, k, None)
|
|
83
|
+
if get("claimed_at") is None:
|
|
84
|
+
return False
|
|
85
|
+
# every claim stamps a token: a token that is not this copy's (or one where this copy had none) is another
|
|
86
|
+
# claim (K1b, M4: a token-less copy used to read every later claim as its own)
|
|
87
|
+
return get("claimed_by") == self.claimed_by
|
|
88
|
+
|
|
89
|
+
async def not_ours(self, why: str) -> bool:
|
|
90
|
+
"""The queue says this copy no longer holds its job. When the reaper only DEAD-LETTERED it (a false "dead" on
|
|
91
|
+
the last attempt), no other copy can ever own the run: take the job back and keep going, so the run is
|
|
92
|
+
finished and its terminal status written (K1b, B1). Otherwise mark the lease lost (which stops the copy).
|
|
93
|
+
Returns True when the copy keeps the job."""
|
|
94
|
+
from matrx_graph.workers.queue import take_back_false_dead_letter
|
|
95
|
+
|
|
96
|
+
if self.settling:
|
|
97
|
+
return True
|
|
98
|
+
try:
|
|
99
|
+
kept = await take_back_false_dead_letter(
|
|
100
|
+
self.job_id, claimed_by=self.claimed_by, visibility_timeout_seconds=self.visibility_timeout)
|
|
101
|
+
except Exception: # noqa: BLE001 — unknown: the copy stops (the safe side for a run another copy may own)
|
|
102
|
+
log.warning("job %s: could not try to take back a dead-lettered job", self.job_id[:8], exc_info=True)
|
|
103
|
+
kept = False
|
|
104
|
+
if not kept:
|
|
105
|
+
self.mark_lost(why)
|
|
106
|
+
return kept
|
|
107
|
+
|
|
108
|
+
async def check(self) -> None:
|
|
109
|
+
"""Raise :class:`JobLeaseLost` when this copy no longer holds its job; read the row at most every
|
|
110
|
+
``look_every`` seconds."""
|
|
111
|
+
if not self.lost and time.monotonic() - self._looked >= self.look_every:
|
|
112
|
+
self._looked = time.monotonic()
|
|
113
|
+
if await self.still_held() is False:
|
|
114
|
+
await self.not_ours("the job row is claimed by another copy or was requeued")
|
|
115
|
+
if self.lost:
|
|
116
|
+
raise JobLeaseLost(f"job {self.job_id[:8]} lease lost: {self.why}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _running_task() -> asyncio.Task | None:
|
|
120
|
+
try:
|
|
121
|
+
return asyncio.current_task()
|
|
122
|
+
except RuntimeError:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_current: ContextVar[JobLease | None] = ContextVar("matrx_graph_job_lease", default=None)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def current() -> JobLease | None:
|
|
130
|
+
"""The lease of the job this task executes; None outside a queued job (an inline run has no job lease)."""
|
|
131
|
+
return _current.get()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def check() -> None:
|
|
135
|
+
"""Before each item of many-item work: raises :class:`JobLeaseLost` once this copy's job lease is gone."""
|
|
136
|
+
lease = _current.get()
|
|
137
|
+
if lease is not None:
|
|
138
|
+
await lease.check()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def lost() -> bool:
|
|
142
|
+
lease = _current.get()
|
|
143
|
+
return bool(lease is not None and lease.lost)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@contextlib.contextmanager
|
|
147
|
+
def holding(lease: JobLease) -> Iterator[JobLease]:
|
|
148
|
+
token = _current.set(lease)
|
|
149
|
+
try:
|
|
150
|
+
yield lease
|
|
151
|
+
finally:
|
|
152
|
+
_current.reset(token)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__all__ = ["JobLease", "JobLeaseLost", "check", "current", "holding", "lost"]
|
matrx_graph/workers/queue.py
CHANGED
|
@@ -707,19 +707,89 @@ async def renew_lease(
|
|
|
707
707
|
return result.rows_affected > 0
|
|
708
708
|
|
|
709
709
|
|
|
710
|
+
async def take_back_false_dead_letter(
|
|
711
|
+
job_id: str,
|
|
712
|
+
*,
|
|
713
|
+
claimed_by: str | None,
|
|
714
|
+
visibility_timeout_seconds: int = 600,
|
|
715
|
+
) -> bool:
|
|
716
|
+
"""A live copy whose job the reaper DEAD-LETTERED (a false "dead": an outage outlasted the lease on the job's last
|
|
717
|
+
attempt) takes the job back, so it finishes the run and writes its terminal status (lane K1b, B1, 2026-10-21).
|
|
718
|
+
|
|
719
|
+
Without this the live copy read "not ours", cancelled itself and skipped its terminal write, and no other copy would
|
|
720
|
+
ever claim a dead-lettered job: the run stayed non-terminal (stranded) for every job kind. A dead-lettered job has
|
|
721
|
+
no other owner, so taking it back can never run two copies. Compare-and-set: only a row that is still unclaimed,
|
|
722
|
+
still dead-lettered BY THE REAPER (``last_error.type == 'lease_expired'``) and still parked in the far future
|
|
723
|
+
(an operator's retry moves ``visible_at`` back) is taken; anything else answers False and the copy stops.
|
|
724
|
+
"""
|
|
725
|
+
from matrx_graph.db._registry import get_model
|
|
726
|
+
|
|
727
|
+
model = get_model("WfJob")
|
|
728
|
+
try:
|
|
729
|
+
rows = await model.filter(id=str(UUID(job_id))).all(use_cache=False)
|
|
730
|
+
except Exception: # noqa: BLE001 — unreadable: not taken back (the caller decides on the renewal's answer)
|
|
731
|
+
log.warning("could not read job %s to see whether the reaper dead-lettered it", job_id[:8], exc_info=True)
|
|
732
|
+
return False
|
|
733
|
+
if not rows:
|
|
734
|
+
return False
|
|
735
|
+
row = rows[0]
|
|
736
|
+
get = row.get if isinstance(row, dict) else lambda k: getattr(row, k, None)
|
|
737
|
+
note = _parse_json(get("last_error")) or {}
|
|
738
|
+
if get("claimed_at") is not None or not isinstance(note, dict):
|
|
739
|
+
return False
|
|
740
|
+
if note.get("queue_state") != "dead_letter" or note.get("type") != "lease_expired":
|
|
741
|
+
return False
|
|
742
|
+
now = datetime.now(UTC)
|
|
743
|
+
taken = (
|
|
744
|
+
await model.filter(
|
|
745
|
+
id=str(UUID(job_id)),
|
|
746
|
+
claimed_at__isnull=True,
|
|
747
|
+
visible_at__gt=now + timedelta(days=_FAR_FUTURE_DAYS // 2),
|
|
748
|
+
).update(
|
|
749
|
+
claimed_at=now,
|
|
750
|
+
claimed_by=claimed_by,
|
|
751
|
+
visible_at=now + timedelta(seconds=visibility_timeout_seconds),
|
|
752
|
+
last_error={
|
|
753
|
+
"queue_state": "in_progress",
|
|
754
|
+
"type": "false_dead_taken_back",
|
|
755
|
+
"message": "the reaper dead-lettered a job its worker was still running (an outage outlasted the "
|
|
756
|
+
"lease); the live copy took it back to finish the run",
|
|
757
|
+
},
|
|
758
|
+
)
|
|
759
|
+
).updated_rows
|
|
760
|
+
if taken:
|
|
761
|
+
log.warning("job %s: the reaper dead-lettered it while this worker still ran it; taken back to finish the run",
|
|
762
|
+
job_id[:8])
|
|
763
|
+
return bool(taken)
|
|
764
|
+
|
|
765
|
+
|
|
710
766
|
async def reap_expired_leases(*, grace_seconds: int = 120) -> int:
|
|
711
767
|
"""Requeue jobs whose lease expired ≥ ``grace_seconds`` ago (audit P1-14).
|
|
712
768
|
|
|
713
769
|
A worker that dies mid-job leaves ``claimed_at`` set forever — only
|
|
714
770
|
complete/fail/release null it — so the job is orphaned: never re-run,
|
|
715
771
|
never dead-lettered, invisible to claim_one. This reaper is the one
|
|
716
|
-
mechanism that recovers those rows.
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
772
|
+
mechanism that recovers those rows. WorkerRunner renews the lease every
|
|
773
|
+
lease/3 seconds while a job executes, so an expired-by-more-than-grace
|
|
774
|
+
lease USUALLY means the worker is dead — but not always: a database
|
|
775
|
+
outage longer than the lease fails every renewal of every live worker
|
|
776
|
+
at once (live 2026-10-21, 15:49–15:59Z: job c1d89868 was requeued from
|
|
777
|
+
a worker that was alive).
|
|
778
|
+
|
|
779
|
+
Decision (lane K1, 2026-10-21): the reaper does NOT try to tell an outage
|
|
780
|
+
from a death (an outage-detection rule would itself have to read a store
|
|
781
|
+
that just failed, and would delay real recovery); instead a false "dead"
|
|
782
|
+
is made HARMLESS by the copies themselves: a copy whose renewal answers
|
|
783
|
+
"not ours" marks its lease lost, is cancelled and skips its terminal
|
|
784
|
+
write (``runner._renew_loop`` + ``job_lease``); many-item work checks the
|
|
785
|
+
lease before each item and each store attempt; and a slot held for the
|
|
786
|
+
run (``work_claims.hold_work_slot(holder=run_id)``) is taken over by the
|
|
787
|
+
new copy at once, so the run never waits on its own earlier copy.
|
|
720
788
|
|
|
721
789
|
Jobs with attempts left are requeued (claim_one re-claims and bumps
|
|
722
|
-
``attempt``); exhausted jobs are dead-lettered
|
|
790
|
+
``attempt``); exhausted jobs are dead-lettered (a live copy whose job was
|
|
791
|
+
falsely dead-lettered takes it back, ``take_back_false_dead_letter``, lane
|
|
792
|
+
K1b, so its run is never stranded) with the same far-future
|
|
723
793
|
visible_at shape ``fail()`` uses. Returns how many rows were touched.
|
|
724
794
|
"""
|
|
725
795
|
from matrx_graph.db._registry import get_model
|
matrx_graph/workers/runner.py
CHANGED
|
@@ -30,6 +30,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
30
30
|
|
|
31
31
|
from matrx_utils import capture_error
|
|
32
32
|
|
|
33
|
+
from matrx_graph.workers import job_lease
|
|
33
34
|
from matrx_graph.workers.capability import (
|
|
34
35
|
JobNeedsCapability,
|
|
35
36
|
mark_run_awaiting_capability,
|
|
@@ -320,14 +321,36 @@ class WorkerRunner:
|
|
|
320
321
|
await asyncio.gather(*still_running, return_exceptions=True)
|
|
321
322
|
|
|
322
323
|
async def _process(self, job: Job) -> None:
|
|
323
|
-
|
|
324
|
+
# The lease this copy holds, published to the work (``job_lease.check`` before each item) and marked lost by
|
|
325
|
+
# the renewal the moment the queue says the job is no longer ours (lane K1, 2026-10-21).
|
|
326
|
+
lease = job_lease.JobLease(
|
|
327
|
+
job_id=job.job_id,
|
|
328
|
+
run_id=job.run_id,
|
|
329
|
+
claimed_by=job.claimed_by,
|
|
330
|
+
look_every=max(self._visibility_timeout // 3, 10) / 10,
|
|
331
|
+
owner=asyncio.current_task(),
|
|
332
|
+
visibility_timeout=self._visibility_timeout,
|
|
333
|
+
# the job's attempt: every re-claim bumps it, so a later copy's fencing token is always greater (K1b, B5)
|
|
334
|
+
generation=float(job.attempt or 0),
|
|
335
|
+
)
|
|
336
|
+
renewer = asyncio.create_task(
|
|
337
|
+
self._renew_loop(job.job_id, claimed_by=job.claimed_by, lease=lease, owner=asyncio.current_task())
|
|
338
|
+
)
|
|
324
339
|
try:
|
|
325
340
|
app = await self._app_factory(job)
|
|
326
|
-
|
|
341
|
+
with job_lease.holding(lease):
|
|
342
|
+
await self._run_job(job, app)
|
|
343
|
+
lease.settling = True # a renewal answering "not ours" after this settles is not a loss (K1b, M1)
|
|
327
344
|
await complete(job.job_id, claimed_by=job.claimed_by)
|
|
328
345
|
except JobNeedsCapability as need:
|
|
329
346
|
await hand_back_for_capability(job, need)
|
|
330
347
|
except asyncio.CancelledError as exc:
|
|
348
|
+
if lease.lost:
|
|
349
|
+
# A copy whose lease was reaped stops here and settles NOTHING: the job row and the run belong to the
|
|
350
|
+
# copy that holds the job now (its fail() would be fenced anyway; this keeps the log honest).
|
|
351
|
+
log.warning("job %s: this copy stopped after losing its lease (%s); the current holder owns the run",
|
|
352
|
+
job.job_id[:8], lease.why)
|
|
353
|
+
raise
|
|
331
354
|
try:
|
|
332
355
|
await asyncio.shield(
|
|
333
356
|
fail(
|
|
@@ -396,7 +419,14 @@ class WorkerRunner:
|
|
|
396
419
|
except asyncio.CancelledError:
|
|
397
420
|
pass
|
|
398
421
|
|
|
399
|
-
async def _renew_loop(
|
|
422
|
+
async def _renew_loop(
|
|
423
|
+
self,
|
|
424
|
+
job_id: str,
|
|
425
|
+
*,
|
|
426
|
+
claimed_by: str | None,
|
|
427
|
+
lease: job_lease.JobLease | None = None,
|
|
428
|
+
owner: asyncio.Task[Any] | None = None,
|
|
429
|
+
) -> None:
|
|
400
430
|
# Lease/3 keeps two renewal misses of headroom before expiry; the
|
|
401
431
|
# reaper adds its own grace on top, so only a dead worker is reaped.
|
|
402
432
|
interval = max(self._visibility_timeout // 3, 10)
|
|
@@ -421,16 +451,27 @@ class WorkerRunner:
|
|
|
421
451
|
)
|
|
422
452
|
continue
|
|
423
453
|
if not still_ours:
|
|
424
|
-
# Reaped
|
|
425
|
-
#
|
|
426
|
-
#
|
|
427
|
-
#
|
|
454
|
+
# Reaped (a false "dead" after a database outage looks exactly like this) or settled elsewhere.
|
|
455
|
+
# Arman, 2026-10-21: a copy that loses its job lease STOPS ITSELF. Finishing "anyway" (the old rule)
|
|
456
|
+
# left two copies of one run alive: the old one, hung on a store call, kept its organization import
|
|
457
|
+
# slot and the restarted copy waited on itself (run 8749d414). Another copy may own the run now, so
|
|
458
|
+
# this one stops: the lease is marked lost (many-item work checks it before each item), the job
|
|
459
|
+
# task is cancelled, and the host skips its terminal write.
|
|
460
|
+
# Lane K1b, B1: when the reaper only DEAD-LETTERED the job (a false "dead" on its last attempt), no
|
|
461
|
+
# other copy can ever own the run, so this copy takes the job back and finishes it (stopping would
|
|
462
|
+
# strand the run: nothing would ever write its terminal status).
|
|
463
|
+
if lease is not None:
|
|
464
|
+
if lease.settling:
|
|
465
|
+
return
|
|
466
|
+
if await lease.not_ours("the queue reaped or settled the job"):
|
|
467
|
+
continue
|
|
428
468
|
log.error(
|
|
429
469
|
"job %s lease is no longer ours (reaped or deleted) — "
|
|
430
|
-
"
|
|
431
|
-
"arbitrate duplicate terminal writes",
|
|
470
|
+
"this copy stops; the current holder owns the run",
|
|
432
471
|
job_id,
|
|
433
472
|
)
|
|
473
|
+
if owner is not None and not owner.done():
|
|
474
|
+
owner.cancel()
|
|
434
475
|
return
|
|
435
476
|
|
|
436
477
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: matrx-graph
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.332
|
|
4
4
|
Summary: Channel-based, super-step workflow orchestration engine for the Matrx ecosystem
|
|
5
5
|
Project-URL: Homepage, https://github.com/AI-Matrix-Engine/aidream-current
|
|
6
6
|
Project-URL: Repository, https://github.com/AI-Matrix-Engine/aidream-current
|
|
@@ -19,7 +19,7 @@ Requires-Python: >=3.13
|
|
|
19
19
|
Requires-Dist: croniter>=2.0
|
|
20
20
|
Requires-Dist: httpx>=0.27
|
|
21
21
|
Requires-Dist: jsonschema>=4.20
|
|
22
|
-
Requires-Dist: matrx-connect>=0.1.
|
|
22
|
+
Requires-Dist: matrx-connect>=0.1.236
|
|
23
23
|
Requires-Dist: matrx-utils>=2.0.83
|
|
24
24
|
Requires-Dist: pydantic>=2.12
|
|
25
25
|
Requires-Dist: pyyaml>=6.0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
matrx_graph/__init__.py,sha256=6-2kvFL4Q1xbj21ZOqIGAvErgwYTStenEqzyrMdmY5o,7803
|
|
2
2
|
matrx_graph/candidate.py,sha256=nWyD4FkELyT0aGAJG13OkcGjyLlqO_M7Jct4IIe8oJk,10497
|
|
3
3
|
matrx_graph/checks.py,sha256=F5mDzoDAj5ArNuuehN88DntSk_iIWAq1WlBaMTXIUFE,12187
|
|
4
|
-
matrx_graph/compile.py,sha256=
|
|
4
|
+
matrx_graph/compile.py,sha256=ewfpjIZtNBPvl0Kj_1jYx12KA1cB9NekdmuWWw_SOqA,27436
|
|
5
5
|
matrx_graph/composition.py,sha256=PX3XAnH1v6jOJ1kYGVlJH3hzqQFTEtxR2uNzRObtdWA,9069
|
|
6
6
|
matrx_graph/contract_kinds.py,sha256=xOlhlbwijClxeDzj12mxpbWqMc6k8bsz6UyHcjROqsc,11818
|
|
7
7
|
matrx_graph/decision_spec.py,sha256=5QGYYa0xLqXidHbILwTjTkE9WbZzyKxLjz25CYVr2Kw,15764
|
|
@@ -15,7 +15,7 @@ matrx_graph/input_bindings.py,sha256=mweDa6SKo-I9PU7qJIz_jgruciUhtp7bV1KBBjla0TU
|
|
|
15
15
|
matrx_graph/kind_control_keys.py,sha256=35LcweBvfqU2pz6Eu8vFn7kM-vh9FK1B4QPArZ76WS0,9444
|
|
16
16
|
matrx_graph/kind_fields.py,sha256=6F9jmJNz9-104FSAqIwx8xcy5HqeeYxedgyoyPJf0mk,21123
|
|
17
17
|
matrx_graph/kinds.py,sha256=Uh1KmVBL46EpC4ojKCq7eOom8SVleEROUmRYzz27BU4,37751
|
|
18
|
-
matrx_graph/knobs.py,sha256=
|
|
18
|
+
matrx_graph/knobs.py,sha256=tZMlF7YV3GZw3liccEnFdOkYBbvOeFxgc25oetdEOGo,22463
|
|
19
19
|
matrx_graph/mapping_sources.py,sha256=8VGTbPmib7uyh2L8ZnjsJa6h57lKgz0JOpJ9fmkcYxU,3910
|
|
20
20
|
matrx_graph/overview.py,sha256=3_hxx3dFZQtZkVE-9TcdU8udK4GWndnHKKPNU__aTws,24406
|
|
21
21
|
matrx_graph/patch.py,sha256=DCd7MioacEfcaPxuvrDZS9uxfUE-3EKN7FBL4vLc9y0,10675
|
|
@@ -147,7 +147,7 @@ matrx_graph/executor/resume_chain.py,sha256=YJv8P8Ca1wSAsUEjeii3Tg6Fgoy5N9Tf2Gs4
|
|
|
147
147
|
matrx_graph/executor/resume_preflight.py,sha256=vz9_01QINvgwK9u2y7NcF7B9J2Yv7K1jHalRuZD7siw,11977
|
|
148
148
|
matrx_graph/executor/review_flags.py,sha256=hMR8tPrSd_-vgsLiTc82MmEaIx-sMkC5yf53zIbhh8M,5542
|
|
149
149
|
matrx_graph/executor/run_stop.py,sha256=HpdGE83MabnU_4rnf_Vn8zRyk5LBF3qbZQ2Ouvc36hY,3004
|
|
150
|
-
matrx_graph/executor/scheduler.py,sha256=
|
|
150
|
+
matrx_graph/executor/scheduler.py,sha256=1ezi7TYpY-tLSk-dsGjuk38WIK3ivdPw8wUDFVCdLfw,323193
|
|
151
151
|
matrx_graph/executor/schema_validation.py,sha256=X2CbDX7_vZGBtcw_L0BQcjDD-KEAxnGA8ruhm9Qp5CI,5250
|
|
152
152
|
matrx_graph/nodes/__init__.py,sha256=ikOsSIu8aaSYAZXa_Uks-RW_BkIjwiO1TwTptLom0bY,10118
|
|
153
153
|
matrx_graph/nodes/_sandbox.py,sha256=Uizb2QIbW9MgZ_4MtWoJlmjR1mMXZNb9oMYGhXNXeQ0,10517
|
|
@@ -229,7 +229,7 @@ matrx_graph/observability/__init__.py,sha256=h8iYxtLuNQg2CFepjbVzvx_dLOeA-iG3H-1
|
|
|
229
229
|
matrx_graph/types/__init__.py,sha256=DsU_RAigVdPhLwzRDFU9M0bZ10uhYOhVveQOBh2vPr8,2335
|
|
230
230
|
matrx_graph/types/canonical_payloads.py,sha256=c_R_EdjbXUyXcDUxMYTbi66oFJzzyTFmTIAKdQIB2gw,4925
|
|
231
231
|
matrx_graph/types/channel.py,sha256=Tr0RjeUkhaXwW-DYnEhloWV2Zw-xbT6VmRbFqo6u0Zg,2385
|
|
232
|
-
matrx_graph/types/compiled.py,sha256=
|
|
232
|
+
matrx_graph/types/compiled.py,sha256=9oCymfDLXEmxrezmfzTPV3EHK8ELtjdAG_u_cjXwsYM,12048
|
|
233
233
|
matrx_graph/types/context.py,sha256=4AamW22VTE3K3IhGONg4gR3q8M9hrbMC9sFiMbYQU7M,18213
|
|
234
234
|
matrx_graph/types/definition.py,sha256=noLq0ThxQQ1_N8ucLPXnXHXHmnyeKDwvSRcbSyNn2QU,12935
|
|
235
235
|
matrx_graph/types/deliverables.py,sha256=-3kOWPSAKbSmqEFBhcVjYpjJ75mCycVrhOOSo3gEBOM,12550
|
|
@@ -246,14 +246,15 @@ matrx_graph/types/send.py,sha256=sKE_moS4uTB4P3L02Q7uKj0WhTzABm3vTk2o54EpoPA,254
|
|
|
246
246
|
matrx_graph/types/usl.py,sha256=-zXXiHZpxpv7V7n_xgdcnY2l8XlWoM9ZUQNkwHYmqiQ,3376
|
|
247
247
|
matrx_graph/types/value_objects.py,sha256=ylVpyj-_YQ2xhMAHVW2AdRo5yqzgXLajGUuaIZhJFQY,1295
|
|
248
248
|
matrx_graph/types/variables.py,sha256=pCECEaDP3y2qwZkIlhpICRucFTMHRLW8wU3zXQBooFY,10896
|
|
249
|
-
matrx_graph/workers/FEATURE.md,sha256=
|
|
249
|
+
matrx_graph/workers/FEATURE.md,sha256=yZXybUsAbUkYDwXBNnzoJXGCedQmVK_BQCZ3AXDlogk,18502
|
|
250
250
|
matrx_graph/workers/__init__.py,sha256=G8e24ryKLafH_NggiiGIb9EZ7YparOJ5DaBOw0bbPv0,2888
|
|
251
251
|
matrx_graph/workers/capability.py,sha256=9kFjZCfuie5GkevLLTBJVJ3m9NeeS36RJhbjK0r86fw,11058
|
|
252
252
|
matrx_graph/workers/cron_watcher.py,sha256=JVzUJ7Wmtz9PPwUtkxnSyqkheK3nEM-7lHUlecdazYY,3988
|
|
253
253
|
matrx_graph/workers/event_watcher.py,sha256=DYk4RgapiJJDyKY8zirBpp3HAlqi7BWExf4rWW97FHY,6263
|
|
254
|
-
matrx_graph/workers/
|
|
255
|
-
matrx_graph/workers/
|
|
254
|
+
matrx_graph/workers/job_lease.py,sha256=pYZidqPB4hgHa8KKZqSJDGetsW7Wm_aVwSXM0ycrrfY,7213
|
|
255
|
+
matrx_graph/workers/queue.py,sha256=GGzuJju1DvrZFPbfvGQEMuWay-2m18NzZPNlWligS4Q,32413
|
|
256
|
+
matrx_graph/workers/runner.py,sha256=6ALoNst6LY9pK-8I7r0QPLjdKpSfyimp5JwFb1PFdmI,23946
|
|
256
257
|
matrx_graph/workers/sleep_wake.py,sha256=1V-opM2zYtuN3n4ez85C4trYHoEapVwz8F7_MAex8HI,5970
|
|
257
|
-
matrx_graph-0.2.
|
|
258
|
-
matrx_graph-0.2.
|
|
259
|
-
matrx_graph-0.2.
|
|
258
|
+
matrx_graph-0.2.332.dist-info/METADATA,sha256=Xtv8QD6KJcjVl9GHAwEdySTwocg45udFubgG2r2VWYk,5480
|
|
259
|
+
matrx_graph-0.2.332.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
260
|
+
matrx_graph-0.2.332.dist-info/RECORD,,
|
|
File without changes
|