matrx-assignment 0.1.0__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_assignment/__init__.py +60 -0
- matrx_assignment/coordinator.py +189 -0
- matrx_assignment/in_memory.py +416 -0
- matrx_assignment/models.py +271 -0
- matrx_assignment/planner.py +143 -0
- matrx_assignment/store.py +57 -0
- matrx_assignment-0.1.0.dist-info/METADATA +30 -0
- matrx_assignment-0.1.0.dist-info/RECORD +9 -0
- matrx_assignment-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from matrx_assignment.coordinator import (
|
|
2
|
+
AssignmentCoordinator,
|
|
3
|
+
AssignmentExecutionFailure,
|
|
4
|
+
AssignmentExecutor,
|
|
5
|
+
)
|
|
6
|
+
from matrx_assignment.in_memory import InMemoryAssignmentStore
|
|
7
|
+
from matrx_assignment.models import (
|
|
8
|
+
AssignmentBatchResult,
|
|
9
|
+
AssignmentClaim,
|
|
10
|
+
AssignmentError,
|
|
11
|
+
AssignmentExecutionOutput,
|
|
12
|
+
AssignmentItemResult,
|
|
13
|
+
AssignmentItemSnapshot,
|
|
14
|
+
AssignmentItemStatus,
|
|
15
|
+
AssignmentOrder,
|
|
16
|
+
AssignmentPlan,
|
|
17
|
+
AssignmentRow,
|
|
18
|
+
AssignmentSessionRequest,
|
|
19
|
+
AssignmentSessionStatus,
|
|
20
|
+
AssignmentSessionSummary,
|
|
21
|
+
AssignmentSource,
|
|
22
|
+
AssignmentUniqueness,
|
|
23
|
+
CartesianPlan,
|
|
24
|
+
CoordinatedRowsPlan,
|
|
25
|
+
IndependentRandomPlan,
|
|
26
|
+
MaterializedAssignment,
|
|
27
|
+
MaterializedPlan,
|
|
28
|
+
)
|
|
29
|
+
from matrx_assignment.planner import AssignmentPlanner, fingerprint_json
|
|
30
|
+
from matrx_assignment.store import AssignmentStore
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"AssignmentBatchResult",
|
|
34
|
+
"AssignmentClaim",
|
|
35
|
+
"AssignmentCoordinator",
|
|
36
|
+
"AssignmentError",
|
|
37
|
+
"AssignmentExecutionFailure",
|
|
38
|
+
"AssignmentExecutionOutput",
|
|
39
|
+
"AssignmentExecutor",
|
|
40
|
+
"AssignmentItemResult",
|
|
41
|
+
"AssignmentItemSnapshot",
|
|
42
|
+
"AssignmentItemStatus",
|
|
43
|
+
"AssignmentOrder",
|
|
44
|
+
"AssignmentPlan",
|
|
45
|
+
"AssignmentPlanner",
|
|
46
|
+
"AssignmentRow",
|
|
47
|
+
"AssignmentSessionRequest",
|
|
48
|
+
"AssignmentSessionStatus",
|
|
49
|
+
"AssignmentSessionSummary",
|
|
50
|
+
"AssignmentSource",
|
|
51
|
+
"AssignmentStore",
|
|
52
|
+
"AssignmentUniqueness",
|
|
53
|
+
"CartesianPlan",
|
|
54
|
+
"CoordinatedRowsPlan",
|
|
55
|
+
"IndependentRandomPlan",
|
|
56
|
+
"InMemoryAssignmentStore",
|
|
57
|
+
"MaterializedAssignment",
|
|
58
|
+
"MaterializedPlan",
|
|
59
|
+
"fingerprint_json",
|
|
60
|
+
]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import socket
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from datetime import UTC, datetime, timedelta
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
from matrx_assignment.models import (
|
|
10
|
+
AssignmentBatchResult,
|
|
11
|
+
AssignmentClaim,
|
|
12
|
+
AssignmentError,
|
|
13
|
+
AssignmentExecutionOutput,
|
|
14
|
+
AssignmentItemResult,
|
|
15
|
+
AssignmentSessionRequest,
|
|
16
|
+
AssignmentSessionStatus,
|
|
17
|
+
)
|
|
18
|
+
from matrx_assignment.store import AssignmentStore
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AssignmentExecutor(Protocol):
|
|
22
|
+
async def __call__(self, claim: AssignmentClaim) -> AssignmentExecutionOutput: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ProgressCallback = Callable[[int, int], Awaitable[None]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AssignmentExecutionFailure(RuntimeError):
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
code: str = "assignment_execution_failed",
|
|
34
|
+
retryable: bool = True,
|
|
35
|
+
details: dict[str, object] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.error = AssignmentError(
|
|
39
|
+
code=code,
|
|
40
|
+
message=message,
|
|
41
|
+
retryable=retryable,
|
|
42
|
+
details=details or {},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AssignmentCoordinator:
|
|
47
|
+
"""Lease and execute persisted items without knowing what the payload does."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, store: AssignmentStore, *, holder: str | None = None) -> None:
|
|
50
|
+
self._store = store
|
|
51
|
+
self._holder = holder or socket.gethostname()
|
|
52
|
+
|
|
53
|
+
async def run(
|
|
54
|
+
self,
|
|
55
|
+
session_id: str,
|
|
56
|
+
request: AssignmentSessionRequest,
|
|
57
|
+
executor: AssignmentExecutor,
|
|
58
|
+
*,
|
|
59
|
+
progress: ProgressCallback | None = None,
|
|
60
|
+
) -> AssignmentBatchResult:
|
|
61
|
+
await self._store.reap_expired_claims(session_id, now=datetime.now(UTC))
|
|
62
|
+
while True:
|
|
63
|
+
session = await self._store.get_session(session_id)
|
|
64
|
+
if session.status in {
|
|
65
|
+
AssignmentSessionStatus.COMPLETED,
|
|
66
|
+
AssignmentSessionStatus.PARTIALLY_FAILED,
|
|
67
|
+
AssignmentSessionStatus.FAILED,
|
|
68
|
+
AssignmentSessionStatus.CANCELLED,
|
|
69
|
+
}:
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
claims = await self._store.claim_items(
|
|
73
|
+
session_id,
|
|
74
|
+
holder=self._holder,
|
|
75
|
+
limit=request.max_concurrency,
|
|
76
|
+
lease_seconds=request.lease_seconds,
|
|
77
|
+
)
|
|
78
|
+
if not claims:
|
|
79
|
+
session = await self._store.finalize_session(session_id)
|
|
80
|
+
if session.status is AssignmentSessionStatus.RUNNING:
|
|
81
|
+
await asyncio.sleep(
|
|
82
|
+
min(1.0, max(0.05, float(request.retry_delay_seconds)))
|
|
83
|
+
)
|
|
84
|
+
continue
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
await asyncio.gather(
|
|
88
|
+
*(
|
|
89
|
+
self._execute_one(
|
|
90
|
+
claim,
|
|
91
|
+
request=request,
|
|
92
|
+
executor=executor,
|
|
93
|
+
)
|
|
94
|
+
for claim in claims
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
if progress is not None:
|
|
98
|
+
snapshot = await self._store.get_session(session_id)
|
|
99
|
+
await progress(snapshot.completed_items + snapshot.failed_items, snapshot.total_items)
|
|
100
|
+
|
|
101
|
+
await self._store.finalize_session(session_id)
|
|
102
|
+
return await self.snapshot(session_id)
|
|
103
|
+
|
|
104
|
+
async def snapshot(self, session_id: str) -> AssignmentBatchResult:
|
|
105
|
+
"""Load the durable result without claiming or executing any work."""
|
|
106
|
+
|
|
107
|
+
final_session = await self._store.get_session(session_id)
|
|
108
|
+
items = await self._store.list_items(session_id)
|
|
109
|
+
return AssignmentBatchResult(
|
|
110
|
+
session=final_session,
|
|
111
|
+
items=[
|
|
112
|
+
AssignmentItemResult(
|
|
113
|
+
id=item.id,
|
|
114
|
+
ordinal=item.ordinal,
|
|
115
|
+
key=item.key,
|
|
116
|
+
status=item.status,
|
|
117
|
+
values=item.values,
|
|
118
|
+
output=item.output,
|
|
119
|
+
output_kind=item.output_kind,
|
|
120
|
+
output_kind_version=item.output_kind_version,
|
|
121
|
+
conversation_id=item.conversation_id,
|
|
122
|
+
runtime_execution_id=item.runtime_execution_id,
|
|
123
|
+
error=item.error,
|
|
124
|
+
)
|
|
125
|
+
for item in items
|
|
126
|
+
],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def _execute_one(
|
|
130
|
+
self,
|
|
131
|
+
claim: AssignmentClaim,
|
|
132
|
+
*,
|
|
133
|
+
request: AssignmentSessionRequest,
|
|
134
|
+
executor: AssignmentExecutor,
|
|
135
|
+
) -> None:
|
|
136
|
+
await self._store.mark_running(claim)
|
|
137
|
+
heartbeat_stop = asyncio.Event()
|
|
138
|
+
heartbeat = asyncio.create_task(
|
|
139
|
+
self._renew_lease(
|
|
140
|
+
claim,
|
|
141
|
+
lease_seconds=request.lease_seconds,
|
|
142
|
+
stop=heartbeat_stop,
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
output = await executor(claim)
|
|
147
|
+
except AssignmentExecutionFailure as exc:
|
|
148
|
+
error = exc.error
|
|
149
|
+
except Exception as exc: # executor boundary: unknown failures are retryable by default
|
|
150
|
+
error = AssignmentError(
|
|
151
|
+
code=type(exc).__name__,
|
|
152
|
+
message=str(exc) or type(exc).__name__,
|
|
153
|
+
retryable=True,
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
await self._store.complete_item(claim, output)
|
|
157
|
+
return
|
|
158
|
+
finally:
|
|
159
|
+
heartbeat_stop.set()
|
|
160
|
+
await heartbeat
|
|
161
|
+
|
|
162
|
+
retryable = error.retryable and claim.attempt_number < claim.item.max_attempts
|
|
163
|
+
if retryable != error.retryable:
|
|
164
|
+
error = error.model_copy(update={"retryable": False})
|
|
165
|
+
delay = request.retry_delay_seconds * (2 ** max(0, claim.attempt_number - 1))
|
|
166
|
+
await self._store.fail_item(
|
|
167
|
+
claim,
|
|
168
|
+
error,
|
|
169
|
+
retry_at=datetime.now(UTC) + timedelta(seconds=delay),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
async def _renew_lease(
|
|
173
|
+
self,
|
|
174
|
+
claim: AssignmentClaim,
|
|
175
|
+
*,
|
|
176
|
+
lease_seconds: int,
|
|
177
|
+
stop: asyncio.Event,
|
|
178
|
+
) -> None:
|
|
179
|
+
interval = max(10.0, lease_seconds / 3)
|
|
180
|
+
while True:
|
|
181
|
+
try:
|
|
182
|
+
await asyncio.wait_for(stop.wait(), timeout=interval)
|
|
183
|
+
return
|
|
184
|
+
except TimeoutError:
|
|
185
|
+
if not await self._store.renew_claim(
|
|
186
|
+
claim,
|
|
187
|
+
lease_seconds=lease_seconds,
|
|
188
|
+
):
|
|
189
|
+
return
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from uuid import NAMESPACE_URL, uuid4, uuid5
|
|
7
|
+
|
|
8
|
+
from matrx_assignment.models import (
|
|
9
|
+
AssignmentAttemptStatus,
|
|
10
|
+
AssignmentClaim,
|
|
11
|
+
AssignmentError,
|
|
12
|
+
AssignmentExecutionOutput,
|
|
13
|
+
AssignmentItemSnapshot,
|
|
14
|
+
AssignmentItemStatus,
|
|
15
|
+
AssignmentSessionRequest,
|
|
16
|
+
AssignmentSessionStatus,
|
|
17
|
+
AssignmentSessionSummary,
|
|
18
|
+
MaterializedAssignment,
|
|
19
|
+
MaterializedPlan,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class _Session:
|
|
25
|
+
id: str
|
|
26
|
+
request: AssignmentSessionRequest
|
|
27
|
+
plan_fingerprint: str
|
|
28
|
+
status: AssignmentSessionStatus
|
|
29
|
+
created_at: datetime
|
|
30
|
+
updated_at: datetime
|
|
31
|
+
completed_at: datetime | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class _Item:
|
|
36
|
+
id: str
|
|
37
|
+
session_id: str
|
|
38
|
+
materialized: MaterializedAssignment
|
|
39
|
+
status: AssignmentItemStatus
|
|
40
|
+
attempt_count: int
|
|
41
|
+
max_attempts: int
|
|
42
|
+
created_at: datetime
|
|
43
|
+
updated_at: datetime
|
|
44
|
+
visible_at: datetime
|
|
45
|
+
lease_holder: str | None = None
|
|
46
|
+
lease_expires_at: datetime | None = None
|
|
47
|
+
conversation_id: str | None = None
|
|
48
|
+
runtime_execution_id: str | None = None
|
|
49
|
+
output: object = None
|
|
50
|
+
output_kind: str | None = None
|
|
51
|
+
output_kind_version: int | None = None
|
|
52
|
+
error: AssignmentError | None = None
|
|
53
|
+
started_at: datetime | None = None
|
|
54
|
+
completed_at: datetime | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class _Attempt:
|
|
59
|
+
id: str
|
|
60
|
+
item_id: str
|
|
61
|
+
number: int
|
|
62
|
+
status: AssignmentAttemptStatus
|
|
63
|
+
holder: str
|
|
64
|
+
started_at: datetime
|
|
65
|
+
updated_at: datetime
|
|
66
|
+
completed_at: datetime | None = None
|
|
67
|
+
error: AssignmentError | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class InMemoryAssignmentStore:
|
|
71
|
+
"""Concurrency-safe reference store used by standalone hosts and tests."""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self._lock = asyncio.Lock()
|
|
75
|
+
self._sessions: dict[str, _Session] = {}
|
|
76
|
+
self._session_by_key: dict[str, str] = {}
|
|
77
|
+
self._items: dict[str, _Item] = {}
|
|
78
|
+
self._attempts: dict[str, _Attempt] = {}
|
|
79
|
+
|
|
80
|
+
async def create_or_get_session(
|
|
81
|
+
self, request: AssignmentSessionRequest, materialized: MaterializedPlan
|
|
82
|
+
) -> AssignmentSessionSummary:
|
|
83
|
+
async with self._lock:
|
|
84
|
+
existing_id = self._session_by_key.get(request.idempotency_key)
|
|
85
|
+
if existing_id is not None:
|
|
86
|
+
existing = self._sessions[existing_id]
|
|
87
|
+
if existing.plan_fingerprint != materialized.plan_fingerprint:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"idempotency key already belongs to a different assignment plan"
|
|
90
|
+
)
|
|
91
|
+
return self._summary(existing)
|
|
92
|
+
|
|
93
|
+
now = datetime.now(UTC)
|
|
94
|
+
session_id = str(uuid4())
|
|
95
|
+
session = _Session(
|
|
96
|
+
id=session_id,
|
|
97
|
+
request=request,
|
|
98
|
+
plan_fingerprint=materialized.plan_fingerprint,
|
|
99
|
+
status=AssignmentSessionStatus.PENDING,
|
|
100
|
+
created_at=now,
|
|
101
|
+
updated_at=now,
|
|
102
|
+
)
|
|
103
|
+
self._sessions[session_id] = session
|
|
104
|
+
self._session_by_key[request.idempotency_key] = session_id
|
|
105
|
+
for materialized_item in materialized.items:
|
|
106
|
+
item_id = str(
|
|
107
|
+
uuid5(
|
|
108
|
+
NAMESPACE_URL,
|
|
109
|
+
f"matrx-assignment:{session_id}:{materialized_item.ordinal}",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
self._items[item_id] = _Item(
|
|
113
|
+
id=item_id,
|
|
114
|
+
session_id=session_id,
|
|
115
|
+
materialized=materialized_item,
|
|
116
|
+
status=AssignmentItemStatus.PENDING,
|
|
117
|
+
attempt_count=0,
|
|
118
|
+
max_attempts=request.max_attempts,
|
|
119
|
+
created_at=now,
|
|
120
|
+
updated_at=now,
|
|
121
|
+
visible_at=now,
|
|
122
|
+
conversation_id=str(uuid5(NAMESPACE_URL, f"{item_id}:conversation")),
|
|
123
|
+
)
|
|
124
|
+
return self._summary(session)
|
|
125
|
+
|
|
126
|
+
async def get_session(self, session_id: str) -> AssignmentSessionSummary:
|
|
127
|
+
async with self._lock:
|
|
128
|
+
return self._summary(self._sessions[session_id])
|
|
129
|
+
|
|
130
|
+
async def list_items(self, session_id: str) -> list[AssignmentItemSnapshot]:
|
|
131
|
+
async with self._lock:
|
|
132
|
+
return [self._snapshot(item) for item in self._session_items(session_id)]
|
|
133
|
+
|
|
134
|
+
async def claim_items(
|
|
135
|
+
self,
|
|
136
|
+
session_id: str,
|
|
137
|
+
*,
|
|
138
|
+
holder: str,
|
|
139
|
+
limit: int,
|
|
140
|
+
lease_seconds: int,
|
|
141
|
+
) -> list[AssignmentClaim]:
|
|
142
|
+
async with self._lock:
|
|
143
|
+
now = datetime.now(UTC)
|
|
144
|
+
candidates = [
|
|
145
|
+
item
|
|
146
|
+
for item in self._session_items(session_id)
|
|
147
|
+
if item.status
|
|
148
|
+
in {AssignmentItemStatus.PENDING, AssignmentItemStatus.RETRYABLE_FAILED}
|
|
149
|
+
and item.visible_at <= now
|
|
150
|
+
][:limit]
|
|
151
|
+
claims: list[AssignmentClaim] = []
|
|
152
|
+
for item in candidates:
|
|
153
|
+
item.attempt_count += 1
|
|
154
|
+
item.status = AssignmentItemStatus.LEASED
|
|
155
|
+
item.lease_holder = holder
|
|
156
|
+
item.lease_expires_at = now + timedelta(seconds=lease_seconds)
|
|
157
|
+
item.updated_at = now
|
|
158
|
+
attempt_id = str(uuid5(NAMESPACE_URL, f"{item.id}:attempt:{item.attempt_count}"))
|
|
159
|
+
self._attempts[attempt_id] = _Attempt(
|
|
160
|
+
id=attempt_id,
|
|
161
|
+
item_id=item.id,
|
|
162
|
+
number=item.attempt_count,
|
|
163
|
+
status=AssignmentAttemptStatus.LEASED,
|
|
164
|
+
holder=holder,
|
|
165
|
+
started_at=now,
|
|
166
|
+
updated_at=now,
|
|
167
|
+
)
|
|
168
|
+
claims.append(
|
|
169
|
+
AssignmentClaim(
|
|
170
|
+
attempt_id=attempt_id,
|
|
171
|
+
attempt_number=item.attempt_count,
|
|
172
|
+
holder=holder,
|
|
173
|
+
item=self._snapshot(item),
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
if candidates:
|
|
177
|
+
session = self._sessions[session_id]
|
|
178
|
+
session.status = AssignmentSessionStatus.RUNNING
|
|
179
|
+
session.updated_at = now
|
|
180
|
+
return claims
|
|
181
|
+
|
|
182
|
+
async def mark_running(self, claim: AssignmentClaim) -> None:
|
|
183
|
+
async with self._lock:
|
|
184
|
+
item = self._owned_item(claim)
|
|
185
|
+
now = datetime.now(UTC)
|
|
186
|
+
item.status = AssignmentItemStatus.RUNNING
|
|
187
|
+
item.started_at = item.started_at or now
|
|
188
|
+
item.updated_at = now
|
|
189
|
+
attempt = self._attempts[claim.attempt_id]
|
|
190
|
+
attempt.status = AssignmentAttemptStatus.RUNNING
|
|
191
|
+
attempt.updated_at = now
|
|
192
|
+
|
|
193
|
+
async def renew_claim(self, claim: AssignmentClaim, *, lease_seconds: int) -> bool:
|
|
194
|
+
async with self._lock:
|
|
195
|
+
item = self._items.get(claim.item.id)
|
|
196
|
+
if item is None or item.lease_holder != claim.holder:
|
|
197
|
+
return False
|
|
198
|
+
if item.status not in {AssignmentItemStatus.LEASED, AssignmentItemStatus.RUNNING}:
|
|
199
|
+
return False
|
|
200
|
+
now = datetime.now(UTC)
|
|
201
|
+
item.lease_expires_at = now + timedelta(seconds=lease_seconds)
|
|
202
|
+
item.updated_at = now
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
async def complete_item(
|
|
206
|
+
self, claim: AssignmentClaim, output: AssignmentExecutionOutput
|
|
207
|
+
) -> None:
|
|
208
|
+
async with self._lock:
|
|
209
|
+
if self._items[claim.item.id].status is AssignmentItemStatus.CANCELLED:
|
|
210
|
+
return
|
|
211
|
+
item = self._owned_item(claim)
|
|
212
|
+
now = datetime.now(UTC)
|
|
213
|
+
item.status = AssignmentItemStatus.COMPLETED
|
|
214
|
+
item.output = output.value
|
|
215
|
+
item.output_kind = output.kind
|
|
216
|
+
item.output_kind_version = output.kind_version
|
|
217
|
+
item.conversation_id = output.conversation_id or item.conversation_id
|
|
218
|
+
item.runtime_execution_id = output.runtime_execution_id
|
|
219
|
+
item.error = None
|
|
220
|
+
item.lease_holder = None
|
|
221
|
+
item.lease_expires_at = None
|
|
222
|
+
item.completed_at = now
|
|
223
|
+
item.updated_at = now
|
|
224
|
+
attempt = self._attempts[claim.attempt_id]
|
|
225
|
+
attempt.status = AssignmentAttemptStatus.COMPLETED
|
|
226
|
+
attempt.completed_at = now
|
|
227
|
+
attempt.updated_at = now
|
|
228
|
+
|
|
229
|
+
async def fail_item(
|
|
230
|
+
self,
|
|
231
|
+
claim: AssignmentClaim,
|
|
232
|
+
error: AssignmentError,
|
|
233
|
+
*,
|
|
234
|
+
retry_at: datetime,
|
|
235
|
+
) -> None:
|
|
236
|
+
async with self._lock:
|
|
237
|
+
if self._items[claim.item.id].status is AssignmentItemStatus.CANCELLED:
|
|
238
|
+
return
|
|
239
|
+
item = self._owned_item(claim)
|
|
240
|
+
now = datetime.now(UTC)
|
|
241
|
+
item.error = error
|
|
242
|
+
item.lease_holder = None
|
|
243
|
+
item.lease_expires_at = None
|
|
244
|
+
item.visible_at = retry_at
|
|
245
|
+
item.updated_at = now
|
|
246
|
+
attempt = self._attempts[claim.attempt_id]
|
|
247
|
+
attempt.error = error
|
|
248
|
+
attempt.completed_at = now
|
|
249
|
+
attempt.updated_at = now
|
|
250
|
+
if error.retryable and item.attempt_count < item.max_attempts:
|
|
251
|
+
item.status = AssignmentItemStatus.RETRYABLE_FAILED
|
|
252
|
+
attempt.status = AssignmentAttemptStatus.RETRYABLE_FAILED
|
|
253
|
+
else:
|
|
254
|
+
item.status = AssignmentItemStatus.TERMINAL_FAILED
|
|
255
|
+
item.completed_at = now
|
|
256
|
+
attempt.status = AssignmentAttemptStatus.TERMINAL_FAILED
|
|
257
|
+
|
|
258
|
+
async def finalize_session(self, session_id: str) -> AssignmentSessionSummary:
|
|
259
|
+
async with self._lock:
|
|
260
|
+
session = self._sessions[session_id]
|
|
261
|
+
items = self._session_items(session_id)
|
|
262
|
+
statuses = {item.status for item in items}
|
|
263
|
+
now = datetime.now(UTC)
|
|
264
|
+
if session.status is AssignmentSessionStatus.CANCELLED:
|
|
265
|
+
session.status = AssignmentSessionStatus.CANCELLED
|
|
266
|
+
elif statuses <= {AssignmentItemStatus.COMPLETED}:
|
|
267
|
+
session.status = AssignmentSessionStatus.COMPLETED
|
|
268
|
+
elif statuses <= {AssignmentItemStatus.TERMINAL_FAILED}:
|
|
269
|
+
session.status = AssignmentSessionStatus.FAILED
|
|
270
|
+
elif statuses <= {
|
|
271
|
+
AssignmentItemStatus.COMPLETED,
|
|
272
|
+
AssignmentItemStatus.TERMINAL_FAILED,
|
|
273
|
+
AssignmentItemStatus.CANCELLED,
|
|
274
|
+
}:
|
|
275
|
+
session.status = AssignmentSessionStatus.PARTIALLY_FAILED
|
|
276
|
+
else:
|
|
277
|
+
session.status = AssignmentSessionStatus.RUNNING
|
|
278
|
+
if session.status in {
|
|
279
|
+
AssignmentSessionStatus.COMPLETED,
|
|
280
|
+
AssignmentSessionStatus.PARTIALLY_FAILED,
|
|
281
|
+
AssignmentSessionStatus.FAILED,
|
|
282
|
+
AssignmentSessionStatus.CANCELLED,
|
|
283
|
+
}:
|
|
284
|
+
session.completed_at = session.completed_at or now
|
|
285
|
+
session.updated_at = now
|
|
286
|
+
return self._summary(session)
|
|
287
|
+
|
|
288
|
+
async def reap_expired_claims(self, session_id: str, *, now: datetime) -> int:
|
|
289
|
+
async with self._lock:
|
|
290
|
+
count = 0
|
|
291
|
+
for item in self._session_items(session_id):
|
|
292
|
+
if item.status not in {AssignmentItemStatus.LEASED, AssignmentItemStatus.RUNNING}:
|
|
293
|
+
continue
|
|
294
|
+
if item.lease_expires_at is None or item.lease_expires_at >= now:
|
|
295
|
+
continue
|
|
296
|
+
error = AssignmentError(
|
|
297
|
+
code="assignment_lease_expired",
|
|
298
|
+
message="assignment worker lease expired before settlement",
|
|
299
|
+
retryable=item.attempt_count < item.max_attempts,
|
|
300
|
+
)
|
|
301
|
+
item.error = error
|
|
302
|
+
item.lease_holder = None
|
|
303
|
+
item.lease_expires_at = None
|
|
304
|
+
item.visible_at = now
|
|
305
|
+
item.updated_at = now
|
|
306
|
+
attempt_id = str(
|
|
307
|
+
uuid5(NAMESPACE_URL, f"{item.id}:attempt:{item.attempt_count}")
|
|
308
|
+
)
|
|
309
|
+
attempt = self._attempts[attempt_id]
|
|
310
|
+
attempt.status = AssignmentAttemptStatus.ABANDONED
|
|
311
|
+
attempt.error = error
|
|
312
|
+
attempt.completed_at = now
|
|
313
|
+
attempt.updated_at = now
|
|
314
|
+
if error.retryable:
|
|
315
|
+
item.status = AssignmentItemStatus.RETRYABLE_FAILED
|
|
316
|
+
else:
|
|
317
|
+
item.status = AssignmentItemStatus.TERMINAL_FAILED
|
|
318
|
+
item.completed_at = now
|
|
319
|
+
count += 1
|
|
320
|
+
return count
|
|
321
|
+
|
|
322
|
+
async def cancel_session(self, session_id: str) -> AssignmentSessionSummary:
|
|
323
|
+
async with self._lock:
|
|
324
|
+
now = datetime.now(UTC)
|
|
325
|
+
session = self._sessions[session_id]
|
|
326
|
+
session.status = AssignmentSessionStatus.CANCELLED
|
|
327
|
+
session.updated_at = now
|
|
328
|
+
session.completed_at = now
|
|
329
|
+
for item in self._session_items(session_id):
|
|
330
|
+
if item.status is AssignmentItemStatus.COMPLETED:
|
|
331
|
+
continue
|
|
332
|
+
item.status = AssignmentItemStatus.CANCELLED
|
|
333
|
+
item.lease_holder = None
|
|
334
|
+
item.lease_expires_at = None
|
|
335
|
+
item.updated_at = now
|
|
336
|
+
item.completed_at = now
|
|
337
|
+
if item.attempt_count > 0:
|
|
338
|
+
attempt_id = str(
|
|
339
|
+
uuid5(NAMESPACE_URL, f"{item.id}:attempt:{item.attempt_count}")
|
|
340
|
+
)
|
|
341
|
+
attempt = self._attempts.get(attempt_id)
|
|
342
|
+
if attempt is not None:
|
|
343
|
+
attempt.status = AssignmentAttemptStatus.CANCELLED
|
|
344
|
+
attempt.updated_at = now
|
|
345
|
+
attempt.completed_at = now
|
|
346
|
+
return self._summary(session)
|
|
347
|
+
|
|
348
|
+
def _owned_item(self, claim: AssignmentClaim) -> _Item:
|
|
349
|
+
item = self._items[claim.item.id]
|
|
350
|
+
if item.lease_holder != claim.holder:
|
|
351
|
+
raise RuntimeError("assignment claim ownership was lost")
|
|
352
|
+
if item.attempt_count != claim.attempt_number:
|
|
353
|
+
raise RuntimeError("assignment claim attempt is stale")
|
|
354
|
+
return item
|
|
355
|
+
|
|
356
|
+
def _session_items(self, session_id: str) -> list[_Item]:
|
|
357
|
+
return sorted(
|
|
358
|
+
(item for item in self._items.values() if item.session_id == session_id),
|
|
359
|
+
key=lambda item: item.materialized.ordinal,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def _summary(self, session: _Session) -> AssignmentSessionSummary:
|
|
363
|
+
items = self._session_items(session.id)
|
|
364
|
+
pending = sum(
|
|
365
|
+
item.status in {AssignmentItemStatus.PENDING, AssignmentItemStatus.RETRYABLE_FAILED}
|
|
366
|
+
for item in items
|
|
367
|
+
)
|
|
368
|
+
running = sum(
|
|
369
|
+
item.status in {AssignmentItemStatus.LEASED, AssignmentItemStatus.RUNNING}
|
|
370
|
+
for item in items
|
|
371
|
+
)
|
|
372
|
+
completed = sum(item.status is AssignmentItemStatus.COMPLETED for item in items)
|
|
373
|
+
failed = sum(item.status is AssignmentItemStatus.TERMINAL_FAILED for item in items)
|
|
374
|
+
cancelled = sum(item.status is AssignmentItemStatus.CANCELLED for item in items)
|
|
375
|
+
return AssignmentSessionSummary(
|
|
376
|
+
id=session.id,
|
|
377
|
+
idempotency_key=session.request.idempotency_key,
|
|
378
|
+
plan_fingerprint=session.plan_fingerprint,
|
|
379
|
+
status=session.status,
|
|
380
|
+
total_items=len(items),
|
|
381
|
+
pending_items=pending,
|
|
382
|
+
running_items=running,
|
|
383
|
+
completed_items=completed,
|
|
384
|
+
failed_items=failed,
|
|
385
|
+
cancelled_items=cancelled,
|
|
386
|
+
created_at=session.created_at,
|
|
387
|
+
updated_at=session.updated_at,
|
|
388
|
+
completed_at=session.completed_at,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
@staticmethod
|
|
392
|
+
def _snapshot(item: _Item) -> AssignmentItemSnapshot:
|
|
393
|
+
return AssignmentItemSnapshot(
|
|
394
|
+
id=item.id,
|
|
395
|
+
session_id=item.session_id,
|
|
396
|
+
ordinal=item.materialized.ordinal,
|
|
397
|
+
key=item.materialized.key,
|
|
398
|
+
values=item.materialized.values,
|
|
399
|
+
metadata=item.materialized.metadata,
|
|
400
|
+
fingerprint=item.materialized.fingerprint,
|
|
401
|
+
status=item.status,
|
|
402
|
+
attempt_count=item.attempt_count,
|
|
403
|
+
max_attempts=item.max_attempts,
|
|
404
|
+
lease_holder=item.lease_holder,
|
|
405
|
+
lease_expires_at=item.lease_expires_at,
|
|
406
|
+
conversation_id=item.conversation_id,
|
|
407
|
+
runtime_execution_id=item.runtime_execution_id,
|
|
408
|
+
output=item.output,
|
|
409
|
+
output_kind=item.output_kind,
|
|
410
|
+
output_kind_version=item.output_kind_version,
|
|
411
|
+
error=item.error,
|
|
412
|
+
created_at=item.created_at,
|
|
413
|
+
updated_at=item.updated_at,
|
|
414
|
+
started_at=item.started_at,
|
|
415
|
+
completed_at=item.completed_at,
|
|
416
|
+
)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Annotated, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AssignmentModel(BaseModel):
|
|
12
|
+
model_config = ConfigDict(extra="forbid", frozen=True, validate_default=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AssignmentSessionStatus(StrEnum):
|
|
16
|
+
PENDING = "pending"
|
|
17
|
+
RUNNING = "running"
|
|
18
|
+
COMPLETED = "completed"
|
|
19
|
+
PARTIALLY_FAILED = "partially_failed"
|
|
20
|
+
FAILED = "failed"
|
|
21
|
+
CANCELLED = "cancelled"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AssignmentItemStatus(StrEnum):
|
|
25
|
+
PENDING = "pending"
|
|
26
|
+
LEASED = "leased"
|
|
27
|
+
RUNNING = "running"
|
|
28
|
+
COMPLETED = "completed"
|
|
29
|
+
RETRYABLE_FAILED = "retryable_failed"
|
|
30
|
+
TERMINAL_FAILED = "terminal_failed"
|
|
31
|
+
CANCELLED = "cancelled"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AssignmentAttemptStatus(StrEnum):
|
|
35
|
+
LEASED = "leased"
|
|
36
|
+
RUNNING = "running"
|
|
37
|
+
COMPLETED = "completed"
|
|
38
|
+
RETRYABLE_FAILED = "retryable_failed"
|
|
39
|
+
TERMINAL_FAILED = "terminal_failed"
|
|
40
|
+
ABANDONED = "abandoned"
|
|
41
|
+
CANCELLED = "cancelled"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AssignmentOrder(StrEnum):
|
|
45
|
+
DECLARED = "declared"
|
|
46
|
+
RANDOM = "random"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AssignmentUniqueness(StrEnum):
|
|
50
|
+
ALLOW_REPEATS = "allow_repeats"
|
|
51
|
+
WITHOUT_REPLACEMENT = "without_replacement"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AssignmentRow(AssignmentModel):
|
|
55
|
+
key: str = Field(min_length=1, max_length=240)
|
|
56
|
+
values: dict[str, JsonValue] = Field(min_length=1)
|
|
57
|
+
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
@model_validator(mode="after")
|
|
60
|
+
def validate_variable_names(self) -> AssignmentRow:
|
|
61
|
+
if any(not name.strip() for name in self.values):
|
|
62
|
+
raise ValueError("assignment variable names must be non-empty")
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CoordinatedRowsPlan(AssignmentModel):
|
|
67
|
+
strategy: Literal["coordinated_rows"] = "coordinated_rows"
|
|
68
|
+
rows: list[AssignmentRow] = Field(min_length=1)
|
|
69
|
+
order: AssignmentOrder = AssignmentOrder.DECLARED
|
|
70
|
+
limit: int | None = Field(default=None, ge=1)
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="after")
|
|
73
|
+
def validate_rows(self) -> CoordinatedRowsPlan:
|
|
74
|
+
keys = [row.key for row in self.rows]
|
|
75
|
+
if len(keys) != len(set(keys)):
|
|
76
|
+
raise ValueError("coordinated row keys must be unique")
|
|
77
|
+
if self.limit is not None and self.limit > len(self.rows):
|
|
78
|
+
raise ValueError("limit cannot exceed the number of coordinated rows")
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class IndependentRandomPlan(AssignmentModel):
|
|
83
|
+
strategy: Literal["independent_random"] = "independent_random"
|
|
84
|
+
variables: dict[str, list[JsonValue]] = Field(min_length=1)
|
|
85
|
+
count: int = Field(ge=1, le=100_000)
|
|
86
|
+
uniqueness: AssignmentUniqueness = AssignmentUniqueness.ALLOW_REPEATS
|
|
87
|
+
|
|
88
|
+
@model_validator(mode="after")
|
|
89
|
+
def validate_options(self) -> IndependentRandomPlan:
|
|
90
|
+
_validate_option_map(self.variables)
|
|
91
|
+
if self.uniqueness is AssignmentUniqueness.WITHOUT_REPLACEMENT:
|
|
92
|
+
capacity = _combination_capacity(self.variables)
|
|
93
|
+
if self.count > capacity:
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"count={self.count} exceeds the {capacity} available combinations"
|
|
96
|
+
)
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CartesianPlan(AssignmentModel):
|
|
101
|
+
strategy: Literal["cartesian"] = "cartesian"
|
|
102
|
+
variables: dict[str, list[JsonValue]] = Field(min_length=1)
|
|
103
|
+
order: AssignmentOrder = AssignmentOrder.DECLARED
|
|
104
|
+
limit: int | None = Field(default=None, ge=1, le=100_000)
|
|
105
|
+
|
|
106
|
+
@model_validator(mode="after")
|
|
107
|
+
def validate_options(self) -> CartesianPlan:
|
|
108
|
+
_validate_option_map(self.variables)
|
|
109
|
+
capacity = _combination_capacity(self.variables)
|
|
110
|
+
if capacity > 100_000 and self.limit is None:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"cartesian plans over 100,000 combinations require an explicit limit"
|
|
113
|
+
)
|
|
114
|
+
if self.limit is not None and self.limit > capacity:
|
|
115
|
+
raise ValueError("limit cannot exceed the cartesian combination count")
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
AssignmentPlan = Annotated[
|
|
120
|
+
CoordinatedRowsPlan | IndependentRandomPlan | CartesianPlan,
|
|
121
|
+
Field(discriminator="strategy"),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AssignmentSource(AssignmentModel):
|
|
126
|
+
kind: Literal["api", "workflow", "scheduler", "internal"]
|
|
127
|
+
execution_id: str | None = Field(default=None, min_length=1, max_length=200)
|
|
128
|
+
workflow_run_id: str | None = Field(default=None, min_length=1, max_length=200)
|
|
129
|
+
workflow_node_id: str | None = Field(default=None, min_length=1, max_length=200)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AssignmentSessionRequest(AssignmentModel):
|
|
133
|
+
idempotency_key: str = Field(min_length=1, max_length=500)
|
|
134
|
+
plan: AssignmentPlan
|
|
135
|
+
source: AssignmentSource
|
|
136
|
+
max_attempts: int = Field(default=3, ge=1, le=20)
|
|
137
|
+
lease_seconds: int = Field(default=900, ge=30, le=86_400)
|
|
138
|
+
max_concurrency: int = Field(default=5, ge=1, le=100)
|
|
139
|
+
retry_delay_seconds: int = Field(default=0, ge=0, le=86_400)
|
|
140
|
+
result_kind: str | None = Field(default=None, min_length=1, max_length=240)
|
|
141
|
+
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class MaterializedAssignment(AssignmentModel):
|
|
145
|
+
ordinal: int = Field(ge=0)
|
|
146
|
+
key: str = Field(min_length=1, max_length=240)
|
|
147
|
+
values: dict[str, JsonValue] = Field(min_length=1)
|
|
148
|
+
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
|
149
|
+
fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class MaterializedPlan(AssignmentModel):
|
|
153
|
+
plan_fingerprint: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
154
|
+
items: list[MaterializedAssignment] = Field(min_length=1)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class AssignmentError(AssignmentModel):
|
|
158
|
+
code: str = Field(min_length=1, max_length=160)
|
|
159
|
+
message: str = Field(min_length=1)
|
|
160
|
+
retryable: bool
|
|
161
|
+
details: dict[str, JsonValue] = Field(default_factory=dict)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class AssignmentSessionSummary(AssignmentModel):
|
|
165
|
+
id: str
|
|
166
|
+
idempotency_key: str
|
|
167
|
+
plan_fingerprint: str
|
|
168
|
+
status: AssignmentSessionStatus
|
|
169
|
+
total_items: int = Field(ge=0)
|
|
170
|
+
pending_items: int = Field(ge=0)
|
|
171
|
+
running_items: int = Field(ge=0)
|
|
172
|
+
completed_items: int = Field(ge=0)
|
|
173
|
+
failed_items: int = Field(ge=0)
|
|
174
|
+
cancelled_items: int = Field(ge=0)
|
|
175
|
+
created_at: datetime
|
|
176
|
+
updated_at: datetime
|
|
177
|
+
completed_at: datetime | None = None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class AssignmentItemSnapshot(AssignmentModel):
|
|
181
|
+
id: str
|
|
182
|
+
session_id: str
|
|
183
|
+
ordinal: int = Field(ge=0)
|
|
184
|
+
key: str
|
|
185
|
+
values: dict[str, JsonValue]
|
|
186
|
+
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
|
187
|
+
fingerprint: str
|
|
188
|
+
status: AssignmentItemStatus
|
|
189
|
+
attempt_count: int = Field(ge=0)
|
|
190
|
+
max_attempts: int = Field(ge=1)
|
|
191
|
+
lease_holder: str | None = None
|
|
192
|
+
lease_expires_at: datetime | None = None
|
|
193
|
+
conversation_id: str | None = None
|
|
194
|
+
runtime_execution_id: str | None = None
|
|
195
|
+
output: JsonValue = None
|
|
196
|
+
output_kind: str | None = None
|
|
197
|
+
output_kind_version: int | None = Field(default=None, ge=1)
|
|
198
|
+
error: AssignmentError | None = None
|
|
199
|
+
created_at: datetime
|
|
200
|
+
updated_at: datetime
|
|
201
|
+
started_at: datetime | None = None
|
|
202
|
+
completed_at: datetime | None = None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class AssignmentClaim(AssignmentModel):
|
|
206
|
+
attempt_id: str
|
|
207
|
+
attempt_number: int = Field(ge=1)
|
|
208
|
+
holder: str = Field(min_length=1, max_length=240)
|
|
209
|
+
item: AssignmentItemSnapshot
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class AssignmentExecutionOutput(AssignmentModel):
|
|
213
|
+
value: JsonValue
|
|
214
|
+
kind: str | None = Field(default=None, min_length=1, max_length=240)
|
|
215
|
+
kind_version: int | None = Field(default=None, ge=1)
|
|
216
|
+
conversation_id: str | None = None
|
|
217
|
+
runtime_execution_id: str | None = None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class AssignmentItemResult(AssignmentModel):
|
|
221
|
+
id: str
|
|
222
|
+
ordinal: int
|
|
223
|
+
key: str
|
|
224
|
+
status: AssignmentItemStatus
|
|
225
|
+
values: dict[str, JsonValue]
|
|
226
|
+
output: JsonValue = None
|
|
227
|
+
output_kind: str | None = None
|
|
228
|
+
output_kind_version: int | None = None
|
|
229
|
+
conversation_id: str | None = None
|
|
230
|
+
runtime_execution_id: str | None = None
|
|
231
|
+
error: AssignmentError | None = None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class AssignmentBatchResult(AssignmentModel):
|
|
235
|
+
session: AssignmentSessionSummary
|
|
236
|
+
items: list[AssignmentItemResult]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _validate_option_map(variables: dict[str, list[JsonValue]]) -> None:
|
|
240
|
+
if any(not name.strip() for name in variables):
|
|
241
|
+
raise ValueError("assignment variable names must be non-empty")
|
|
242
|
+
empty = [name for name, options in variables.items() if not options]
|
|
243
|
+
if empty:
|
|
244
|
+
raise ValueError(f"assignment options cannot be empty: {empty}")
|
|
245
|
+
duplicated = [
|
|
246
|
+
name
|
|
247
|
+
for name, options in variables.items()
|
|
248
|
+
if len({_canonical_option(option) for option in options}) != len(options)
|
|
249
|
+
]
|
|
250
|
+
if duplicated:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
"assignment option lists cannot contain duplicate values because "
|
|
253
|
+
f"duplicates would bias selection: {duplicated}"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _combination_capacity(variables: dict[str, list[JsonValue]]) -> int:
|
|
258
|
+
capacity = 1
|
|
259
|
+
for options in variables.values():
|
|
260
|
+
capacity *= len(options)
|
|
261
|
+
return capacity
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _canonical_option(value: JsonValue) -> str:
|
|
265
|
+
return json.dumps(
|
|
266
|
+
value,
|
|
267
|
+
ensure_ascii=False,
|
|
268
|
+
sort_keys=True,
|
|
269
|
+
separators=(",", ":"),
|
|
270
|
+
allow_nan=False,
|
|
271
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import secrets
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import TypeVar
|
|
8
|
+
|
|
9
|
+
from pydantic import JsonValue, TypeAdapter
|
|
10
|
+
|
|
11
|
+
from matrx_assignment.models import (
|
|
12
|
+
AssignmentOrder,
|
|
13
|
+
AssignmentPlan,
|
|
14
|
+
AssignmentUniqueness,
|
|
15
|
+
CartesianPlan,
|
|
16
|
+
CoordinatedRowsPlan,
|
|
17
|
+
IndependentRandomPlan,
|
|
18
|
+
MaterializedAssignment,
|
|
19
|
+
MaterializedPlan,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_PLAN_ADAPTER = TypeAdapter(AssignmentPlan)
|
|
23
|
+
_T = TypeVar("_T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AssignmentPlanner:
|
|
27
|
+
"""Materialize a typed assignment plan exactly once."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, random_source: secrets.SystemRandom | None = None) -> None:
|
|
30
|
+
self._random = random_source or secrets.SystemRandom()
|
|
31
|
+
|
|
32
|
+
def materialize(self, plan: AssignmentPlan) -> MaterializedPlan:
|
|
33
|
+
validated = _PLAN_ADAPTER.validate_python(plan)
|
|
34
|
+
if isinstance(validated, CoordinatedRowsPlan):
|
|
35
|
+
rows = self._coordinated_rows(validated)
|
|
36
|
+
elif isinstance(validated, IndependentRandomPlan):
|
|
37
|
+
rows = self._independent_random(validated)
|
|
38
|
+
elif isinstance(validated, CartesianPlan):
|
|
39
|
+
rows = self._cartesian(validated)
|
|
40
|
+
else: # pragma: no cover - discriminated union is exhaustive
|
|
41
|
+
raise TypeError(f"unsupported assignment plan: {type(validated).__name__}")
|
|
42
|
+
|
|
43
|
+
items = [
|
|
44
|
+
MaterializedAssignment(
|
|
45
|
+
ordinal=ordinal,
|
|
46
|
+
key=key,
|
|
47
|
+
values=values,
|
|
48
|
+
metadata=metadata,
|
|
49
|
+
fingerprint=fingerprint_json({"key": key, "values": values, "metadata": metadata}),
|
|
50
|
+
)
|
|
51
|
+
for ordinal, (key, values, metadata) in enumerate(rows)
|
|
52
|
+
]
|
|
53
|
+
return MaterializedPlan(
|
|
54
|
+
plan_fingerprint=fingerprint_json(validated.model_dump(mode="json")),
|
|
55
|
+
items=items,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def _coordinated_rows(
|
|
59
|
+
self, plan: CoordinatedRowsPlan
|
|
60
|
+
) -> list[tuple[str, dict[str, JsonValue], dict[str, JsonValue]]]:
|
|
61
|
+
count = plan.limit or len(plan.rows)
|
|
62
|
+
indices = self._indices(len(plan.rows), count, plan.order)
|
|
63
|
+
return [
|
|
64
|
+
(plan.rows[index].key, dict(plan.rows[index].values), dict(plan.rows[index].metadata))
|
|
65
|
+
for index in indices
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
def _independent_random(
|
|
69
|
+
self, plan: IndependentRandomPlan
|
|
70
|
+
) -> list[tuple[str, dict[str, JsonValue], dict[str, JsonValue]]]:
|
|
71
|
+
names = list(plan.variables)
|
|
72
|
+
lengths = [len(plan.variables[name]) for name in names]
|
|
73
|
+
capacity = _product(lengths)
|
|
74
|
+
if plan.uniqueness is AssignmentUniqueness.WITHOUT_REPLACEMENT:
|
|
75
|
+
combination_indices = self._random.sample(range(capacity), plan.count)
|
|
76
|
+
else:
|
|
77
|
+
combination_indices = [self._random.randrange(capacity) for _ in range(plan.count)]
|
|
78
|
+
return [
|
|
79
|
+
(
|
|
80
|
+
f"random-{ordinal + 1:06d}",
|
|
81
|
+
_combination_at(index, names=names, variables=plan.variables, lengths=lengths),
|
|
82
|
+
{},
|
|
83
|
+
)
|
|
84
|
+
for ordinal, index in enumerate(combination_indices)
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
def _cartesian(
|
|
88
|
+
self, plan: CartesianPlan
|
|
89
|
+
) -> list[tuple[str, dict[str, JsonValue], dict[str, JsonValue]]]:
|
|
90
|
+
names = list(plan.variables)
|
|
91
|
+
lengths = [len(plan.variables[name]) for name in names]
|
|
92
|
+
capacity = _product(lengths)
|
|
93
|
+
count = plan.limit or capacity
|
|
94
|
+
indices = self._indices(capacity, count, plan.order)
|
|
95
|
+
return [
|
|
96
|
+
(
|
|
97
|
+
f"combination-{index + 1:06d}",
|
|
98
|
+
_combination_at(index, names=names, variables=plan.variables, lengths=lengths),
|
|
99
|
+
{},
|
|
100
|
+
)
|
|
101
|
+
for index in indices
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
def _indices(self, capacity: int, count: int, order: AssignmentOrder) -> Sequence[int]:
|
|
105
|
+
if order is AssignmentOrder.DECLARED:
|
|
106
|
+
return range(count)
|
|
107
|
+
return self._random.sample(range(capacity), count)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fingerprint_json(value: JsonValue | dict[str, JsonValue]) -> str:
|
|
111
|
+
encoded = json.dumps(
|
|
112
|
+
value,
|
|
113
|
+
ensure_ascii=False,
|
|
114
|
+
sort_keys=True,
|
|
115
|
+
separators=(",", ":"),
|
|
116
|
+
allow_nan=False,
|
|
117
|
+
).encode("utf-8")
|
|
118
|
+
return hashlib.sha256(encoded).hexdigest()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _product(values: list[int]) -> int:
|
|
122
|
+
result = 1
|
|
123
|
+
for value in values:
|
|
124
|
+
result *= value
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _combination_at(
|
|
129
|
+
index: int,
|
|
130
|
+
*,
|
|
131
|
+
names: list[str],
|
|
132
|
+
variables: dict[str, list[JsonValue]],
|
|
133
|
+
lengths: list[int],
|
|
134
|
+
) -> dict[str, JsonValue]:
|
|
135
|
+
values: dict[str, JsonValue] = {}
|
|
136
|
+
remainder = index
|
|
137
|
+
positions = [0] * len(names)
|
|
138
|
+
for position in range(len(names) - 1, -1, -1):
|
|
139
|
+
remainder, option_index = divmod(remainder, lengths[position])
|
|
140
|
+
positions[position] = option_index
|
|
141
|
+
for name, option_index in zip(names, positions, strict=True):
|
|
142
|
+
values[name] = variables[name][option_index]
|
|
143
|
+
return values
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from matrx_assignment.models import (
|
|
7
|
+
AssignmentClaim,
|
|
8
|
+
AssignmentError,
|
|
9
|
+
AssignmentExecutionOutput,
|
|
10
|
+
AssignmentItemSnapshot,
|
|
11
|
+
AssignmentSessionRequest,
|
|
12
|
+
AssignmentSessionSummary,
|
|
13
|
+
MaterializedPlan,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AssignmentStore(Protocol):
|
|
18
|
+
async def create_or_get_session(
|
|
19
|
+
self, request: AssignmentSessionRequest, materialized: MaterializedPlan
|
|
20
|
+
) -> AssignmentSessionSummary: ...
|
|
21
|
+
|
|
22
|
+
async def get_session(self, session_id: str) -> AssignmentSessionSummary: ...
|
|
23
|
+
|
|
24
|
+
async def list_items(self, session_id: str) -> list[AssignmentItemSnapshot]: ...
|
|
25
|
+
|
|
26
|
+
async def claim_items(
|
|
27
|
+
self,
|
|
28
|
+
session_id: str,
|
|
29
|
+
*,
|
|
30
|
+
holder: str,
|
|
31
|
+
limit: int,
|
|
32
|
+
lease_seconds: int,
|
|
33
|
+
) -> list[AssignmentClaim]: ...
|
|
34
|
+
|
|
35
|
+
async def mark_running(self, claim: AssignmentClaim) -> None: ...
|
|
36
|
+
|
|
37
|
+
async def renew_claim(
|
|
38
|
+
self, claim: AssignmentClaim, *, lease_seconds: int
|
|
39
|
+
) -> bool: ...
|
|
40
|
+
|
|
41
|
+
async def complete_item(
|
|
42
|
+
self, claim: AssignmentClaim, output: AssignmentExecutionOutput
|
|
43
|
+
) -> None: ...
|
|
44
|
+
|
|
45
|
+
async def fail_item(
|
|
46
|
+
self,
|
|
47
|
+
claim: AssignmentClaim,
|
|
48
|
+
error: AssignmentError,
|
|
49
|
+
*,
|
|
50
|
+
retry_at: datetime,
|
|
51
|
+
) -> None: ...
|
|
52
|
+
|
|
53
|
+
async def finalize_session(self, session_id: str) -> AssignmentSessionSummary: ...
|
|
54
|
+
|
|
55
|
+
async def reap_expired_claims(self, session_id: str, *, now: datetime) -> int: ...
|
|
56
|
+
|
|
57
|
+
async def cancel_session(self, session_id: str) -> AssignmentSessionSummary: ...
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: matrx-assignment
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed planning and durable coordination for resolved variable assignments
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Requires-Dist: pydantic>=2.12
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest-asyncio>=0.25.0; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# matrx-assignment
|
|
14
|
+
|
|
15
|
+
Typed, payload-agnostic assignment planning and durable coordination.
|
|
16
|
+
|
|
17
|
+
The library turns a strict plan into immutable work items, then drives those
|
|
18
|
+
items through an injected store and executor. Hosts can begin with coordinated
|
|
19
|
+
rows (for example, 50 paired `topic` + `research` inputs) and later add policies
|
|
20
|
+
without changing the agent or workflow execution systems.
|
|
21
|
+
|
|
22
|
+
Supported plans:
|
|
23
|
+
|
|
24
|
+
- `coordinated_rows`: preserve paired values and run every row once.
|
|
25
|
+
- `independent_random`: choose every variable independently with unbiased system
|
|
26
|
+
randomness, optionally without repeating a full combination.
|
|
27
|
+
- `cartesian`: enumerate combinations, optionally in unbiased random order.
|
|
28
|
+
|
|
29
|
+
Random plans are materialized once and persisted by the host. Recovery therefore
|
|
30
|
+
resumes the same items instead of producing a new draw.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
matrx_assignment/__init__.py,sha256=UnSMFrwWX1hxBoB2p5U7p7PTfGbgg1d4Dqw4XN030j8,1598
|
|
2
|
+
matrx_assignment/coordinator.py,sha256=hCRDI7K_R_uWXx30IdSO-B5Jp2l-n3fBiGpDwsV9s8w,6314
|
|
3
|
+
matrx_assignment/in_memory.py,sha256=zEs6VjcBDwuifpntUMx75ZvAVQMUN_POyFrpYO2tUog,16688
|
|
4
|
+
matrx_assignment/models.py,sha256=MOW7xvmwVZWB39NfkYq32ieb49-hPLyFd7UFRtz6F28,9054
|
|
5
|
+
matrx_assignment/planner.py,sha256=zR-93MJHfXVxMh1Lzbm4M5rscJFSrZDv0UHzMGSPZPw,4895
|
|
6
|
+
matrx_assignment/store.py,sha256=r15d_NsMAbmggfathy0iVHXT9wh1RbHlUrNwFCoopSk,1584
|
|
7
|
+
matrx_assignment-0.1.0.dist-info/METADATA,sha256=bxVi90AtbfAugtadlpZEoSMfpGB5ke-MjIrTXt6PQIM,1232
|
|
8
|
+
matrx_assignment-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
matrx_assignment-0.1.0.dist-info/RECORD,,
|