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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any