Skip to content

Commit c711a30

Browse files
committed
[feat.jobs.Omega.D8+] the kernel gets one durable JobStore contract, canonical request identity, honest cancellation, and replaceable execution
The v2 kernel had a ledger parameter but never received the database ledger, so every SDK and CLI result reported job_id null while physics kept an unrelated process-local queue. These files are one action because the schema, the two JobStore implementations, the executor seam, kernel wiring, v2 control routes, and lifecycle tests must change together or one transport can claim durability or cancellation the runtime does not have. request_digest replaces split input-plus-md5 identity; queued work is cancellable, while running work records intent without pretending Python interrupted native compute.
1 parent aec7686 commit c711a30

10 files changed

Lines changed: 572 additions & 31 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
-- 014 · app.job becomes the durable implementation of the public JobStore contract.
2+
--
3+
-- input_sha256 describes input bytes. It is not the identity of an invocation: basis,
4+
-- spin and other parameters can change the science while the molecule bytes remain the
5+
-- same. The old unique index compensated with md5(params::text), which is both split
6+
-- across two columns and weaker than every other content identity in this system.
7+
-- request_digest is the one canonical SHA-256 used for open, dedup and wait.
8+
--
9+
-- Cancellation is also separated from interruption. cancel_requested_at records what a
10+
-- caller asked; state='cancelled' records what the executor actually achieved. A running
11+
-- SCF that cannot be interrupted must never be relabelled cancelled merely because the
12+
-- browser stopped waiting.
13+
14+
BEGIN;
15+
16+
ALTER TABLE app.job
17+
ADD COLUMN request_digest bytea,
18+
ADD COLUMN durability text NOT NULL DEFAULT 'durable'
19+
CHECK (durability IN ('durable', 'process')),
20+
ADD COLUMN cancel_requested_at timestamptz,
21+
ADD COLUMN result_summary jsonb;
22+
23+
UPDATE app.job
24+
SET request_digest = digest(
25+
method_row_id::text || ':' || encode(input_sha256, 'hex') || ':' || params::text,
26+
'sha256');
27+
28+
ALTER TABLE app.job
29+
ALTER COLUMN request_digest SET NOT NULL,
30+
ADD CONSTRAINT job_request_digest_sha256
31+
CHECK (octet_length(request_digest) = 32);
32+
33+
DROP INDEX app.job_one_inflight;
34+
CREATE UNIQUE INDEX job_one_inflight
35+
ON app.job (method_row_id, request_digest)
36+
WHERE state IN ('queued', 'running');
37+
38+
CREATE OR REPLACE VIEW app.v_job_live AS
39+
SELECT j.id, m.method_id, m.version AS method_version, j.state,
40+
j.compound_id, j.budget_seconds, j.est_seconds,
41+
round(extract(epoch FROM now() - coalesce(j.started_at, j.created_at))::numeric, 1)
42+
AS age_seconds,
43+
j.worker, j.created_at, j.started_at,
44+
j.request_digest, j.durability, j.cancel_requested_at
45+
FROM app.job j JOIN meta.method m ON m.id = j.method_row_id
46+
WHERE j.state IN ('queued', 'running')
47+
ORDER BY j.created_at;
48+
49+
COMMENT ON COLUMN app.job.request_digest IS
50+
'Canonical SHA-256 of the complete scientific invocation identity; the sole in-flight dedup key beside method_row_id.';
51+
COMMENT ON COLUMN app.job.cancel_requested_at IS
52+
'A caller asked to cancel. This does not claim a running executor was interrupted.';
53+
COMMENT ON COLUMN app.job.result_summary IS
54+
'Small JSON summary only; result bytes remain first-class artifacts.';
55+
56+
INSERT INTO meta.migration (filename, sha256, content_sha256, hash_source)
57+
VALUES ('014_job_store_contract.sql',
58+
'\xcf7999ac21a5392f6ddcc12addeb654768133083ea8b8db96661ccebda0400d9'::bytea,
59+
'\xcf7999ac21a5392f6ddcc12addeb654768133083ea8b8db96661ccebda0400d9'::bytea, 'content')
60+
ON CONFLICT (filename) DO NOTHING;
61+
62+
COMMIT;

