Spike T2 — engine event capture and golden fixtures (2026-10-28)¶
Question.
- Can a minimal Snakemake 9 logger plugin capture every event of the synthetic pipeline, a checkpoint, a failed job and a retry included? Yes.
- Does
-with-toweragainst a ~50-line capture server deliver the Tower protocol payloads on Nextflow 26.10.x and 25.10.x, with a failed task, a retry and-resume? Yes. - Can a first
nf-spectio(TraceObserverV2) be built and run in the time box, next tonf-towerin the same run, and do both report the same run (decision 0001)? Yes: built without Gradle in minutes; same run name and session ID in 6 of 6 runs, and the same task IDs. - Does
onTaskCompleteexposeevent.handler.task.outputs, and in which order doonProcessTerminateevents arrive (checking-scenarios.md§11.1, §11.5)? Outputs: yes, on both versions, also inonTaskCached. Order: in dataflow order, and the lastonTaskCompleteof a process arrives after that process'sonProcessTerminatein every run on both versions.
Answer in one line. All three captures work; 24 differences with the specs, listed below. The ones that change behaviour: end of stage (§11.5), the task status to read in nf-spectio, onFlowError after onFlowComplete, and the Snakemake exit code that is only in error text.
Time box: about 60 minutes of work, met. Nothing was edited in the specs: TRIAGE3 decides.
Setup¶
- Machine. Claude Code cloud session, Linux 6.18, OpenJDK 21. No Docker: every run used the
localexecutor. -
Engines, at the pins of
dev/versions.env, installed asdev/bootstrap.shdoes (download +sha256sum -c;uv pip sync --require-hashes dev/snakemake.lock.txt):spikes/engine-events/install.sh.- Nextflow 26.10.4 and 25.10.9.
nf-toweris downloaded from the registry on first use: 1.32.2 for 26.10.4 (its manifest saysPlugin-Requires: >=26.10.2), 1.17.8 for 25.10.9 (~/.nextflow/plugins/). - Snakemake 9.27.1, with
snakemake-interface-logger-plugins2.1.0 (the version in the lock file).
- Nextflow 26.10.4 and 25.10.9.
-
Pipelines.
tests/pipelines/synthetic/{snakemake,nextflow}with the flags ofrun_scenarios.py(chunks=2,--retries 2on Snakemake,-profile test,localon Nextflow). -
Runs, the same three on each engine version:
Run Fault Expected flakyALIGN / alignfor S07 fails at attempt 1 (both chunks)retried, run exit 0; the Snakemake run also crosses the splitcheckpointtoolerrCOMPARE / comparefor treatA exits 1run exit 1; 1 attempt on Nextflow, 3 on Snakemake (T1 finding 2) resume/rerunnone, same launch directory Nextflow -resume; Snakemake plain rerunAll nine runs ended with the expected exit code (
spikes/engine-events/LOG.md§2, §3, §5, branchspike/engine-eventsonly: of the spike, onlyfixtures/is meant to be integrated).
What was built (branch spike/engine-events, throwaway)¶
| Piece | Where | Notes |
|---|---|---|
Snakemake logger plugin spectio |
spikes/engine-events/snakemake/snakemake_logger_plugin_spectio/__init__.py |
Subclass of LogHandlerBase (snakemake_interface_logger_plugins/base.py, v2.1.0): emit plus the five abstract properties; has_filter = True, needs_rulegraph = True. Discovered by the module prefix snakemake_logger_plugin_ (common.py, registry/__init__.py of the same package) through PYTHONPATH: no install. Writes every LogRecord (message plus all extra= fields) as one JSON line. |
| Capture server | spikes/engine-events/capture_server.py (48 lines, stdlib) |
Logs method, path, body, user agent and whether an Authorization header was present (not its value). Answers POST /trace/create with {"workflowId": ...} and everything else with {}. |
nf-spectio 0.0.1 |
spikes/engine-events/nextflow/nf-spectio/ |
One Groovy file: SpectioPlugin (BasePlugin), SpectioFactory (TraceObserverFactoryV2), SpectioObserver (TraceObserverV2), one JSON POST per hook to $SPECTIO_ENDPOINT. The interface was read with javap from both -dist jars: identical method lists. build.sh compiles with the Groovy compiler inside the Nextflow jar and writes the unpacked layout of the registry-installed nf-tower (classes/META-INF/MANIFEST.MF, extensions.idx). -plugins nf-spectio@0.0.1 loads it from ~/.nextflow/plugins with no registry lookup. The same class files run on 26.10.4 and 25.10.9. |
Not done (out of the time box): a Gradle build with the Nextflow plugin Gradle plugin, a signed zip and publication to the registry; batching, retry and a local buffer; the failure modes of the Tower client (an error on create, begin or progress).
Results¶
Snakemake 9.27.1 (logger plugin)¶
- Event mix, flaky run (469 records):
job_info69,shellcmd69,job_finished67,progress67,job_started66,job_error2,error2,resources_info2,workflow_started,rulegraph,run_info1 each, and 122 untyped records (eventabsent): "Select jobs to execute..." 66, "DAG of jobs will be updated after completion." 12, "Updating checkpoint dependencies." 12, "Removing temporary output ..." (logged twice per file), "Trying to restart job N." and log-file paths. - Checkpoint.
run_infosays 32 jobs;progress.totalthen climbs 32 → 33 … 43 (one step persplitjob) → 67. No typed event marks the DAG update; the two text messages come fromjobs.py:1107anddag.py:2245(Snakemake 9.27.1 wheel).job_info.is_checkpointistruefor the 12splitjobs. - Retry. A retried job is re-announced with the same
jobid:job_started→job_info→job_error→ text "Trying to restart job 103." (scheduling/job_scheduler.py:566) →job_started→job_info→job_finished. No attempt number in any typed field. The synthetic Snakefile happens to expose one through its ownattemptresource (job_info.resources.attempt), which a real workflow will not have. - Failure.
job_errorcarriesrule_name,jobid,input,output,log,shellcmd,conda_env,container_img,aux(jobs.py:1129-1142), no exit code. The exit code is only in the text of the matchingerrorrecord: "CalledProcessError ... returned non-zero exit status 1." A fatal run ends with a lasterror: "WorkflowError: At least one job did not complete successfully." - Identity.
workflow_started.workflow_idis auuid.UUIDobject (workflow.py:303); the rerun gets a new one (6a5d370a…→88d1b16b…), as T1 found.workflow_startedalso carrieshost,user,cmd,cwd,rundirandplatform. - Plugin contract gaps. With
writes_to_file = True, Snakemake readshandler.baseFilename(logging.py:661-663), whichLogHandlerBasedoes not declare: the first run crashed withAttributeError.resourcesarrives as asnakemake.io.Namedlistand loses its names unless converted. The registry reads a settings class namedLogHandlerSettings(registry/__init__.py), while thesettings.pydocstring saysLoggerSettings. Records reach the handler through aQueueListenerthread (logging.py:673-679), assynthesis.md§2bis says.
Nextflow, Tower protocol (26.10.4 and 25.10.9)¶
- Routes. Every run:
POST /trace/create,PUT /trace/{id}/begin, 2 to 12PUT /trace/{id}/progress,PUT /trace/{id}/complete. The failed 25.10.9 run (toolerr) sentPUT /trace/{id}/completetwice: the Tower counterpart of its doubleonFlowComplete(below). No/heartbeat(no run lasted a minute) and noGET. User agentNextflow/<version>; the token goes in anAuthorizationheader. - create body:
instant,projectName,repository,runName,sessionId,workflowId(null withoutTOWER_WORKFLOW_ID). A reply holding onlyworkflowIdis enough. - begin body:
workflow(42 keys on 26.10.4, 41 on 25.10.9, which has noplatform; includingparams,configText,manifest,launchDir,workDir,userName,homeDir,commandLine),processNames,towerLaunch(false),instant, anddagon 26.10.4 only. - progress body:
tasks,progress(with a per-processesbreakdown),containers,instant. Task records have 33–53 keys on 26.10.4 and 26–46 on 25.10.9 (fewer on early records), among themscript(the task command) andenv. Tasks are sent fromNEWon: in the shipped fixturenextflow-26.10.4_tower_flaky.jsonl(analyse_tower.py), the flaky run sent 59NEW, 3SUBMITTED, 8RUNNING, 65COMPLETEDand 2FAILEDrecords for 67 task IDs. - Retry. The failed attempt is
FAILED,exit1,attempt1,errorActionRETRY; the retry is a newtaskIdwithattempt2.complete.progress:failed2,retries2,succeeded65. - Tool error. COMPARE treatA
FAILED,errorActionTERMINATE. On 26.10.4, FINAL_REPORT staysNEWin the last record. On 25.10.9, the last record of COMPARE treatB_vs_ctrl isABORTEDand FINAL_REPORT never appears. In both,complete.workflow.successfalse,errorMessage"synthetic failure: tool exited with code 1". - Resume.
begin.workflow.resumetrue, new run name, same session ID; tasks arrive asCACHED(63 on 26.10.4, 62 on 25.10.9: the count depends on timing, as in T1).
Nextflow, nf-spectio next to nf-tower¶
| Version | Run | Run name (both observers) | Session ID (both) | Task IDs Tower / plugin |
|---|---|---|---|---|
| 26.10.4 | flaky | intergalactic_montalcini |
d138a5c4-… |
67 / 67, equal |
| 26.10.4 | toolerr | soggy_payne |
38960569-… |
65 / 65, equal |
| 26.10.4 | resume | irreverent_yalow |
38960569-… |
65 / 65, equal |
| 25.10.9 | flaky | pedantic_gauss |
ea241900-… |
67 / 67, equal |
| 25.10.9 | toolerr | scruffy_hodgkin |
ac71c9a0-… |
64 / 64, equal |
| 25.10.9 | resume | fervent_mendel |
ac71c9a0-… |
65 / 65, equal |
- Hooks, flaky run on 26.10.4:
onFlowCreate, 8onProcessCreate,onFlowBegin, 67 each ofonTaskPending,onTaskSubmit,onTaskStart,onTaskComplete, 8onProcessTerminate, 19onFilePublish,onFlowComplete. NoonWorkflowOutput: the synthetic pipeline usespublishDir, not workflow outputs. event.handler.task.outputsinonTaskComplete(§11.1): present on both versions, and inonTaskCachedtoo. AMap<OutParam, Object>: keys print asfileoutparam<0:1>orvalueoutparam<0:0>; apathoutput has no name (namenull), avaloutput has its name (sample); values are absolute work-directory paths, a list when the output matches several files. Example, SPLIT S04:fileoutparam<0:1>→[<launch>/work/d5/f05437…/chunks/S04.chunk0000.txt, …chunk0001.txt].onProcessTerminateorder (§11.5): dataflow order, not declaration order, and not the same from run to run. 26.10.4 flaky: MAKE_READS, QC_STATS, SPLIT, QC_REPORT, ALIGN, MERGE, COMPARE, FINAL_REPORT; 26.10.4 toolerr: MAKE_READS, SPLIT, QC_STATS, ALIGN, QC_REPORT, MERGE, COMPARE.- The last task of a process completes after the process terminates. In all 6 runs, for 2 to 8 processes per run, one or two
onTaskCompleteevents of a process arrived after itsonProcessTerminate(analyse_plugin.py, "task events … AFTER its onProcessTerminate"). The plugin posts synchronously and numbers events in arrival order, so this is the order in which Nextflow called the observer. Why: probably the processor terminates on the dataflow thread once its outputs are bound, while the task monitor thread callsonTaskComplete[to be validated in the Nextflow source]. - Failed attempt.
handler.statusisCOMPLETED(the handler's lifecycle) whiletrace.statusisFAILED, withexitStatus1 andtask.errorActionRETRYorTERMINATE. On 25.10.9 oneonTaskCompletecame withhandler.statusRUNNING. - Run failure. 26.10.4:
onFlowComplete(success false, exit 1), thenonFlowError. 25.10.9:onFlowComplete,onFlowError,onFlowCompleteagain: twoonFlowComplete. In both, theTaskEventpassed toonFlowErrorhas no handler. Processes downstream of the failure never fireonProcessTerminate(7 of 8 on 26.10.4, 6 of 8 on 25.10.9), and on 26.10.4 FINAL_REPORT hadonTaskPendingand nothing after. - Resume. Cached tasks fire only
onTaskCached(no pending, submit or start), withhandler.statusNEWand their outputs.onFilePublishfires again for every published file of a cached task (19 events in the resume run). onFilePublish. One event per file:sourcein the work directory,targetunderresults/,labelsnull forpublishDir.
Evidence¶
Fixtures, branch spike/engine-events, spikes/engine-events/fixtures/ (15 files, 2.6 MB: 12 Nextflow, 3 Snakemake):
| Engine | Files |
|---|---|
| Snakemake 9.27.1 | snakemake-9.27.1_checkpoint_flaky_retry.jsonl, snakemake-9.27.1_tool_error.jsonl, snakemake-9.27.1_tool_error_rerun.jsonl |
| Nextflow 26.10.4 | nextflow-26.10.4_{tower,plugin}_{flaky,toolerr,resume}.jsonl |
| Nextflow 25.10.9 | nextflow-25.10.9_{tower,plugin}_{flaky,toolerr,resume}.jsonl |
- Normalisation (
spikes/engine-events/normalise.py, branchspike/engine-eventsonly): the launch directory, the repository root, the scratch directory and$HOMEbecome<launch>,<repo>,<scratch>,<home>; the Snakemakeplatformstring (kernel version) becomes<platform>(--token-key platform); the host name (whole word) becomes<host>, the user name in a path or command becomes<user>; the values ofuserName,user,hostname,homeDir,tokenand similar keys become<redacted>; the dummy token becomes<token>(it never appears: the server logs only that the header was there). Timestamps, IDs and run names are kept: the same-run check needs them. In the plugin fixtures, the trace record is dropped fromonTaskPending,onTaskSubmitandonTaskStart(--slim, size only: it is repeated ononTaskComplete). A grep for the host, user, token, home and scratch paths over the fixtures finds nothing. - Summaries:
analyse_snakemake.py,analyse_tower.py,analyse_plugin.py(branchspike/engine-eventsonly; the plugin one compares a plugin fixture with the Tower fixture of the same run). Their output is inLOG.md. - Commands and outputs:
spikes/engine-events/LOG.md(branchspike/engine-eventsonly).
Differences with the specs¶
Compared with synthesis.md §2, §2bis, §2ter and checking-scenarios.md §11.1, §11.5. Rows that matched are not listed (examples: the five Tower routes, the create fields, JOB_FINISHED with job_id only, --logger events from the main process, the QueueListener, is_checkpoint, no attempt number on Snakemake, attempt and new taskId on a Nextflow retry).
| # | Spec, section | Spec says | Observed | Spec impact |
|---|---|---|---|---|
| 1 | checking-scenarios §11.5 | "End of stage" means every matching process has fired onProcessTerminate |
The last 1–2 onTaskComplete of a process arrive after its onProcessTerminate, in all 6 runs, both versions |
§11.5: end of stage = every matching process terminated and every task of those processes has had its onTaskComplete (or onTaskCached); live checks on the last task must not be skipped |
| 2 | checking-scenarios §11.5 | (silent on failures) | After a fatal error, downstream processes never fire onProcessTerminate |
§11.5: a stage after a failure never "ends"; the gate logic needs the run's terminal state too |
| 3 | checking-scenarios §11.1 | event.handler.task.outputs "holds them" |
Present (both versions), also in onTaskCached; keys are OutParam objects; path outputs have no name; values are absolute work-dir paths, lists for multi-file outputs |
§11.1: match outputs by declared index (fileoutparam<0:1>) and glob on the file name, not by an output name; note onTaskCached as a second source on resume |
| 4 | checking-scenarios §11.1 | Snakemake JOB_INFO: jobs.py:1028-1045 at 1c67a66 |
At v9.27.1 the emission is Job.log_info, jobs.py:1060-1101; it also carries reason, resources, threads, priority, benchmark, shellcmd, is_checkpoint, is_handover |
§11.1: refresh the citation to the pinned version |
| 5 | synthesis §2 (Nextflow hooks) | Lists onFlowError as a hook |
onFlowError comes after onFlowComplete (26.10.4), and its TaskEvent has no handler |
§2 / nf-spectio design: do not treat onFlowComplete as the last event; wait for observer shutdown or a short drain |
| 6 | synthesis §2 | One onFlowComplete per run (implied) |
25.10.9, failed run: onFlowComplete twice, and the Tower client sent PUT /trace/{id}/complete twice in the same run |
nf-spectio must be idempotent on flow completion (api-and-data-model §8.2 lattice) |
| 7 | synthesis §2ter (Nextflow column, retries) | Status per task from the trace | In nf-spectio, handler.status is COMPLETED on a failed attempt (once RUNNING on 25.10.9); the failure is in trace.status FAILED, exitStatus, task.errorAction |
§2ter / nf-spectio: derive the attempt state from the trace record and errorAction, never from handler.status |
| 8 | synthesis §2ter (Nextflow, resume) | Cached tasks: onTaskCached |
Cached tasks fire only onTaskCached (no pending/submit/start), with outputs; onFilePublish fires again for every published file of a cached task |
§2ter: a resume republishes; the server must dedupe onFilePublish by target (and checks must not re-run on unchanged files) |
| 9 | synthesis §2ter (conditional execution) | A task filtered by when: produces no event |
Also: on a fatal error a task can get onTaskPending and nothing else (FINAL_REPORT, 26.10.4); Tower leaves it NEW. On 25.10.9 a running task ends ABORTED in Tower (COMPARE treatB_vs_ctrl) and FINAL_REPORT never appears |
§2ter: close tasks still pending at flow end (not_scheduled or cancelled, api-and-data-model §7.2) |
| 10 | synthesis §2bis (begin) | dag (new, optional) |
Present on 26.10.4, absent on 25.10.9 | §2bis: say which versions send it |
| 11 | synthesis §2bis (progress) | Tasks with "all trace record fields" | Includes script (the task command) and env; tasks are sent from NEW on |
§2bis note (Tower column only, decision 0001); for nf-spectio, decide whether script and env leave the cluster (secrets in commands) — security-reviewer |
| 12 | synthesis §2bis | nf-tower "shipped with Nextflow" |
Listed as a core plugin, but downloaded from the registry on first use into ~/.nextflow/plugins (1.32.2 for 26.10.4, 1.17.8 for 25.10.9) |
§2bis; matters for offline HPC (and for nf-spectio, which needs the same plugin cache) |
| 13 | synthesis §2bis (heartbeat, abort) | /heartbeat every minute; an error on create/begin/progress aborts |
Not observed: runs under a minute; the server never failed | No change; follow-up 4 |
| 14 | decision 0001 | Running nf-spectio next to nf-tower is [to be validated] |
Both observers in one run, 6 of 6 runs, same run name, session ID and task IDs | decision 0001 and synthesis §2bis: drop the mark, cite this note |
| 15 | synthesis §2 (Snakemake events) | WORKFLOW_STARTED, RUN_INFO, JOB_INFO, JOB_STARTED, JOB_FINISHED, JOB_ERROR, PROGRESS, ERROR, etc. |
Also SHELLCMD, RESOURCES_INFO, RULEGRAPH (with needs_rulegraph); LogEvent has 14 members (common.py); and 122 of 469 records have no event type |
§2: list the 14 members; the plugin keeps untyped records as text |
| 16 | synthesis §2ter (Snakemake, retries) | "Attempt number not sent; same jobid re-emitted after 'Trying to restart job N'" | Confirmed; the restart message is untyped text; JOB_ERROR is emitted once per attempt |
§2ter: count JOB_ERRORs per jobid to number attempts (no regex needed) |
| 17 | synthesis §2ter / extended data model (Attempt exit code) | Attempt has an exit code | Snakemake JOB_ERROR has no exit code; it is only in the ERROR record text ("returned non-zero exit status 1") |
§2ter: a versioned regex on the ERROR text, or the exit code stays unknown on Snakemake; data model: exit code nullable |
| 18 | synthesis §2ter (Snakemake, dynamic DAG) | No "DAG updated" event; the new total appears at the next PROGRESS |
Confirmed, plus two text messages per checkpoint job ("DAG of jobs will be updated after completion.", "Updating checkpoint dependencies."); total 32 → 67 in 12 steps | §2ter: the text messages can mark "DAG may change" for the "at least N" display |
| 19 | synthesis §2ter (temporary files) | Deletion is a text message "Removing temporary output" | Confirmed (dag.py:1055), and each one is logged twice |
§2ter: dedupe temp_removed |
| 20 | synthesis §2ter (Snakemake, executor) | Only local (true/false) |
Confirmed; JOB_STARTED carries jobs (job IDs) |
No change (groups untested) |
| 21 | synthesis §2bis (Snakemake plugin) | has_filter = True to see everything |
Also needed: baseFilename on any handler with writes_to_file = True (logging.py:661-663), not in LogHandlerBase |
§2bis / cli-and-shared-libraries.md: plugin contract note |
| 22 | synthesis §2bis (Snakemake plugin) | (silent) | resources is a Namedlist and workflow_id a uuid.UUID: naive JSON loses names or fails |
§2bis: the plugin serialises defensively ("read defensively" already in §2) |
| 23 | synthesis §2 / security | (silent) | WORKFLOW_STARTED carries host, user, cmd, cwd; Tower begin carries userName, homeDir, commandLine, configText |
Data-protection note for the ingest gateway (what is stored, what is redacted) — security-reviewer |
| 24 | interface docs | settings.py docstring: settings class LoggerSettings |
The registry reads LogHandlerSettings (registry/__init__.py, v2.1.0) |
None for Spectio; upstream docstring fix |
Count: 24 (rows 13 and 20 are recorded as "not observed / no change" but kept, because they were in the question).
What it changes (for TRIAGE3; no spec was edited)¶
checking-scenarios.md§11.5 (rows 1, 2) and §11.1 (rows 3, 4).synthesis.md§2 (rows 5, 6, 15), §2bis (rows 10–14, 21, 22), §2ter (rows 7–9, 16–20) and the extended data model (row 17).- Decision 0001: the coexistence mark can go (row 14).
api-and-data-model.md§7.2 / §8.2: pending tasks at flow end, a second flow completion, republished files (rows 6, 8, 9).- Security review of event content (rows 11, 23).
Follow-ups¶
- Source check of row 1 (why
onTaskCompletefollowsonProcessTerminate) inTaskProcessorandTaskPollingMonitorat v26.10.4 and v25.10.9. - Packaging
nf-spectio: Gradle build with the Nextflow plugin Gradle plugin, zip,Plugin-Requires, a private plugin index and the registry; offline install on HPC (row 12). - Transport: batching, retry and a local buffer in
nf-spectioand the logger (synthesis §2bis), then a run with the capture server down or slow: doesnf-spectioslow or abort the run? Same question fornf-tower(row 13). - Longer runs: a run over a minute for
/heartbeat;fanout_mediumfor batch sizes; a Snakemake job group forGROUP_INFO; a workflowoutput {}block foronWorkflowOutput. - Turn the fixtures into parser tests once the ingest code exists (
tests/layout ofdev-environment-and-testing.md§10), and decide whether 2.6 MB of fixtures go tomainor are cut down further. - Nextflow 25.04.8, the floor of the tested range:
TraceObserverV2was not checked there.
Open questions¶
- Should
nf-spectiosend the taskscriptandenvat all (row 11)? - Is end of stage on Nextflow "all processes terminated and all their tasks completed", or does Spectio wait for a short drain after the last
onProcessTerminate(row 1)?