feat(core): PymoniK rewrite - #30
AncientPatata wants to merge 24 commits into
Conversation
62c2af4 to
2a6c496
Compare
2a6c496 to
c5dc997
Compare
Lazy futures: the completion loop marks status only; result bytes download lazily on .result()/await, once and memoised. Pipelining downloads nothing — reading one terminal of an N-task DAG fetches one result, not N. Result reuse: structural (Merkle) cache keys — a Future arg contributes its upstream's key, so intermediate tasks are cacheable and an unchanged DAG prefix stays stable when a downstream task changes. Source-based fn_identity + cache_version override; client-side key->result_id index validated against the cluster (RESULT_ID + STATUS==COMPLETED). A hit reuses the existing result_id with no resubmission (cross-session verified on real ArmoniK); reused futures are named reused-<task_id>. cache_locally option plumbed; its wiring (Layer 3) is a documented TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pymonik CLI (main/doctor/replay/mcp) lives on ad/rewrite-extras now so this branch can focus on hardening the core runtime. Also drops the now-dangling `pymonik` console-script entry point; `pymonik-worker` stays. Nothing in the library imports pymonik.cli, so removal is clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…multi-result tasks
…ng results to minimize confusion between sync/async
Security reviewOne medium-confidence finding worth fixing before merge. Credential leak in worker logs —
|
aneojgurhem
left a comment
There was a problem hiding this comment.
Automated review from 10-angle multi-agent analysis (15 confirmed findings across 3 critical / 8 high / 4 medium).
| traceparent_carrier: dict[str, str] = {} | ||
| inject_context(traceparent_carrier) | ||
|
|
||
| envelope = TaskEnvelope( |
There was a problem hiding this comment.
_submit_tail omits ctx_param — context injection broken for tail-called tasks
This TaskEnvelope(...) call does not pass ctx_param, so it defaults to "". The worker dispatch code gates context injection on if envelope.ctx_param: — the condition is false, injection is skipped. A tail-called task that declares ctx: pymonik.Ctx is called without the argument → TypeError: <fn>() missing required argument: 'ctx', surfacing as TaskFailed on the parent future.
The main submit path in _internal/submit.py:280 correctly passes ctx_param=task.ctx_param or "".
Fix: add ctx_param=task.ctx_param or "", to the TaskEnvelope(...) call here.
| self._emit_opened() | ||
| return self | ||
|
|
||
| def __exit__(self, exc_type, exc, tb) -> None: |
There was a problem hiding this comment.
__exit__ sets _stop but never sets _result_events — deadlocks executor.shutdown(wait=True) when tasks have pending deps
Two-task DAG: task B depends on A's output. If an exception is raised in the with block before A finishes, __exit__ sets _stop and clears _pending, but does not set any _result_events. B's dispatcher thread is blocked at ev.wait() waiting for A's result event, which is never set. LocalCluster.__exit__ calls executor.shutdown(wait=True) → deadlock. Note: cancel() avoids this because it explicitly sets all events; a plain context-manager exception does not.
Fix: during teardown (before executor.shutdown), iterate _result_events and call .set() on each.
| out[idx] = fut | ||
| return FutureList(out) | ||
|
|
||
| def _cache_classify( |
There was a problem hiding this comment.
ℹ️ Medium: LocalCluster(cache=True) is a silent no-op — cache is never read or written
This method unconditionally returns ({}, list(range(len(normalised))), {}) — every call is a miss and the keys dict is always {}. Because fut._cache_key is never set, the put_bytes write path in _dispatch_result is also never triggered. The ExecCache object is constructed, passed in, and completely ignored. Running the same @task(cache=True) twice always re-executes.
Fix: implement this method using self._cache.get_bytes(key), mirroring Session._cache_classify.
| old_task_id=fut.task_id, | ||
| ) | ||
|
|
||
| def _run(): |
There was a problem hiding this comment.
If submit_many(...) raises (e.g., a partition validation error), this daemon thread dies silently. fut._done is never set. Any fut.result() or await fut hangs forever. The equivalent in session.py:385 wraps the call in try/except and resolves the future with an error on failure — that guard is absent here.
Fix: mirror session.py:385 — wrap the submit_many call in try/except Exception as e: and call fut._resolve_error(TaskFailed(fut.task_id, str(e))).
| if envelope.env_spec.deps and not envelope.env_spec.isolate: | ||
| venv_dir = ensure_env(envelope.env_spec) | ||
| site = str(venv_site_packages(venv_dir)) | ||
| if site not in _sys.path: |
There was a problem hiding this comment.
sys.path check-insert-remove is not locked — concurrent tasks with the same venv dep race to ModuleNotFoundError
if site not in _sys.path: _sys.path.insert(0, site) and the finally: _sys.path.remove(spliced_path) are unprotected. LocalCluster dispatches on a ThreadPoolExecutor. Two concurrent tasks with the same env_spec both pass the check before either inserts, both insert, and the first to finish removes the path — the second task's imports then fail with ModuleNotFoundError mid-execution. self._lock exists and is used elsewhere in this class, but not here.
Fix: acquire self._lock around the entire check-insert-execute-remove sequence, or use a reference-count so the path is only removed when the last holder is done.
|
|
||
| from pymonik.envelope import TaskEnvelope | ||
|
|
||
| envelope = TaskEnvelope( |
There was a problem hiding this comment.
LocalSession._submit_tail omits ctx_param — context injection broken for tail-called tasks (mirrors worker_session.py:205)
Same mechanism as worker_session.py:205: this TaskEnvelope(...) call does not pass ctx_param, defaulting it to "". The local dispatcher at line 1058 checks if envelope.ctx_param: — false, injection skipped. A tail-called task with ctx: Ctx raises TypeError during local test runs, masking the production bug.
Fix: add ctx_param=task.ctx_param or "", to the TaskEnvelope(...) call here.
lemaitre-aneo
left a comment
There was a problem hiding this comment.
I think the code could be refactored in many places to reduce the number of edge case handling.
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True, kw_only=True) | ||
| class TaskInfo: |
There was a problem hiding this comment.
Why don't you use the API type here?
| if self._error is not None: | ||
| raise self._error | ||
| return self._outcome # type: ignore[no-any-return] | ||
| with self._materialize_lock: |
There was a problem hiding this comment.
Do you really need the lock to be held during the download?
Motivation
PymoniK 0.1 proved out the @task decorator model under time pressure but is fragile under the hood: a schema-less magic-string argument protocol, four overlapping ways to set task options, a ~50-line
hand-rolled option merge, double-pickling via LazyArgs, a worker that re-uses the client class via is_worker=True and rebuilds a client per task, polling instead of event streams, and no async,
retries, OTel, local-test story, or typed errors.
This PR is the v1 clean-break rewrite specified: decorator-first ergonomics, typed end-to-end, a single typed wire envelope, first-class local execution, etc.
Description
Greenfield src/pymonik/ package replacing the v1 pymonik/ + pymonik_worker/ + test_client/ trees. Highlights:
retries, and result resolution. Sync + async surfaces (session()/session_async(), .result()/await).
field-driven .merge(); gather/as_completed (+ sync siblings).
(_internal/refs.py) replace 0.1's magic strings; oversize inline args auto-spill to content-addressed blobs.
Testing
multiple unit/local tests (tests/) against LocalCluster and stubbed armonik clients — no network. Strong coverage of TaskOpts.merge, env_id canonicalization, envelope round-trips, MultiResult AST extraction, map/starmap, lazy futures, multi-partition, hooks.
Impact
No direct impact on ArmoniK. Might need to make changes to the existing PymoniK partition.
Additional Information
None yet.
Checklist