‎backend/execution.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Execution strategies for the invocation kernel.
2+
3+
An executor runs a callable; it never decides method semantics, cache identity, response
4+
shape, or error vocabulary. Keeping that boundary explicit is what lets an inline call
5+
become a worker/cluster submission without changing SDK, CLI, HTTP, or MCP adapters.
6+
"""
7+
from __future__ import annotations
8+
9+
from concurrent.futures import Future, ThreadPoolExecutor as _ThreadPool
10+
from typing import Any, Callable
11+
12+
13+
class InlineExecutor:
14+
kind = 'inline'
15+
supports_submission = False
16+
17+
def execute(self, fn: Callable[..., Any], *args, **kwargs) -> Any:
18+
return fn(*args, **kwargs)
19+
20+
21+
class ThreadExecutor:
22+
"""A bounded in-process executor for reconnectable job-mode work.
23+
24+
Cancellation is honest: a queued future may be cancelled; Python cannot interrupt a
25+
callable that is already running, so ``cancel`` returns false in that state.
26+
"""
27+
28+
kind = 'thread'
29+
supports_submission = True
30+
31+
def __init__(self, max_workers: int = 1) -> None:
32+
self._pool = _ThreadPool(max_workers=max_workers,
33+
thread_name_prefix='dirac-job')
34+
35+
def execute(self, fn: Callable[..., Any], *args, **kwargs) -> Any:
36+
return fn(*args, **kwargs)
37+
38+
def submit(self, fn: Callable[..., Any], *args, **kwargs) -> Future:
39+
return self._pool.submit(fn, *args, **kwargs)
40+
41+
@staticmethod
42+
def cancel(future: Future) -> bool:
43+
return future.cancel()
44+
45+
def shutdown(self, *, wait: bool = True) -> None:
46+
self._pool.shutdown(wait=wait)

‎backend/field_server.py‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,10 +310,38 @@ def handle_v2(method: str, path: str, body: dict | None) -> tuple[int, dict] | N
310310
"""
311311
if not path.startswith('/v2/'):
312312
return None
313-
q = path.split('?', 1)
314-
path = q[0]
313+
split = urllib.parse.urlsplit(path)
314+
path = split.path
315+
query = urllib.parse.parse_qs(split.query)
315316

316317
try:
318+
if method == 'GET' and path == '/v2/meta':
319+
svc = _kernel()
320+
return 200, {'ok': True,
321+
'data': {'api_version': '2.0.0',
322+
'supported_envelopes': [2],
323+
'capabilities': svc.capabilities()},
324+
'meta': {'envelope': 2}}
325+
326+
if method == 'GET' and path == '/v2/jobs':
327+
svc = _kernel()
328+
state = (query.get('state') or [None])[0]
329+
limit = int((query.get('limit') or ['100'])[0])
330+
return 200, {'ok': True,
331+
'data': {'jobs': svc.list_jobs(state=state, limit=limit)},
332+
'meta': {'envelope': 2,
333+
'durability': getattr(svc, 'job_durability', None)}}
334+
335+
if path.startswith('/v2/jobs/'):
336+
rest = urllib.parse.unquote(path[len('/v2/jobs/'):]).strip('/')
337+
if method == 'POST' and rest.endswith('/cancel'):
338+
job_id = rest[:-len('/cancel')].strip('/')
339+
return 200, {'ok': True, 'data': _kernel().cancel_job(job_id),
340+
'meta': {'envelope': 2}}
341+
if method == 'GET' and rest and '/' not in rest:
342+
return 200, {'ok': True, 'data': _kernel().get_job(rest),
343+
'meta': {'envelope': 2}}
344+
317345
if method == 'GET' and path == '/v2/methods':
318346
svc = _kernel()
319347
return 200, {'ok': True, 'data': {'methods': svc.list_methods()},
@@ -489,7 +517,8 @@ def db_init() -> bool:
489517
f'({len(_method_versions)} source digests)', file=sys.stderr, flush=True)
490518
global _jobs
491519
_jobs = jobs.JobLedger(
492-
_db, jobs.make_worker_name(os.getpid(), _producer_version()))
520+
_db, jobs.make_worker_name(os.getpid(), _producer_version()),
521+
method_rows=_method_ids)
493522
# A restart leaves rows 'running' that no process is doing. Without
494523
# the reap, v_job_live's age_seconds grows without bound and the
495524
# ledger reports work nobody is performing — worse than no ledger,

‎backend/invocation.py‎

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747

4848
import artifacts as A
4949
import catalog as C
50+
import execution as E
5051
import failures
5152

5253

