33from __future__ import annotations
44
55import functools
6+ import inspect
67import logging
78import threading
89import uuid
2627LEASE_RETRY_MAX = 10
2728
2829
29- def with_idempotency (task_name : str ) -> Callable [[Callable [..., Any ]], Callable [..., Any ]]:
30+ def with_idempotency (
31+ task_name : str ,
32+ * ,
33+ on_poison : Optional [Callable [[str , dict ], None ]] = None ,
34+ ) -> Callable [[Callable [..., Any ]], Callable [..., Any ]]:
3035 """Short-circuit on completed key; gate concurrent runs via a lease.
3136
37+ The guard key is the caller's ``idempotency_key``, or one synthesized
38+ from ``source_id`` so a keyless dispatch is still poison-guarded.
39+
3240 Entry short-circuits:
3341 - completed row → return cached result
3442 - live lease held → retry(countdown=LEASE_TTL_SECONDS)
35- - attempt_count > MAX_TASK_ATTEMPTS → poison-loop alert
43+ - attempt_count > MAX_TASK_ATTEMPTS → poison alert; ``on_poison`` fires
3644 Success writes ``completed``; exceptions leave ``pending`` for
3745 autoretry until the poison-loop guard trips.
3846 """
3947
4048 def decorator (fn : Callable [..., Any ]) -> Callable [..., Any ]:
4149 @functools .wraps (fn )
4250 def wrapper (self , * args : Any , idempotency_key : Any = None , ** kwargs : Any ) -> Any :
43- key = idempotency_key if isinstance (idempotency_key , str ) and idempotency_key else None
51+ explicit_key = (
52+ idempotency_key
53+ if isinstance (idempotency_key , str ) and idempotency_key
54+ else None
55+ )
56+ # A keyless dispatch still gets the guard via a synthesized key;
57+ # None means no anchor exists — run unguarded, as before.
58+ key = explicit_key or _synthesize_guard_key (task_name , kwargs )
4459 if key is None :
4560 return fn (self , * args , idempotency_key = idempotency_key , ** kwargs )
4661
@@ -88,6 +103,9 @@ def wrapper(self, *args: Any, idempotency_key: Any = None, **kwargs: Any) -> Any
88103 "attempts" : attempt ,
89104 }
90105 _finalize (key , poisoned , status = "failed" )
106+ _run_poison_hook (
107+ on_poison , task_name , fn , self , args , kwargs , idempotency_key ,
108+ )
91109 return poisoned
92110
93111 heartbeat_thread , heartbeat_stop = _start_lease_heartbeat (
@@ -109,6 +127,45 @@ def wrapper(self, *args: Any, idempotency_key: Any = None, **kwargs: Any) -> Any
109127 return decorator
110128
111129
130+ def _synthesize_guard_key (task_name : str , kwargs : dict ) -> Optional [str ]:
131+ """Derive a deterministic guard key from ``source_id`` for a keyless dispatch.
132+
133+ ``source_id`` is stable across broker redeliveries and unique per
134+ upload, so the poison-loop counter survives an OOM SIGKILL. Returns
135+ ``None`` when absent — the dispatch then runs unguarded as before.
136+ """
137+ source_id = kwargs .get ("source_id" )
138+ if source_id :
139+ return f"auto:{ task_name } :{ source_id } "
140+ return None
141+
142+
143+ def _run_poison_hook (
144+ on_poison : Optional [Callable [[str , dict ], None ]],
145+ task_name : str ,
146+ fn : Callable [..., Any ],
147+ task_self : Any ,
148+ args : tuple ,
149+ kwargs : dict ,
150+ idempotency_key : Any ,
151+ ) -> None :
152+ """Invoke a task's poison-path hook with named call args; swallow failures.
153+
154+ A hook failure must never change the poison-guard outcome.
155+ """
156+ if on_poison is None :
157+ return
158+ try :
159+ bound = inspect .signature (fn ).bind_partial (
160+ task_self , * args , idempotency_key = idempotency_key , ** kwargs ,
161+ )
162+ on_poison (task_name , dict (bound .arguments ))
163+ except Exception :
164+ logger .exception (
165+ "idempotency: poison hook failed for task=%s" , task_name ,
166+ )
167+
168+
112169def _lookup_completed (key : str ) -> Any :
113170 """Return cached ``result_json`` if a completed row exists for ``key``, else None."""
114171 with db_readonly () as conn :
0 commit comments