4141from __future__ import annotations
4242
4343import sys
44+ import threading
4445import time
4546from dataclasses import dataclass , field
4647from typing import Any , Callable , Protocol
@@ -140,6 +141,8 @@ def __init__(self, catalog: C.MethodCatalog, *,
140141 self .cache = cache
141142 self .executor = executor or E .InlineExecutor ()
142143 self .toolkit_versions = toolkit_versions or {}
144+ self ._futures : dict [str , Any ] = {}
145+ self ._futures_lock = threading .Lock ()
143146 self .counters = {'invoked' : 0 , 'cache_hit' : 0 , 'refused' : 0 , 'failed' : 0 ,
144147 'artifacts_registered' : 0 }
145148
@@ -211,18 +214,96 @@ def get_job(self, job_id: str) -> dict:
211214 return row
212215
213216 def cancel_job (self , job_id : str ) -> dict :
217+ with self ._futures_lock :
218+ future = self ._futures .get (job_id )
219+ if future is not None and not future .running () and not future .done ():
220+ future .cancel ()
214221 row = self .ledger .request_cancel (job_id ) if self .ledger is not None and \
215222 hasattr (self .ledger , 'request_cancel' ) else None
216223 if row is None :
217224 raise failures .DiracNotFound (
218225 f'no job { job_id !r} ' , details = {'job_id' : job_id })
219226 return row
220227
228+ def wait_job (self , job_id : str , * , timeout : float = 300.0 ,
229+ poll : float = 0.1 ) -> dict :
230+ """Wait for a handle without coupling the caller to executor internals."""
231+ deadline = time .time () + max (0.0 , float (timeout ))
232+ while True :
233+ row = self .get_job (job_id )
234+ if row .get ('state' ) in ('done' , 'failed' , 'cancelled' ):
235+ return row
236+ if time .time () >= deadline :
237+ row ['wait' ] = {'timed_out' : True , 'timeout_seconds' : float (timeout )}
238+ return row
239+ time .sleep (max (0.01 , float (poll )))
240+
241+ def submit (self , method_id : str , payload : dict , * ,
242+ inline_max : int | None = None ,
243+ budget_seconds : float | None = None ,
244+ request_id : str | None = None ) -> dict :
245+ """Create a reconnectable queued Job and execute it on the injected executor."""
246+ spec = self .catalog .get (method_id )
247+ self .catalog .validate (method_id , payload )
248+ spec .handler () # fail before minting a handle that can never run
249+ if 'job' not in spec .execution .get ('supported_modes' , []):
250+ raise failures .DiracUnsupported (
251+ f'{ method_id } does not declare job execution' ,
252+ details = {'method_id' : method_id ,
253+ 'supported_modes' : spec .execution .get ('supported_modes' , [])})
254+ if self .ledger is None :
255+ raise failures .DiracInternal (
256+ 'job submission requires a JobStore; this kernel has none' )
257+ if not getattr (self .executor , 'supports_submission' , False ):
258+ raise failures .DiracInternal (
259+ 'job submission requires an executor with submit(); '
260+ f'got { getattr (self .executor , "kind" , type (self .executor ).__name__ )} ' )
261+ budget = budget_seconds
262+ if budget is None :
263+ budget = float ((payload .get ('parameters' ) or {}).get ('max_seconds' )
264+ or spec .execution .get ('default_budget_seconds' ) or 0.0 ) or None
265+ job_id , conflicted = self ._open_job (spec , payload , budget , queued = True )
266+ if job_id is None :
267+ raise failures .DiracInternal (
268+ 'the JobStore could not create or resolve a durable job handle' )
269+ if not conflicted :
270+ future = self .executor .submit (
271+ self ._run_submitted , job_id , method_id , payload ,
272+ inline_max , budget , request_id )
273+ with self ._futures_lock :
274+ self ._futures [job_id ] = future
275+ future .add_done_callback (lambda _f , jid = job_id : self ._forget_future (jid ))
276+ return {
277+ 'ok' : True ,
278+ 'data' : {'job' : self .get_job (job_id )},
279+ 'artifacts' : [], 'warnings' : [],
280+ 'meta' : {'envelope' : 2 , 'method_id' : method_id ,
281+ 'version' : spec .version , 'job_id' : job_id ,
282+ 'execution_mode' : 'job' , 'deduplicated' : conflicted ,
283+ 'request_id' : request_id },
284+ }
285+
286+ def _run_submitted (self , job_id : str , method_id : str , payload : dict ,
287+ inline_max : int | None , budget_seconds : float | None ,
288+ request_id : str | None ) -> None :
289+ row = self .ledger .get (job_id )
290+ if row is None or row .get ('state' ) == 'cancelled' :
291+ return
292+ self .ledger .start (job_id )
293+ self .invoke (method_id , payload , inline_max = inline_max ,
294+ budget_seconds = budget_seconds , request_id = request_id ,
295+ _preopened_job_id = job_id )
296+
297+ def _forget_future (self , job_id : str ) -> None :
298+ with self ._futures_lock :
299+ self ._futures .pop (job_id , None )
300+
221301 # ── the write side ────────────────────────────────────────────────────────
222302 def invoke (self , method_id : str , payload : dict , * ,
223303 inline_max : int | None = None ,
224304 budget_seconds : float | None = None ,
225- request_id : str | None = None ) -> dict :
305+ request_id : str | None = None ,
306+ _preopened_job_id : str | None = None ) -> dict :
226307 """Run a method and return a v2 envelope. Never raises for a REFUSAL.
227308
228309 A refusal is a RESULT — the molecule is too large, the basis does not cover
@@ -237,7 +318,7 @@ def invoke(self, method_id: str, payload: dict, *,
237318 """
238319 t0 = time .time ()
239320 self .counters ['invoked' ] += 1
240- job_id = None
321+ job_id = _preopened_job_id
241322 spec = None
242323 try :
243324 spec = self .catalog .get (method_id )
@@ -265,12 +346,16 @@ def invoke(self, method_id: str, payload: dict, *,
265346 # the COMPUTED path while cache hits are the majority of responses. A rule
266347 # that applies to the minority of answers is not a rule.
267348 self .catalog .validate_output (method_id , hit .result )
268- return self ._envelope (spec , hit , t0 , cache = 'db' ,
269- inline_max = inline_max , request_id = request_id ,
270- job_id = None )
349+ env = self ._envelope (spec , hit , t0 , cache = 'db' ,
350+ inline_max = inline_max , request_id = request_id ,
351+ job_id = job_id )
352+ if job_id is not None and self .ledger is not None :
353+ self .ledger .done (job_id , seconds = round (time .time () - t0 , 3 ),
354+ result_summary = {'ok' : True , 'cache' : 'db' })
355+ return env
271356
272- if self .ledger is not None :
273- job_id = self ._open_job (spec , payload , budget )
357+ if self .ledger is not None and job_id is None :
358+ job_id , _ = self ._open_job (spec , payload , budget )
274359
275360 ctx = InvocationContext (
276361 method_id = method_id , version = spec .version , budget_seconds = budget ,
@@ -346,7 +431,7 @@ def invoke(self, method_id: str, payload: dict, *,
346431
347432 # ── internals ─────────────────────────────────────────────────────────────
348433 def _open_job (self , spec : C .MethodSpec , payload : dict ,
349- budget : float | None ) -> str | None :
434+ budget : float | None , * , queued : bool = False ) -> tuple [ str | None , bool ] :
350435 """Open a ledger row, and never let the ledger break the invocation.
351436
352437 A job row is OBSERVABILITY. If the database is down, the science must still
@@ -357,18 +442,18 @@ def _open_job(self, spec: C.MethodSpec, payload: dict,
357442 import hashlib
358443 import json as _json
359444 canonical = _json .dumps (payload , sort_keys = True , separators = (',' , ':' ))
360- job_id , _conflict = self .ledger .open (
445+ job_id , conflict = self .ledger .open (
361446 method_row_id = getattr (self .ledger , 'method_row_for' ,
362447 lambda _m : None )(spec .method_id ),
363448 input_sha256 = hashlib .sha256 (canonical .encode ()).digest (),
364449 params = dict (payload .get ('parameters' ) or {}),
365- budget_seconds = budget , queued = False )
366- return job_id
450+ budget_seconds = budget , queued = queued )
451+ return job_id , conflict
367452 except Exception as e : # noqa: BLE001
368453 print (f'[invoke] ledger unavailable ({ type (e ).__name__ } : { e } ) — running '
369454 f'anyway, and provenance will say job_id: null' , file = sys .stderr ,
370455 flush = True )
371- return None
456+ return None , False
372457
373458 def _require_declared_artifacts (self , spec : C .MethodSpec ,
374459 out : HandlerResult ) -> None :
0 commit comments