@@ -131,11 +132,13 @@ def __init__(self, catalog: C.MethodCatalog, *,
131132
store: Any | None = None,
132133
ledger: Any | None = None,
133134
cache: Any | None = None,
135+
executor: Any | None = None,
134136
toolkit_versions: dict[str, str] | None = None) -> None:
135137
self.catalog = catalog
136138
self.store = store or A.MemoryArtifactStore()
137139
self.ledger = ledger
138140
self.cache = cache
141+
self.executor = executor or E.InlineExecutor()
139142
self.toolkit_versions = toolkit_versions or {}
140143
self.counters = {'invoked': 0, 'cache_hit': 0, 'refused': 0, 'failed': 0,
141144
'artifacts_registered': 0}
@@ -175,6 +178,46 @@ def estimate(self, method_id: str, payload: dict) -> dict:
175178
out['version'] = spec.version
176179
return out
177180

181+
# ── job control surface ─────────────────────────────────────────────────
182+
def capabilities(self) -> dict:
183+
return {
184+
'job_store': {
185+
'kind': getattr(self.ledger, 'kind', 'none') if self.ledger else 'none',
186+
'durability': (getattr(self.ledger, 'durability', 'none')
187+
if self.ledger else 'none'),
188+
},
189+
'artifact_store': {
190+
'kind': getattr(self, 'store_kind', type(self.store).__name__),
191+
'durability': ('durable' if getattr(self, 'store_kind', '') == 'postgres'
192+
else 'process'),
193+
},
194+
'executor': {'kind': getattr(self.executor, 'kind', 'unknown')},
195+
# Inline Python cannot interrupt C/Fortran work. Queued cancellation becomes
196+
# available when ThreadExecutor owns a submitted future.
197+
'cancellation': 'queued-only',
198+
}
199+
200+
def list_jobs(self, *, state: str | None = None, limit: int = 100) -> list[dict]:
201+
if self.ledger is None or not hasattr(self.ledger, 'list'):
202+
return []
203+
return self.ledger.list(state=state, limit=limit)
204+
205+
def get_job(self, job_id: str) -> dict:
206+
row = self.ledger.get(job_id) if self.ledger is not None and \
207+
hasattr(self.ledger, 'get') else None
208+
if row is None:
209+
raise failures.DiracNotFound(
210+
f'no job {job_id!r}', details={'job_id': job_id})
211+
return row
212+
213+
def cancel_job(self, job_id: str) -> dict:
214+
row = self.ledger.request_cancel(job_id) if self.ledger is not None and \
215+
hasattr(self.ledger, 'request_cancel') else None
216+
if row is None:
217+
raise failures.DiracNotFound(
218+
f'no job {job_id!r}', details={'job_id': job_id})
219+
return row
220+
178221
# ── the write side ────────────────────────────────────────────────────────
179222
def invoke(self, method_id: str, payload: dict, *,
180223
inline_max: int | None = None,
@@ -233,7 +276,7 @@ def invoke(self, method_id: str, payload: dict, *,
233276
method_id=method_id, version=spec.version, budget_seconds=budget,
234277
job_id=job_id, spec=spec,
235278
deadline=(t0 + budget) if budget else None)
236-
out = handler(payload, ctx)
279+
out = self.executor.execute(handler, payload, ctx)
237280
if not isinstance(out, HandlerResult):
238281
raise failures.DiracInternal(
239282
f'{method_id}: its handler returned {type(out).__name__}, not a '
@@ -259,7 +302,11 @@ def invoke(self, method_id: str, payload: dict, *,
259302
inline_max=inline_max, request_id=request_id,
260303
job_id=job_id)
261304
if job_id is not None and self.ledger is not None:
262-
self.ledger.done(job_id, seconds=round(time.time() - t0, 3))
305+
self.ledger.done(
306+
job_id, seconds=round(time.time() - t0, 3),
307+
result_summary={'ok': True,
308+
'result_keys': sorted(out.result),
309+
'artifact_roles': [role for role, _ in out.artifacts]})
263310
return env
264311

265312
except failures.DiracFailure as f:
@@ -314,7 +361,7 @@ def _open_job(self, spec: C.MethodSpec, payload: dict,
314361
method_row_id=getattr(self.ledger, 'method_row_for',
315362
lambda _m: None)(spec.method_id),
316363
input_sha256=hashlib.sha256(canonical.encode()).digest(),
317-
params={'method_id': spec.method_id},
364+
params=dict(payload.get('parameters') or {}),
318365
budget_seconds=budget, queued=False)
319366
return job_id
320367
except Exception as e: # noqa: BLE001

0 commit comments

Comments
 (0)