Skip to content

[e2e] Add a checkpoint-recovery test for restored Python state - #992

Open
weiqingy wants to merge 3 commits into
apache:mainfrom
weiqingy:836-impl
Open

[e2e] Add a checkpoint-recovery test for restored Python state#992
weiqingy wants to merge 3 commits into
apache:mainfrom
weiqingy:836-impl

Conversation

@weiqingy

@weiqingy weiqingy commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Linked issue: #836

Purpose of change

No test covers Python agent memory surviving the loss of a TaskManager process. Existing e2e suites run on MiniCluster, which never recreates the embedded Python interpreter, so a value Pemja cannot materialize into a JVM type fails only on a real restore. This test hard-kills a real TaskManager and asserts the tool-call context and a raw bytes value both survived.

Runtime flow

  1. The harness configures checkpointing and a restart strategy on a standalone cluster, stages this checkout's artifacts, and submits the job detached.
  2. The agent writes the blob to memory, bumps a process-local counter, and requests a chat. The mock model emits one tool call; the tool writes tool-entered and blocks on release.
  3. On tool-entered it waits for two further completed checkpoints, records the pid, resource id and restore count, SIGKILLs the TaskManager, starts a replacement, waits for a restore, then writes release.
  4. The re-executed tool returns its sentinel. The agent checks the restored blob and transcript against the counters, writes verdict.json, and raises on mismatch.

Key decisions

The source must be unbounded. A bounded one raises end-of-input right after the record, and ActionExecutionOperator.endInput() then holds the mailbox thread while the run is parked, so no checkpoint completes. Measured on the pinned stack: unbounded 16 completions in 15s, bounded 0.

Provenance rests on module-level counters outside Flink state, since anything checkpointed is restored with the payload and cannot tell a restored read from the reader's own write. Pemja's singleton interpreter keeps globals across an in-place task restart, so zero counters prove a different OS process.

counts.restored rather than numRestarts, which counts a restart that restored nothing.

Implementation Description

Behavioral contracts

  1. A pass requires a published verdict: "pass"; a run recording no assertion exits non-zero.
  2. The interval comes from the job's own coordinator config, not the harness's file.
  3. The payload sits in a checkpoint taken while parked: two completions past a park-time baseline.
  4. counts.restored rises above its pre-kill baseline.
  5. latest.restored is non-null. If 4 and 5 disagree the run fails rather than picking one.
  6. The process was replaced: pid and resource id both differ from pre-kill.
  7. The values came from another process: both counters are 0 where they are asserted.
  8. The blob is bytes or bytearray with the exact content; a list of the same ints fails.
  9. The kill/restart window fits the tool's deadline: a pre-flight check plus a per-wait clamp.
  10. The TaskManager ran this checkout: version 0.3.dev0, flink_agents.api under its path.
  11. Config edits are idempotent and reverted, leaving no duplicate top-level YAML key.
  12. The new workflow job never runs on a pull request.

Failure behavior

A missing handshake_dir or verdict_dir raises ValueError. Checkpointing off or a wrong interval records a FAIL and exits non-zero, and an infeasible budget exits 1.

An unreachable, slow, or wrongly shaped REST response never reads as a false condition. Requests are bounded by CURL_MAX_TIME; an empty body, bad JSON, a missing path, or a wrongly typed leaf all fail the read.

One path degrades deliberately. The framework absorbs a tool exception into a ... execute failed. response rather than failing the action, so the tool returns a sentinel the assertion requires. The agent publishes the verdict before raising AssertionError, so a failed run leaves a readable file.

Tests

Contract Tests
1 verdict only 7 assert_verdict, 3 print_summary
2 checkpointing 4 assert_checkpointing_enabled
3 two checkpoints none; see negative control
4 restored count none; see provenance control
5 restored non-null none; see provenance control
6 process replaced none
7 counters none; exercised via 8
8 blob predicate _blob_matches, 9 cases
9 budget 4 assert_handshake_budget, 3 charged_timeout, 1 handshake_budget_left
10 identity none
11 config idempotent 6 configure_flink / delete_config_key
12 not on a PR none
handshake, for 3 and 7 3 await_release

About 45 of the 78 bats tests pin harness plumbing the contracts above deliberately do not cover: REST reads, file waits, cluster and venv setup, and EXIT-trap cleanup.

The 78 bats and 12 Python tests run pre-merge; the cluster test does not, since its job is schedule and workflow_dispatch only. Fork runs are the only evidence, and the controls are what make the pass meaningful.

Happy path: 9/9 assertions, verdict pass, pid 4656 to 6034, counts.completed 1 to 3 while parked, both counters 0, blob back as bytes.

Negative control, release marker pre-created: failed at checkpoint-containment, completed stuck at 1.

Provenance control, kill skipped: failed at restore, counts.restored stayed 0, pid unchanged.

It does not reproduce the SIGSEGV that motivated the issue: the memory value validator now raises TypeError at set() before Pemja is reached, so that control no longer exists. Unit tests pin which types the validator admits; only this one shows an admitted type survives a restore.

Known limitations: timeouts are sized from observed runs, not proven bounds; and 11 pre-existing bats assertions are vacuous under bash 3.2 bodies, since errexit does not apply to [[ ]].

API

No public API change, and no product code touched. Six of the seven files are new; the seventh adds a job to the nightly workflow, leaving its existing job, triggers and paths filter untouched. Nothing changes for a caller who does nothing differently.

Documentation

  • doc-needed
  • doc-not-needed
  • doc-included

Was this patch authored or co-authored using generative AI tooling?

  • Yes
  • No

Generated-by: Claude Code 2.1.224

Adds the deterministic agent and job submitted by the standalone-cluster
checkpoint-recovery test, plus unit tests for its two pure predicates.

The agent drives the built-in chat-model tool-call flow with a mock model
that always emits a tool call, and blocks inside the tool on a filesystem
handshake. Blocking there holds the tool-call context in keyed state:
sensory memory is flushed to state when each action task completes and is
cleared only when the whole run finishes, so parking mid-run is what lets a
checkpoint capture it.

Whether a restored value came from the checkpoint or from a fresh write in
the same run cannot be answered from checkpointed state, since any marker
written to state is itself restored. The agent therefore counts writes in a
module-level variable. Pemja runs with ExecType.MULTI_THREAD and a process
singleton MainInterpreter, so that counter survives a task-level restart and
is reset only by process death.

A swallowed tool exception is invisible to the assertions, so the tool
returns a sentinel on release that the transcript check requires. The
sentinel shares no substring with the tool's own name, because the
framework's failure text embeds that name.

The modules are named to avoid two collection traps: *_example.py would be
auto-submitted by the example-submission suite, and *_test.py would be
collected by pytest.

Generated-by: Claude Code 2.1.224
Drives the recovery test: configures checkpointing, starts a standalone
cluster, submits the recovery job, waits until the agent run parks inside
the tool, confirms a checkpoint captured the parked state, kills the
TaskManager, restarts it, confirms the restore, releases the handshake and
asserts the job's verdict.

Checkpointing is not enabled anywhere in this repo's existing setup, so the
script appends the configuration itself. The append is idempotent because a
repeated flat key is a hard YAML parse failure that prevents the cluster from
starting, and install.sh reuses an already-extracted Flink home. The interval
is read back from the job's own checkpoint config rather than the cluster
config, which echoes unrecognized keys and so cannot show that a key took
effect.

Waits are deadline-based and each one distinguishes three outcomes: the probe
field never parsed, the probe parsed but the target did not, and the target
parsed with the condition false. Only the third is evidence about the
condition. The handshake budget measures elapsed time rather than summing
nominal timeouts, and each step draws the smaller of its ceiling and the time
actually left, so an unmodelled cost reduces later grants instead of
overrunning the tool's own deadline.

Verdicts come from the job's single verdict field, never re-derived here, and
waits match marker filenames exactly, since every file the job writes has a
transient .tmp twin that a crash can leave behind.

The bats suite exercises the sourceable functions: JSON extraction against
malformed and empty bodies, the three wait outcomes, config append and
revert, the cleanup decision table, and the budget arithmetic against the
configuration that failed it.

The Flink distribution is installed by sourcing install.sh's download and
extraction helpers rather than running install.sh as a whole program. The
whole-program path also installs a released flink-agents JAR, and no such JAR
is published for every Flink version this checkout can build against, so that
path fails outright on the version the nightly job pins. The test has to run
the artifacts built from this checkout regardless, so nothing is lost by
stopping before that step and installing the built wheel instead.

Generated-by: Claude Code 2.1.224
Adds a checkpoint-recovery job alongside the existing example-submission one.
It installs Flink, builds the checkout, and runs the recovery script, which
kills the TaskManager mid-run and asserts the agent's memory survived.

The job is guarded to scheduled and manual runs. The workflow's pull_request
trigger exists for the examples job, and extending it here would spend a
second full download-and-build on every PR matching those paths, with nothing
in this repository caching either. The accepted consequence is that this test
produces no pre-merge signal, so a path filter would buy nothing and none is
added.

The timeout is 75 minutes rather than the neighbouring job's 60. The recovery
sequence runs last, so a ceiling that truncates the run truncates the part
under test. The budget comment records where the time goes, including that the
Flink download alone can take about 60 of those minutes if every curl retry
runs to its deadline.

The job carries no model-server configuration: the payload answers from a
deterministic mock chat model rather than a real one, so a flaky or slow model
cannot turn a recovery failure into a green run.

Generated-by: Claude Code 2.1.224
@github-actions github-actions Bot added doc-not-needed Your PR changes do not impact docs fixVersion/0.4.0 priority/major Default priority of the PR or issue. labels Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc-not-needed Your PR changes do not impact docs fixVersion/0.4.0 priority/major Default priority of the PR or issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant