wenjin272 commented on code in PR #992: URL: https://github.com/apache/flink-agents/pull/992#discussion_r3794819796
########## e2e-test/test-scripts/test_checkpoint_recovery.sh: ########## @@ -0,0 +1,1307 @@ +#!/usr/bin/env bash +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# +# Verifies that Python agent memory survives the loss of a TaskManager process. +# +# Submits checkpoint_recovery_job.py to a local Flink standalone cluster. The job +# parks itself inside a tool call that blocks on a file this script creates, so a +# completed checkpoint provably holds the agent's memory before anything is killed. +# The script then hard-kills the TaskManager, restarts it, waits for a real restore, +# releases the tool and reads the verdict the job publishes. +# +# Unlike its sibling test_submit_examples_to_flink.sh, a successful submission is +# NOT a pass: the only pass is a verdict file that says so. +# +# Env: FLINK_VERSION (default 2.3.0), FLINK_HOME (reuse existing install), +# VERBOSE=1 (set -x), plus the *_TIMEOUT overrides below. +# +# A FLINK_HOME passed in has to be that same FLINK_VERSION. The run copies +# opt/flink-python-<FLINK_VERSION>.jar into its lib/ and stops when that file is +# not there, so point FLINK_VERSION at whatever installation FLINK_HOME names. + +set -euo pipefail + +if [[ "${VERBOSE:-0}" == "1" ]]; then + set -x +fi + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { printf "${BLUE}[INFO]${NC} %s\n" "$*" >&2; } +log_ok() { printf "${GREEN}[OK]${NC} %s\n" "$*" >&2; } +log_warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*" >&2; } +log_error() { printf "${RED}[ERROR]${NC} %s\n" "$*" >&2; } +log_section() { + printf "\n${BLUE}==============================================================${NC}\n" >&2 + printf "${BLUE}>>> %s${NC}\n" "$*" >&2 + printf "${BLUE}==============================================================${NC}\n" >&2 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.."; pwd)" +log_info "Project root: $ROOT_DIR" + +FLINK_VERSION="${FLINK_VERSION:-2.3.0}" +FLINK_MAJOR_MINOR="${FLINK_VERSION%.*}" +REST_URL="${REST_URL:-http://localhost:8081}" + +JOB_MODULE="flink_agents/e2e_tests/e2e_tests_integration/checkpoint_recovery_job.py" +EXPECTED_AGENTS_VERSION="${EXPECTED_AGENTS_VERSION:-0.3.dev0}" + +# Checkpoint interval is deliberately short: the run is parked while we wait for two +# checkpoints to complete, and that wait is charged against the tool's own deadline. +# Held in milliseconds because that is the unit the checkpoint-config endpoint reports, +# so the value written and the value asserted are the same number. +CHECKPOINT_INTERVAL_MS="${CHECKPOINT_INTERVAL_MS:-5000}" +RESTART_ATTEMPTS="${RESTART_ATTEMPTS:-3}" +# Unset, this falls back to slot.request.timeout (5 min), after which a pending slot +# request is failed and one restart attempt is burned while the TM is being replaced. +STANDALONE_STARTUP_TIME="${STANDALONE_STARTUP_TIME:-600s}" + +# Budgets. Setup waits are unconstrained; the four marked ones run after the tool has +# parked and are therefore charged against the tool's own release deadline. They are +# clamped at runtime to the time actually remaining (see charged_timeout), so these +# numbers are ceilings rather than guarantees. +CLUSTER_TIMEOUT="${CLUSTER_TIMEOUT:-120}" +SUBMIT_TIMEOUT="${SUBMIT_TIMEOUT:-300}" +JOB_RUNNING_TIMEOUT="${JOB_RUNNING_TIMEOUT:-180}" +IDENTITY_TIMEOUT="${IDENTITY_TIMEOUT:-180}" +TOOL_ENTERED_TIMEOUT="${TOOL_ENTERED_TIMEOUT:-300}" +CHECKPOINT_TIMEOUT="${CHECKPOINT_TIMEOUT:-30}" # charged against the tool deadline +TM_GONE_TIMEOUT="${TM_GONE_TIMEOUT:-45}" # charged against the tool deadline +TM_UP_TIMEOUT="${TM_UP_TIMEOUT:-30}" # charged against the tool deadline +RESTORE_TIMEOUT="${RESTORE_TIMEOUT:-30}" # charged against the tool deadline +VERDICT_TIMEOUT="${VERDICT_TIMEOUT:-120}" +POLL_INTERVAL="${POLL_INTERVAL:-2}" + +# Every REST call is bounded. This is charged in the budget arithmetic, so keep the +# two in step: a healthy local JobManager answers in milliseconds, and a large value +# here buys nothing while inflating the worst-case overrun of every wait. +CURL_MAX_TIME="${CURL_MAX_TIME:-5}" + +# Wall-clock costs inside the charged window that belong to no single wait: the +# individual REST reads between the waits, two jps invocations, the SIGKILL grace and +# starting the replacement TaskManager. This is an estimate, and deliberately only +# feeds the pre-flight feasibility check — the runtime clamp is what actually holds +# the guarantee, so being wrong here cannot let the tool self-release. +HANDSHAKE_FIXED_COST_S="${HANDSHAKE_FIXED_COST_S:-45}" + +# Kept in hand so `release` is written comfortably before the tool gives up, covering +# the tool's own poll interval and the one-second granularity of SECONDS. +HANDSHAKE_SAFETY_S="${HANDSHAKE_SAFETY_S:-15}" + +# Bash 3 (default on macOS) lacks associative arrays. +RESULT_NAMES=() +RESULT_STATES=() +SUBMITTED_JOB_IDS=() +CONFIG_KEYS_SET=() +JOB_ID="" +WORK_DIR="" +HANDSHAKE_DIR="" +VERDICT_DIR="" +CHECKPOINT_DIR="" +INPUT_DIR="" +INPUT_FILE="" +FLINK_CONF="" +TM_PID_BEFORE="" +TM_RESOURCE_ID_BEFORE="" +RESTORED_BEFORE="" +RELEASE_DEADLINE_S="" +HANDSHAKE_DEADLINE_AT="" + +cleanup() { + local exit_code=$? + log_section "Cleanup" + + if [[ -n "${FLINK_HOME:-}" && -x "$FLINK_HOME/bin/flink" ]]; then + for jid in "${SUBMITTED_JOB_IDS[@]:-}"; do + [[ -n "$jid" ]] || continue + log_info "Cancelling job $jid" + "$FLINK_HOME/bin/flink" cancel "$jid" >/dev/null 2>&1 || true + done + + if [[ -x "$FLINK_HOME/bin/stop-cluster.sh" ]]; then + # If the TaskManager was killed and never replaced, this prints + # "No taskexecutor daemon (pid: N) is running anymore" into the archive. + # That line is expected here and is not the failure. + log_info "Stopping Flink cluster" + "$FLINK_HOME/bin/stop-cluster.sh" >/dev/null 2>&1 || true + fi + + # Archive the whole log directory, never named files: `kill -9` leaves the + # dead TaskManager's pid in the pid file, so the replacement writes to a + # higher log index and a named copy would miss the post-restore TaskManager. + if [[ -d "$FLINK_HOME/log" ]]; then + local log_archive="$ROOT_DIR/flink-logs-$(date +%Y%m%d-%H%M%S).tar.gz" + tar -czf "$log_archive" -C "$FLINK_HOME" log >/dev/null 2>&1 \ + && log_info "Flink logs archived to: $log_archive" \ + || log_warn "Failed to archive Flink logs" + fi + fi + + # Strip the keys we appended. A second run against a reused Flink home would + # otherwise append them twice, and a duplicate key is a hard YAML parse failure + # that stops the cluster from starting at all. + if [[ -n "$FLINK_CONF" && -f "$FLINK_CONF" ]]; then + local key + for key in "${CONFIG_KEYS_SET[@]:-}"; do + [[ -n "$key" ]] || continue + delete_config_key "$key" + done + log_info "Reverted ${#CONFIG_KEYS_SET[@]} appended config key(s)" + fi + + # The killed TaskManager's entry stays in /tmp/flink-*-taskexecutor.pid, which + # only nudges the next local run's log-file index upward. That file is shared by + # any Flink cluster this user runs, so it is left alone rather than risking a + # concurrent cluster's shutdown for a cosmetic gain. + + # The work directory holds the verdict file, the handshake markers and the + # checkpoints — everything needed to explain a failure. Remove it only when the + # run both recorded assertions and is exiting cleanly; otherwise keep it and say + # where it is. A non-zero exit code is its own reason to keep: the setup steps and + # several assertions abort under set -e without recording anything, so judging by + # the recorded results alone would delete the diagnostics for exactly those. + if [[ -n "$WORK_DIR" && -d "$WORK_DIR" ]]; then + local keep=0 state + for state in "${RESULT_STATES[@]:-}"; do + [[ "$state" == "FAIL" ]] && keep=1 + done + (( ${#RESULT_STATES[@]} == 0 )) && keep=1 + (( exit_code != 0 )) && keep=1 + if (( keep == 1 )); then + log_warn "Keeping diagnostics in $WORK_DIR (verdict, handshake markers, checkpoints)" + else + rm -rf "$WORK_DIR" + fi + fi + + print_summary + exit "$exit_code" +} +trap cleanup EXIT + +print_summary() { + log_section "Test summary" + local total=${#RESULT_NAMES[@]} + if (( total == 0 )); then + # Exit non-zero rather than return: a run that recorded no assertion verified + # nothing, and reporting that as success is the precise failure this whole + # script exists to make impossible. + log_error "No assertion was recorded, so nothing was verified" + exit 1 + fi + local passed=0 + local failed=0 + local i + for (( i = 0; i < total; i++ )); do + local name="${RESULT_NAMES[$i]}" + local state="${RESULT_STATES[$i]}" + if [[ "$state" == "PASS" ]]; then + printf " ${GREEN}PASS${NC} %s\n" "$name" + passed=$((passed + 1)) + else + printf " ${RED}FAIL${NC} %s\n" "$name" + failed=$((failed + 1)) + fi + done + printf "\nTotal: %d Passed: %d Failed: %d\n" "$total" "$passed" "$failed" + + if (( failed > 0 )); then + log_error "$failed assertion(s) failed" + exit 1 + fi +} + +record_result() { + RESULT_NAMES+=("$1") + RESULT_STATES+=("$2") +} + +# --------------------------------------------------------------------------- +# JSON extraction. python3 rather than jq, whose presence on the runner is not +# guaranteed. +# +# Exit 1 means "I could not read this": empty body, unparsable JSON, a path that +# does not exist, or a leaf of the wrong type. Callers MUST keep that distinct +# from "I read a value and did not like it yet" — reporting the first as the +# second turns a wrong field name into a phantom timeout. +# --------------------------------------------------------------------------- +json_query() { + local mode="$1" path="$2" + python3 -c ' +import json +import sys + +mode, path = sys.argv[1], sys.argv[2] +raw = sys.stdin.read() +if not raw.strip(): + sys.exit(1) +try: + doc = json.loads(raw) +except ValueError: + sys.exit(1) + +# The cluster config endpoint answers with a list of {"key":..,"value":..} pairs, +# and its keys contain dots, so it cannot go through the path walker below. +if mode == "conf": + if not isinstance(doc, list): + sys.exit(1) + for item in doc: + if isinstance(item, dict) and item.get("key") == path: + value = item.get("value") + print("" if value is None else value) + sys.exit(0) + sys.exit(1) + +node = doc +for part in path.split("."): + if isinstance(node, list) and part.isdigit() and int(part) < len(node): + node = node[int(part)] + elif isinstance(node, dict) and part in node: + node = node[part] + else: + sys.exit(1) + +if mode == "nullable": + # The key must be present; only its value may be null. A missing key is a + # read failure, so a misspelled field cannot masquerade as "not yet". + print("null" if node is None else "present") +elif mode == "len": + if not isinstance(node, list): + sys.exit(1) + print(len(node)) +elif mode == "int": + if isinstance(node, bool) or not isinstance(node, int): + sys.exit(1) + print(node) +elif mode == "str": + if not isinstance(node, str): + sys.exit(1) + print(node) +else: + sys.exit(2) +' "$mode" "$path" +} + +rest_get() { + curl -fsS --max-time "$CURL_MAX_TIME" "$REST_URL$1" 2>/dev/null +} + +rest_field() { + local path="$1" mode="$2" field="$3" + rest_get "$path" | json_query "$mode" "$field" +} + +# --------------------------------------------------------------------------- +# Compare an observed value against a spec: eq:V, ge:N, nonnull. +# +# Returns 0 on a match, 1 on a mismatch, and 2 when the comparison cannot be +# evaluated at all — an unknown operator, or a non-integer operand for ge. Callers +# must keep 2 apart from 1: the first means the spec is wrong, the second means the +# condition is not satisfied yet. +# --------------------------------------------------------------------------- +value_matches() { + local observed="$1" spec="$2" + local op="${spec%%:*}" want="${spec#*:}" + case "$op" in + eq) [[ "$observed" == "$want" ]] ;; + ge) + # (( )) evaluates its operands as shell arithmetic, so a non-numeric + # observation would be treated as a variable name and abort the script + # under set -u instead of simply not matching. Return 2 so the caller can + # tell "cannot compare" from "does not match". + if [[ ! "$observed" =~ ^-?[0-9]+$ ]] || [[ ! "$want" =~ ^-?[0-9]+$ ]]; then + log_error "value_matches: 'ge' needs two integers, got observed='$observed' want='$want'" + return 2 + fi + (( observed >= want )) + ;; + nonnull) [[ "$observed" == "present" ]] ;; + *) log_error "value_matches: unknown comparison '$spec'"; return 2 ;; + esac +} + +# --------------------------------------------------------------------------- +# Poll a REST field until it satisfies a spec. +# +# $1 label human name of the step, used in both failure messages +# $2 path REST path, e.g. /jobs/<id>/checkpoints +# $3 probe "mode:field" that must ALWAYS parse on a healthy endpoint +# $4 target "mode:field" being tested +# $5 spec comparison, see value_matches +# $6 timeout seconds +# +# The probe is what separates the two failure modes. If the probe never parses we +# are not talking to the endpoint we think we are; if the probe parses but the +# target never does, the target's field name is wrong (or this Flink build omits +# it) — neither is evidence that the condition was false. +# --------------------------------------------------------------------------- +wait_for_rest() { + local label="$1" path="$2" probe="$3" target="$4" spec="$5" timeout="$6" + local probe_mode="${probe%%:*}" probe_field="${probe#*:}" + local target_mode="${target%%:*}" target_field="${target#*:}" + + local probe_seen=0 target_seen=0 last="" + local body value rc + # Deadline, not accumulated sleeps: curl and the two python3 spawns per iteration + # are not free, so counting only the sleeps would let a nominal budget overrun by + # several times against a slow endpoint — and these budgets are what keep the + # recovery inside the tool's own release deadline. + local deadline=$((SECONDS + timeout)) + log_info "$label: polling GET $path for $target_field ($spec), budget ${timeout}s" + while (( SECONDS < deadline )); do + body=$(rest_get "$path") || body="" + if [[ -n "$body" ]]; then + if printf '%s' "$body" | json_query "$probe_mode" "$probe_field" >/dev/null; then + probe_seen=1 + fi + if value=$(printf '%s' "$body" | json_query "$target_mode" "$target_field"); then + target_seen=1 + last="$value" + rc=0 + value_matches "$value" "$spec" || rc=$? + if (( rc == 0 )); then + log_ok "$label: $target_field is $value" + return 0 + elif (( rc > 1 )); then + log_error "$label: comparison '$spec' cannot be evaluated against '$value'" + return 1 + fi + fi + fi + sleep "$POLL_INTERVAL" + done + + # Order matters: an observed target is reported even when the probe never parsed, + # because in that state the condition really was evaluated and really was false, + # and claiming otherwise would withhold the one value a reader needs. + if (( target_seen == 1 )); then + log_error "$label: '$target_field' from GET $REST_URL$path was last '$last' after ${timeout}s and never satisfied '$spec'." + if (( probe_seen == 0 )); then + log_warn "$label: the probe field '$probe_field' never parsed, so the response shape is only partly as expected." + fi + elif (( probe_seen == 1 )); then + log_error "$label: parsed '$probe_field' from GET $REST_URL$path, so the endpoint is right, but never parsed '$target_field' in ${timeout}s. Either the field name is wrong or this Flink build omits it. This is NOT evidence that the condition was false." + else + log_error "$label: never parsed '$probe_field' or '$target_field' from GET $REST_URL$path in ${timeout}s. The endpoint did not answer, or its response shape is not what this script expects. This is NOT evidence that the condition was false." + fi + return 1 +} + +# --------------------------------------------------------------------------- +# Wait for an exact filename. Never a glob: every file the job publishes is +# written to "<name>.tmp" and renamed, so a crash between the two steps leaves a +# permanent ".tmp" twin that a glob would happily read as the real thing. +# --------------------------------------------------------------------------- +wait_for_file() { + local label="$1" file="$2" timeout="$3" + local deadline=$((SECONDS + timeout)) + log_info "$label: waiting for $file, budget ${timeout}s" + while (( SECONDS < deadline )); do + if [[ -f "$file" ]]; then + log_ok "$label: $file appeared" + return 0 + fi + sleep "$POLL_INTERVAL" + done + local dir + dir="$(dirname "$file")" + log_error "$label: $file did not appear within ${timeout}s" + if [[ -f "$file.tmp" ]]; then + log_error "$label: found a leftover $file.tmp — the writer was interrupted between write and rename" + fi + if [[ -d "$dir" ]]; then + local listing + listing=$(find "$dir" -maxdepth 1 -mindepth 1 2>/dev/null | sed 's|.*/||' | sort | tr '\n' ' ') + log_info "$label: $dir currently holds: $listing" + else + log_error "$label: the directory $dir does not exist" + fi + return 1 +} + +install_flink_distribution() { + local install_dir="$1" + + # Reuse install.sh's download, archive validation and extraction logic, but + # stop before it installs a *released* Flink Agents JAR. That step derives its + # download URL from the Flink version, and no such artifact is published for + # every Flink version this test runs against — asking for one that does not + # exist would abort the install. This test must run the artifacts built from + # the current checkout anyway, which stage_dist_jars puts in place below. + FLINK_AGENTS_INSTALL_SH_NO_RUN=1 \ + FLINK_VERSION="$FLINK_VERSION" \ + INSTALL_FLINK=Yes \ + INSTALL_DIR="$install_dir" \ + NO_PROMPT=1 \ + bash -c ' + source "$1" + plan_flink + install_flink_if_needed + ' _ "$ROOT_DIR/tools/install.sh" +} + +# Replaces install.sh's setup_python_env, which would populate the venv with a +# released flink-agents. This creates a plain venv and nothing more; the packages +# arrive later, from tools/build.sh's `uv pip install dist/*.whl` and from +# install_built_python_package. +prepare_python_venv() { + if [[ -f "$VENV_DIR/pyvenv.cfg" && -x "$VENV_DIR/bin/python" ]]; then + # Reuse is unconditional on the interpreter: unlike install.sh, this accepts + # a venv built by a Python outside the range python/pyproject.toml declares. + log_info "Reusing Python venv: $VENV_DIR" + return + fi + if [[ -e "$VENV_DIR" ]] \ + && [[ ! -d "$VENV_DIR" \ + || -n "$(find "$VENV_DIR" -mindepth 1 -print -quit 2>/dev/null)" ]]; then + log_error "VENV_DIR is not an empty directory or a valid venv: $VENV_DIR" + return 1 + fi + + local python_bin="${PYTHON_BIN:-python3}" + log_info "Creating Python venv: $VENV_DIR" + "$python_bin" -m venv "$VENV_DIR" +} + +install_flink() { + log_section "Install Flink standalone (version $FLINK_VERSION)" + + # Anchor VENV_DIR to the repo so a fresh and a reused Flink installation share + # one Python environment, and so later steps can address it by path. + export VENV_DIR="${VENV_DIR:-$ROOT_DIR/.flink-agents-env}" + + if [[ -n "${FLINK_HOME:-}" && -x "$FLINK_HOME/bin/flink" ]]; then + log_info "Reusing existing FLINK_HOME: $FLINK_HOME" + export FLINK_HOME + else + local install_dir="${INSTALL_DIR:-$HOME/.local/flink}" + log_info "Installing the Flink distribution with tools/install.sh helpers" + install_flink_distribution "$install_dir" + export FLINK_HOME="${install_dir}/flink-${FLINK_VERSION}" + + if [[ ! -x "$FLINK_HOME/bin/flink" ]]; then + log_error "Flink installation not found at expected path: $FLINK_HOME" + exit 1 + fi + log_ok "Flink installed at: $FLINK_HOME" + fi + + # The distribution ships the PyFlink jar under opt/, outside the lib/ directory + # the cluster loads. install.sh copies it across as part of --enable-pyflink, + # which is no longer reached from here, so the copy happens here — on both branches + # above, since a reused FLINK_HOME need not have been prepared by install.sh. + local pyflink_jar="$FLINK_HOME/opt/flink-python-${FLINK_VERSION}.jar" + if [[ ! -f "$pyflink_jar" ]]; then + log_error "PyFlink JAR not found in Flink distribution: $pyflink_jar" + exit 1 + fi + cp "$pyflink_jar" "$FLINK_HOME/lib/" + + prepare_python_venv + + # Both branches above fall through to here, so the venv is activated whether + # FLINK_HOME was reused or freshly installed. That is load-bearing: the + # TaskManager daemon inherits this shell's PATH and resolves a bare `python` + # from it, and this script restarts the TaskManager mid-test — so an + # unactivated shell would give the replacement TaskManager a different + # interpreter from the original. Activation settles which interpreter that is + # and nothing more — what is installed in it is install_built_python_package's + # business, and that runs later. + if [[ ! -x "$VENV_DIR/bin/python" ]]; then + log_error "Expected Python venv not found at: $VENV_DIR" + exit 1 + fi + # shellcheck disable=SC1091 + source "$VENV_DIR/bin/activate" + log_ok "Activated Python venv: $VENV_DIR" + + FLINK_CONF="$FLINK_HOME/conf/config.yaml" + if [[ ! -f "$FLINK_CONF" ]]; then + log_error "Flink config not found: $FLINK_CONF" + exit 1 + fi +} + +# The wheel this run must exercise is the one tools/build.sh just produced, so it is +# installed from python/dist rather than from PyPI. apache-flink is pinned to the +# Flink version the cluster runs, because the submitting client and the TaskManager +# both execute this same interpreter. +install_built_python_package() { + local wheel + wheel=$(find "$ROOT_DIR/python/dist" -maxdepth 1 -name '*.whl' | head -n 1) + if [[ -z "$wheel" ]]; then + log_error "Python wheel not found after build in: $ROOT_DIR/python/dist" + return 1 + fi + + log_info "Installing $(basename "$wheel") and apache-flink==$FLINK_VERSION into $VENV_DIR" + "$VENV_DIR/bin/python" -m pip install --quiet \ + "$wheel" "apache-flink==$FLINK_VERSION" + + if ! "$VENV_DIR/bin/python" -c 'import flink_agents, pyflink' >/dev/null 2>&1; then + log_error "The built Flink Agents wheel or PyFlink is not importable from: $VENV_DIR/bin/python" + return 1 + fi + + export PYFLINK_CLIENT_EXECUTABLE="$VENV_DIR/bin/python" + log_ok "Installed the built wheel and PyFlink into: $VENV_DIR" +} + +build_project() { + log_section "Build flink-agents (Java + Python)" + ( + cd "$ROOT_DIR" + SKIP_SPOTLESS_CHECK=true bash tools/build.sh + ) + install_built_python_package + log_ok "Build completed" +} + +stage_dist_jars() { + log_section "Stage dist uber jar into \$FLINK_HOME/lib" + + local project_version + project_version=$(sed -n 's/.*<version>\(.*\)<\/version>.*/\1/p' \ + "$ROOT_DIR/pom.xml" | head -n 2 | tail -n 1) + log_info "Detected project version: $project_version" + + # The flink-version uber jar already bundles the common deps. + local flink_jar="$ROOT_DIR/dist/flink-${FLINK_MAJOR_MINOR}/target/flink-agents-dist-flink-${FLINK_MAJOR_MINOR}-${project_version}.jar" + + if [[ ! -f "$flink_jar" ]]; then + log_error "Flink dist jar not found: $flink_jar" + exit 1 + fi + + # Drop any flink-agents-dist jar already in lib/ before copying. A reused + # FLINK_HOME may still carry one — from an earlier tools/install.sh run, or from + # an earlier run of this test at a different project version — and a jar of a + # different version has a different filename, so the copy below would not + # replace it and both would sit on the classpath. + rm -f "$FLINK_HOME/lib/"/flink-agents-dist-*.jar + cp "$flink_jar" "$FLINK_HOME/lib/" + log_ok "Staged: $(basename "$flink_jar")" +} + +prepare_work_dirs() { + log_section "Create input, handshake, verdict and checkpoint directories" + WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/flink-agents-recovery.XXXXXX")" + HANDSHAKE_DIR="$WORK_DIR/handshake" + VERDICT_DIR="$WORK_DIR/verdict" + CHECKPOINT_DIR="$WORK_DIR/checkpoints" + INPUT_DIR="$WORK_DIR/input" + # The name is load-bearing. Flink's default file filter skips any name beginning + # with '.' or '_', and applies that even to a path the source was handed directly, + # which would leave the job running with nothing to read and nothing logged. + INPUT_FILE="$INPUT_DIR/trigger.txt" + # The job must never create these itself: a tool that provisions its own + # handshake directory cannot tell "the harness set me up" from "my path is wrong". + mkdir -p "$HANDSHAKE_DIR" "$VERDICT_DIR" "$CHECKPOINT_DIR" "$INPUT_DIR" + + # One line is all the source needs: the job builds the record from a constant it + # shares with the assertion and ignores this content. Written under a temporary + # name and renamed, so the file is whole the instant it appears under the name the + # source reads. Submission is several steps away, and that distance is what + # actually orders the two — the rename is redundancy, not the ordering mechanism. + printf 'trigger\n' > "$INPUT_FILE.tmp" + mv "$INPUT_FILE.tmp" "$INPUT_FILE" + # An empty file produces no record and no error. Nothing that depends on a record + # then runs, the agent's identity report included, so the run surfaces as the + # runtime-identity wait expiring with nothing pointing at the input. A wrong path + # needs no guard here: enumeration is eager, so the job fails within seconds with + # "Could not enumerate file splits". + if [[ ! -s "$INPUT_FILE" ]]; then + log_error "Trigger file is missing or empty: $INPUT_FILE" + return 1 + fi + + log_ok "Work directory: $WORK_DIR" +} + +# --------------------------------------------------------------------------- +# Config editing. Delete-then-append, so applying the same key twice is a no-op +# rather than a duplicate YAML key. +# +# A flat dotted key at column 0 is read correctly regardless of the nested blocks +# the shipped file uses, because Flink flattens the parsed document before +# building its Configuration. Appending (never prepending) also means our value +# wins any collision with a nested block, since the flattened map is populated in +# document order. +# --------------------------------------------------------------------------- +delete_config_key() { + local config_key="$1" + local tmp + tmp="$(mktemp)" + # grep -v exits 1 when it filters everything out, which set -e would treat as + # fatal. The dots in the key are regex "any char"; harmless for these keys. + grep -v "^${config_key}: " "$FLINK_CONF" > "$tmp" || true + mv "$tmp" "$FLINK_CONF" +} + +set_config_key() { + local config_key="$1" value="$2" + delete_config_key "$config_key" Review Comment: Could we preserve and restore the original `config.yaml`? When reusing `FLINK_HOME`, `set_config_key` removes pre-existing values, while cleanup only removes the test values, so the original configuration is lost. Backing up the file before modification and restoring it on `EXIT` would avoid mutating the reused installation. ########## python/flink_agents/e2e_tests/e2e_tests_integration/checkpoint_recovery_agent.py: ########## @@ -0,0 +1,447 @@ +################################################################################ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################# +"""Agent that parks a built-in tool-call round so a checkpoint can capture it. + +A deterministic mock chat model emits one tool call, and the tool then blocks on a +filesystem handshake, so the harness rather than the clock decides when the agent +run may finish. While the run is parked, the built-in tool-call context and a +``bytes`` short-term-memory value sit in checkpointable state; the harness kills and +restarts the TaskManager, and the assertions below then run in a TaskManager process +that never performed any of the writes. + +Submitted to a real cluster by ``checkpoint_recovery_job``. The module name is +deliberately neither ``*_test.py`` nor ``*_example.py``: the first would make pytest +collect it, the second would make the example-submission nightly submit it. +""" + +import json +import sys +import time +import uuid +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from typing import Any, Dict, List, Sequence + +from pydantic import BaseModel +from pyflink.datastream import KeySelector + +import flink_agents.api.memory_object as memory_object_module +from flink_agents.api.agents.agent import Agent +from flink_agents.api.agents.types import OutputSchema +from flink_agents.api.chat_message import ChatMessage, MessageRole +from flink_agents.api.chat_models.chat_model import ( + BaseChatModelConnection, + BaseChatModelSetup, +) +from flink_agents.api.decorators import ( + action, + chat_model_connection, + chat_model_setup, + prompt, + tool, +) +from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent +from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType +from flink_agents.api.prompts.prompt import Prompt +from flink_agents.api.resource import ResourceDescriptor +from flink_agents.api.runner_context import RunnerContext +from flink_agents.api.tools import InjectedArg +from flink_agents.api.tools.tool import Tool as BaseTool +from flink_agents.api.tools.tool import ToolType + +# Config keys the submitting job must set; a missing one fails the run loudly. +HANDSHAKE_DIR_CONFIG_KEY = "handshake_dir" +VERDICT_DIR_CONFIG_KEY = "verdict_dir" + +# Filenames exchanged with the harness. +TOOL_ENTERED_MARKER = "tool-entered" +RELEASE_MARKER = "release" +IDENTITY_MARKER = "runtime-identity.json" +VERDICT_MARKER = "verdict.json" + +RELEASE_TIMEOUT_S = 240.0 +POLL_INTERVAL_S = 0.2 + +# The user message content, also built into the input record by the job. +USER_CONTENT = "hold the tool call open across a taskmanager restart" + +BLOCKING_TOOL_NAME = "block_until_released" + +_ROUND_ONE_MARKER = "recovery-round-one-assistant" +_BLOB_MEMORY_KEY = "blob" + +# Returned by the tool only on the released path, and required in the transcript. +# The framework converts a tool exception into the response string +# "Tool `block_until_released` execute failed." rather than failing the action, so a +# timed-out handshake reaches the assertions only as the absence of this value. That +# makes the value load-bearing: it must not be a substring of that failure string, +# which rules out anything built from the tool's own name. +_TOOL_SENTINEL = "handshake-done-sentinel" + +# A NUL plus bytes that are not valid UTF-8, so a value handled as UTF-8 text anywhere +# on the path either raises or comes back corrupted. A single-byte codec such as +# latin-1 would round trip it intact, so this catches UTF-8 handling rather than every +# possible string conversion. +_KNOWN_BLOB = b"\x00\x01flink-agents\xff\xfe" + +# Process-local and deliberately outside Flink state. Anything checkpointed is +# restored along with the payload and therefore cannot tell a restored read apart +# from the reader's own write. +# +# Pemja runs as ExecType.MULTI_THREAD against a singleton main interpreter, so these +# globals outlive an in-place task restart and are reset only when the TaskManager +# process itself dies. A read that returns the known value while they are still zero +# therefore proves the write happened in a different TaskManager process, which is +# stronger than proving it happened in a different interpreter. +_PROCESS_EPOCH = uuid.uuid4().hex +_BLOB_WRITES_IN_THIS_PROCESS = 0 +_TOOL_CALLS_EMITTED_IN_THIS_PROCESS = 0 + + +def _atomic_write(target: Path, content: str) -> None: + """Publish a file by renaming it into place so no reader sees a partial write.""" + tmp = target.parent / f"{target.name}.tmp" + tmp.write_text(content, encoding="utf-8") + tmp.replace(target) + + +def await_release( + handshake_dir: str, + *, + timeout_s: float = RELEASE_TIMEOUT_S, + poll_interval_s: float = POLL_INTERVAL_S, +) -> None: + """Announce that the tool was entered, then block until the harness releases it. + + The marker is written before the wait begins so the harness can order its kill + strictly after the tool-call context reached checkpointable state. + + Raises ``TimeoutError`` at the deadline so the wait is bounded and the reason + reaches the TaskManager log. The raise has no effect on control flow: the caller + converts every tool exception into an ordinary tool response, so raising and + returning drive the run identically. What makes a timeout observable is the + sentinel the tool returns only on this function's success path. + + Parameters + ---------- + handshake_dir : str + Directory the harness created before submitting the job. + timeout_s : float + How long to wait for the release marker before failing the run. + poll_interval_s : float + Interval between existence checks. + """ + base = Path(handshake_dir) + _atomic_write(base / TOOL_ENTERED_MARKER, _PROCESS_EPOCH) + + release = base / RELEASE_MARKER + deadline = time.monotonic() + timeout_s + while not release.exists(): + if time.monotonic() > deadline: + msg = f"waited {timeout_s}s for the release marker at {release}" + raise TimeoutError(msg) + time.sleep(poll_interval_s) + + +def _flink_agents_version() -> str: + """Report the installed distribution version, or ``unknown`` if there is none.""" + try: + return version("flink-agents") + except PackageNotFoundError: + return "unknown" + + +def _runtime_identity() -> Dict[str, Any]: + """Describe the flink-agents installation this process is actually running. + + The probe is ``flink_agents.api.memory_object`` rather than the ``flink_agents`` + package. ``flink_agents/__init__.py`` is a single ``pkgutil.extend_path`` call, so + ``flink_agents.__file__`` names whichever ``__init__.py`` came first on the path + while ``flink_agents.api`` can be served from a different entry. ``api`` extends + nothing, so a module under it pins the installation that supplied the code under + test. + """ + return { + "flink_agents_api_file": memory_object_module.__file__, + "flink_agents_version": _flink_agents_version(), + "python_executable": sys.executable, + "python_version": sys.version, + "process_epoch": _PROCESS_EPOCH, + } + + +def _write_runtime_identity(handshake_dir: str) -> None: + """Publish the pre-kill identity so the harness can fail before it kills anything. + + This copy always describes the process that is about to be killed. The copy the + assertions rely on rides in the verdict record, which the surviving process + writes. + """ + _atomic_write( + Path(handshake_dir) / IDENTITY_MARKER, + json.dumps(_runtime_identity(), sort_keys=True), + ) + + +def _mark_blob_written() -> None: + global _BLOB_WRITES_IN_THIS_PROCESS + _BLOB_WRITES_IN_THIS_PROCESS += 1 + + +def _mark_tool_call_emitted() -> None: + global _TOOL_CALLS_EMITTED_IN_THIS_PROCESS + _TOOL_CALLS_EMITTED_IN_THIS_PROCESS += 1 + + +def _blob_matches(raw: Any) -> bool: + """Check the value is ``bytes`` or ``bytearray`` holding the known blob. + + The type gate is part of the assertion, not defensive coding. ``bytes()`` + accepts any iterable of ints, so without it a ``byte[]`` materialized as + ``[0, 1, 102, ...]`` would compare equal to the blob and pass — and this + predicate is the one whose bug produces a false pass. + + ``bytes`` is admitted because it is the only one of the two the memory value + validator accepts at write time. ``bytearray`` is admitted because the value + comes back across the bridge from a Java ``byte[]`` and may materialize as + either — a read-side allowance this test makes, not one the write-side contract + grants. Nothing else is admitted on the chance it might appear; anything else + fails here rather than aborting the action, so the run still publishes a verdict + and ``blob_observed_type`` records what did come back. + """ + if not isinstance(raw, bytes | bytearray): + return False + return bytes(raw) == _KNOWN_BLOB + + +def _required_config(ctx: RunnerContext, key: str) -> str: + value = ctx.config.get_str(key) + if not value: + msg = f"Missing config for the checkpoint recovery job: {key}" + raise ValueError(msg) + return value + + +class CheckpointRecoveryInput(BaseModel): + """Input record for the checkpoint recovery agent. + + Attributes: + ---------- + id : int + Unique identifier used as the partition key. + content : str + The user message content fed to the agent. + """ + + id: int + content: str + + +class CheckpointRecoveryKeySelector(KeySelector): + """KeySelector extracting the partition key from a CheckpointRecoveryInput.""" + + def get_key(self, value: CheckpointRecoveryInput) -> int: + """Extract key from CheckpointRecoveryInput.""" + return value.id + + +class RecoveryMockChatConnection(BaseChatModelConnection): + """Mock connection emitting one tool call, then joining the whole transcript.""" + + def chat( + self, + messages: Sequence[ChatMessage], + tools: List[BaseTool] | None = None, + output_schema: OutputSchema | None = None, + **kwargs: Any, + ) -> ChatMessage: + """Request the blocking tool, or join every message once the tool replied. + + A non-``None`` ``output_schema`` is rejected: this connection has no native + structured-output translation. Declaring the parameter keeps a caller-supplied + schema out of ``**kwargs``. + """ + self._reject_unsupported_output_schema(output_schema) + if messages[-1].role == MessageRole.TOOL: + # Joining every message carries the rebuilt transcript out to the + # emitted content, which is where the assertion can reach it. + content = "\n".join(message.content for message in messages) + return ChatMessage(role=MessageRole.ASSISTANT, content=content) + + # Validate the tool was bound before the model was invoked. + assert tools[0].name == BLOCKING_TOOL_NAME + _mark_tool_call_emitted() + tool_call = { + "id": str(uuid.uuid4()), + "type": ToolType.FUNCTION, + "function": {"name": BLOCKING_TOOL_NAME, "arguments": {}}, + } + return ChatMessage( + role=MessageRole.ASSISTANT, + content=_ROUND_ONE_MARKER, + tool_calls=[tool_call], + ) + + +class RecoveryMockChatModel(BaseChatModelSetup): + """Mock chat model setup for the checkpoint recovery agent.""" + + @property + def model_kwargs(self) -> Dict[str, Any]: + """Return model kwargs.""" + return {} + + +class CheckpointRecoveryAgent(Agent): + """Agent held mid-tool-call so a checkpoint captures its memory.""" + + @prompt + @staticmethod + def recovery_prompt() -> Prompt: + """Prompt used by the mock chat model.""" + return Prompt.from_text( + text="Please call the appropriate tool to do the following task: {task}", + ) + + @chat_model_connection + @staticmethod + def recovery_connection() -> ResourceDescriptor: + """Chat model connection used by the mock chat model.""" + return ResourceDescriptor( + clazz=f"{RecoveryMockChatConnection.__module__}." + f"{RecoveryMockChatConnection.__name__}" + ) + + @chat_model_setup + @staticmethod + def recovery_chat_model() -> ResourceDescriptor: + """Chat model referenced by the ChatRequestEvent.""" + return ResourceDescriptor( + clazz=f"{RecoveryMockChatModel.__module__}." + f"{RecoveryMockChatModel.__name__}", + connection="recovery_connection", + model="mock-model", + prompt="recovery_prompt", + tools=[BLOCKING_TOOL_NAME], + ) + + @tool( + injected_args={ + "handshake_dir": InjectedArg.from_config(HANDSHAKE_DIR_CONFIG_KEY) + } + ) + @staticmethod + def block_until_released(handshake_dir: str) -> str: + """Hold the agent run open until the harness releases it. + + Takes no model-visible arguments, so the mock never has to fabricate one. + The post-restore re-execution finds the release marker already present and + returns immediately, which makes the handshake idempotent. + + Parameters + ---------- + handshake_dir : str + The handshake directory, injected by runtime. + + Returns: + ------- + str: + The sentinel, which reaches the transcript as the tool message and is + required by the assertion. A timed-out handshake never gets here, so the + transcript carries the framework's failure string instead. + """ + await_release(handshake_dir) + return _TOOL_SENTINEL + + @action(EventType.InputEvent) + @staticmethod + def process_input(event: Event, ctx: RunnerContext) -> None: + """Record the payload and start the tool-call round.""" + input_data = CheckpointRecoveryInput.model_validate( + InputEvent.from_event(event).input + ) + # Resolve both directories up front: a misspelled verdict_dir would otherwise + # surface only after the whole park/kill/restart cycle has been paid for. + handshake_dir = _required_config(ctx, HANDSHAKE_DIR_CONFIG_KEY) + _required_config(ctx, VERDICT_DIR_CONFIG_KEY) + _write_runtime_identity(handshake_dir) + + # Carry the record id across the chat round-trip via per-key memory, + # since ChatResponseEvent does not echo the original input. + ctx.short_term_memory.set("input_id", input_data.id) + ctx.short_term_memory.set(_BLOB_MEMORY_KEY, _KNOWN_BLOB) + _mark_blob_written() + + ctx.send_event( + ChatRequestEvent( + model="recovery_chat_model", + messages=[ + ChatMessage(role=MessageRole.USER, content=input_data.content) + ], + prompt_args={"task": input_data.content}, Review Comment: This request leaves `output_schema` unset, so the E2E only exercises the `None` branch. Since #828 specifically normalized `OutputSchema` for checkpoint safety, a regression in its serialization or reconstruction would remain undetected. Could we include a non-null `OutputSchema` in this recovery flow? ########## python/flink_agents/e2e_tests/e2e_tests_integration/checkpoint_recovery_agent.py: ########## @@ -0,0 +1,447 @@ +################################################################################ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +################################################################################# +"""Agent that parks a built-in tool-call round so a checkpoint can capture it. + +A deterministic mock chat model emits one tool call, and the tool then blocks on a +filesystem handshake, so the harness rather than the clock decides when the agent +run may finish. While the run is parked, the built-in tool-call context and a +``bytes`` short-term-memory value sit in checkpointable state; the harness kills and +restarts the TaskManager, and the assertions below then run in a TaskManager process +that never performed any of the writes. + +Submitted to a real cluster by ``checkpoint_recovery_job``. The module name is +deliberately neither ``*_test.py`` nor ``*_example.py``: the first would make pytest +collect it, the second would make the example-submission nightly submit it. +""" + +import json +import sys +import time +import uuid +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from typing import Any, Dict, List, Sequence + +from pydantic import BaseModel +from pyflink.datastream import KeySelector + +import flink_agents.api.memory_object as memory_object_module +from flink_agents.api.agents.agent import Agent +from flink_agents.api.agents.types import OutputSchema +from flink_agents.api.chat_message import ChatMessage, MessageRole +from flink_agents.api.chat_models.chat_model import ( + BaseChatModelConnection, + BaseChatModelSetup, +) +from flink_agents.api.decorators import ( + action, + chat_model_connection, + chat_model_setup, + prompt, + tool, +) +from flink_agents.api.events.chat_event import ChatRequestEvent, ChatResponseEvent +from flink_agents.api.events.event import Event, InputEvent, OutputEvent +from flink_agents.api.events.event_type import EventType +from flink_agents.api.prompts.prompt import Prompt +from flink_agents.api.resource import ResourceDescriptor +from flink_agents.api.runner_context import RunnerContext +from flink_agents.api.tools import InjectedArg +from flink_agents.api.tools.tool import Tool as BaseTool +from flink_agents.api.tools.tool import ToolType + +# Config keys the submitting job must set; a missing one fails the run loudly. +HANDSHAKE_DIR_CONFIG_KEY = "handshake_dir" +VERDICT_DIR_CONFIG_KEY = "verdict_dir" + +# Filenames exchanged with the harness. +TOOL_ENTERED_MARKER = "tool-entered" +RELEASE_MARKER = "release" +IDENTITY_MARKER = "runtime-identity.json" +VERDICT_MARKER = "verdict.json" + +RELEASE_TIMEOUT_S = 240.0 +POLL_INTERVAL_S = 0.2 + +# The user message content, also built into the input record by the job. +USER_CONTENT = "hold the tool call open across a taskmanager restart" + +BLOCKING_TOOL_NAME = "block_until_released" + +_ROUND_ONE_MARKER = "recovery-round-one-assistant" +_BLOB_MEMORY_KEY = "blob" + +# Returned by the tool only on the released path, and required in the transcript. +# The framework converts a tool exception into the response string +# "Tool `block_until_released` execute failed." rather than failing the action, so a +# timed-out handshake reaches the assertions only as the absence of this value. That +# makes the value load-bearing: it must not be a substring of that failure string, +# which rules out anything built from the tool's own name. +_TOOL_SENTINEL = "handshake-done-sentinel" + +# A NUL plus bytes that are not valid UTF-8, so a value handled as UTF-8 text anywhere +# on the path either raises or comes back corrupted. A single-byte codec such as +# latin-1 would round trip it intact, so this catches UTF-8 handling rather than every +# possible string conversion. +_KNOWN_BLOB = b"\x00\x01flink-agents\xff\xfe" + +# Process-local and deliberately outside Flink state. Anything checkpointed is +# restored along with the payload and therefore cannot tell a restored read apart +# from the reader's own write. +# +# Pemja runs as ExecType.MULTI_THREAD against a singleton main interpreter, so these +# globals outlive an in-place task restart and are reset only when the TaskManager +# process itself dies. A read that returns the known value while they are still zero +# therefore proves the write happened in a different TaskManager process, which is +# stronger than proving it happened in a different interpreter. +_PROCESS_EPOCH = uuid.uuid4().hex +_BLOB_WRITES_IN_THIS_PROCESS = 0 +_TOOL_CALLS_EMITTED_IN_THIS_PROCESS = 0 + + +def _atomic_write(target: Path, content: str) -> None: + """Publish a file by renaming it into place so no reader sees a partial write.""" + tmp = target.parent / f"{target.name}.tmp" + tmp.write_text(content, encoding="utf-8") + tmp.replace(target) + + +def await_release( + handshake_dir: str, + *, + timeout_s: float = RELEASE_TIMEOUT_S, + poll_interval_s: float = POLL_INTERVAL_S, +) -> None: + """Announce that the tool was entered, then block until the harness releases it. + + The marker is written before the wait begins so the harness can order its kill + strictly after the tool-call context reached checkpointable state. + + Raises ``TimeoutError`` at the deadline so the wait is bounded and the reason + reaches the TaskManager log. The raise has no effect on control flow: the caller + converts every tool exception into an ordinary tool response, so raising and + returning drive the run identically. What makes a timeout observable is the + sentinel the tool returns only on this function's success path. + + Parameters + ---------- + handshake_dir : str + Directory the harness created before submitting the job. + timeout_s : float + How long to wait for the release marker before failing the run. + poll_interval_s : float + Interval between existence checks. + """ + base = Path(handshake_dir) + _atomic_write(base / TOOL_ENTERED_MARKER, _PROCESS_EPOCH) + + release = base / RELEASE_MARKER + deadline = time.monotonic() + timeout_s + while not release.exists(): + if time.monotonic() > deadline: + msg = f"waited {timeout_s}s for the release marker at {release}" + raise TimeoutError(msg) + time.sleep(poll_interval_s) + + +def _flink_agents_version() -> str: + """Report the installed distribution version, or ``unknown`` if there is none.""" + try: + return version("flink-agents") + except PackageNotFoundError: + return "unknown" + + +def _runtime_identity() -> Dict[str, Any]: + """Describe the flink-agents installation this process is actually running. + + The probe is ``flink_agents.api.memory_object`` rather than the ``flink_agents`` + package. ``flink_agents/__init__.py`` is a single ``pkgutil.extend_path`` call, so + ``flink_agents.__file__`` names whichever ``__init__.py`` came first on the path + while ``flink_agents.api`` can be served from a different entry. ``api`` extends + nothing, so a module under it pins the installation that supplied the code under + test. + """ + return { + "flink_agents_api_file": memory_object_module.__file__, + "flink_agents_version": _flink_agents_version(), + "python_executable": sys.executable, + "python_version": sys.version, + "process_epoch": _PROCESS_EPOCH, + } + + +def _write_runtime_identity(handshake_dir: str) -> None: + """Publish the pre-kill identity so the harness can fail before it kills anything. + + This copy always describes the process that is about to be killed. The copy the + assertions rely on rides in the verdict record, which the surviving process + writes. + """ + _atomic_write( + Path(handshake_dir) / IDENTITY_MARKER, + json.dumps(_runtime_identity(), sort_keys=True), + ) + + +def _mark_blob_written() -> None: + global _BLOB_WRITES_IN_THIS_PROCESS + _BLOB_WRITES_IN_THIS_PROCESS += 1 + + +def _mark_tool_call_emitted() -> None: + global _TOOL_CALLS_EMITTED_IN_THIS_PROCESS + _TOOL_CALLS_EMITTED_IN_THIS_PROCESS += 1 + + +def _blob_matches(raw: Any) -> bool: + """Check the value is ``bytes`` or ``bytearray`` holding the known blob. + + The type gate is part of the assertion, not defensive coding. ``bytes()`` + accepts any iterable of ints, so without it a ``byte[]`` materialized as + ``[0, 1, 102, ...]`` would compare equal to the blob and pass — and this + predicate is the one whose bug produces a false pass. + + ``bytes`` is admitted because it is the only one of the two the memory value + validator accepts at write time. ``bytearray`` is admitted because the value + comes back across the bridge from a Java ``byte[]`` and may materialize as + either — a read-side allowance this test makes, not one the write-side contract + grants. Nothing else is admitted on the chance it might appear; anything else + fails here rather than aborting the action, so the run still publishes a verdict + and ``blob_observed_type`` records what did come back. + """ + if not isinstance(raw, bytes | bytearray): Review Comment: Could we require exact `bytes` here? The memory contract deliberately accepts `bytes` but rejects `bytearray`. With the current check, a restored `bytearray` with the same content would still pass. Using `type(raw) is bytes and raw == _KNOWN_BLOB` would make this a true `bytes` round-trip assertion. ########## e2e-test/test-scripts/test_checkpoint_recovery.sh: ########## @@ -0,0 +1,1307 @@ +#!/usr/bin/env bash +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# +# Verifies that Python agent memory survives the loss of a TaskManager process. +# +# Submits checkpoint_recovery_job.py to a local Flink standalone cluster. The job +# parks itself inside a tool call that blocks on a file this script creates, so a +# completed checkpoint provably holds the agent's memory before anything is killed. +# The script then hard-kills the TaskManager, restarts it, waits for a real restore, +# releases the tool and reads the verdict the job publishes. +# +# Unlike its sibling test_submit_examples_to_flink.sh, a successful submission is +# NOT a pass: the only pass is a verdict file that says so. +# +# Env: FLINK_VERSION (default 2.3.0), FLINK_HOME (reuse existing install), +# VERBOSE=1 (set -x), plus the *_TIMEOUT overrides below. +# +# A FLINK_HOME passed in has to be that same FLINK_VERSION. The run copies +# opt/flink-python-<FLINK_VERSION>.jar into its lib/ and stops when that file is +# not there, so point FLINK_VERSION at whatever installation FLINK_HOME names. + +set -euo pipefail + +if [[ "${VERBOSE:-0}" == "1" ]]; then + set -x +fi + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { printf "${BLUE}[INFO]${NC} %s\n" "$*" >&2; } +log_ok() { printf "${GREEN}[OK]${NC} %s\n" "$*" >&2; } +log_warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*" >&2; } +log_error() { printf "${RED}[ERROR]${NC} %s\n" "$*" >&2; } +log_section() { + printf "\n${BLUE}==============================================================${NC}\n" >&2 + printf "${BLUE}>>> %s${NC}\n" "$*" >&2 + printf "${BLUE}==============================================================${NC}\n" >&2 +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.."; pwd)" +log_info "Project root: $ROOT_DIR" + +FLINK_VERSION="${FLINK_VERSION:-2.3.0}" +FLINK_MAJOR_MINOR="${FLINK_VERSION%.*}" +REST_URL="${REST_URL:-http://localhost:8081}" + +JOB_MODULE="flink_agents/e2e_tests/e2e_tests_integration/checkpoint_recovery_job.py" +EXPECTED_AGENTS_VERSION="${EXPECTED_AGENTS_VERSION:-0.3.dev0}" + +# Checkpoint interval is deliberately short: the run is parked while we wait for two +# checkpoints to complete, and that wait is charged against the tool's own deadline. +# Held in milliseconds because that is the unit the checkpoint-config endpoint reports, +# so the value written and the value asserted are the same number. +CHECKPOINT_INTERVAL_MS="${CHECKPOINT_INTERVAL_MS:-5000}" +RESTART_ATTEMPTS="${RESTART_ATTEMPTS:-3}" +# Unset, this falls back to slot.request.timeout (5 min), after which a pending slot +# request is failed and one restart attempt is burned while the TM is being replaced. +STANDALONE_STARTUP_TIME="${STANDALONE_STARTUP_TIME:-600s}" + +# Budgets. Setup waits are unconstrained; the four marked ones run after the tool has +# parked and are therefore charged against the tool's own release deadline. They are +# clamped at runtime to the time actually remaining (see charged_timeout), so these +# numbers are ceilings rather than guarantees. +CLUSTER_TIMEOUT="${CLUSTER_TIMEOUT:-120}" +SUBMIT_TIMEOUT="${SUBMIT_TIMEOUT:-300}" +JOB_RUNNING_TIMEOUT="${JOB_RUNNING_TIMEOUT:-180}" +IDENTITY_TIMEOUT="${IDENTITY_TIMEOUT:-180}" +TOOL_ENTERED_TIMEOUT="${TOOL_ENTERED_TIMEOUT:-300}" +CHECKPOINT_TIMEOUT="${CHECKPOINT_TIMEOUT:-30}" # charged against the tool deadline +TM_GONE_TIMEOUT="${TM_GONE_TIMEOUT:-45}" # charged against the tool deadline +TM_UP_TIMEOUT="${TM_UP_TIMEOUT:-30}" # charged against the tool deadline +RESTORE_TIMEOUT="${RESTORE_TIMEOUT:-30}" # charged against the tool deadline +VERDICT_TIMEOUT="${VERDICT_TIMEOUT:-120}" +POLL_INTERVAL="${POLL_INTERVAL:-2}" + +# Every REST call is bounded. This is charged in the budget arithmetic, so keep the +# two in step: a healthy local JobManager answers in milliseconds, and a large value +# here buys nothing while inflating the worst-case overrun of every wait. +CURL_MAX_TIME="${CURL_MAX_TIME:-5}" + +# Wall-clock costs inside the charged window that belong to no single wait: the +# individual REST reads between the waits, two jps invocations, the SIGKILL grace and +# starting the replacement TaskManager. This is an estimate, and deliberately only +# feeds the pre-flight feasibility check — the runtime clamp is what actually holds +# the guarantee, so being wrong here cannot let the tool self-release. +HANDSHAKE_FIXED_COST_S="${HANDSHAKE_FIXED_COST_S:-45}" + +# Kept in hand so `release` is written comfortably before the tool gives up, covering +# the tool's own poll interval and the one-second granularity of SECONDS. +HANDSHAKE_SAFETY_S="${HANDSHAKE_SAFETY_S:-15}" + +# Bash 3 (default on macOS) lacks associative arrays. +RESULT_NAMES=() +RESULT_STATES=() +SUBMITTED_JOB_IDS=() +CONFIG_KEYS_SET=() +JOB_ID="" +WORK_DIR="" +HANDSHAKE_DIR="" +VERDICT_DIR="" +CHECKPOINT_DIR="" +INPUT_DIR="" +INPUT_FILE="" +FLINK_CONF="" +TM_PID_BEFORE="" +TM_RESOURCE_ID_BEFORE="" +RESTORED_BEFORE="" +RELEASE_DEADLINE_S="" +HANDSHAKE_DEADLINE_AT="" + +cleanup() { + local exit_code=$? + log_section "Cleanup" + + if [[ -n "${FLINK_HOME:-}" && -x "$FLINK_HOME/bin/flink" ]]; then + for jid in "${SUBMITTED_JOB_IDS[@]:-}"; do + [[ -n "$jid" ]] || continue + log_info "Cancelling job $jid" + "$FLINK_HOME/bin/flink" cancel "$jid" >/dev/null 2>&1 || true + done + + if [[ -x "$FLINK_HOME/bin/stop-cluster.sh" ]]; then + # If the TaskManager was killed and never replaced, this prints + # "No taskexecutor daemon (pid: N) is running anymore" into the archive. + # That line is expected here and is not the failure. + log_info "Stopping Flink cluster" + "$FLINK_HOME/bin/stop-cluster.sh" >/dev/null 2>&1 || true + fi + + # Archive the whole log directory, never named files: `kill -9` leaves the + # dead TaskManager's pid in the pid file, so the replacement writes to a + # higher log index and a named copy would miss the post-restore TaskManager. + if [[ -d "$FLINK_HOME/log" ]]; then + local log_archive="$ROOT_DIR/flink-logs-$(date +%Y%m%d-%H%M%S).tar.gz" + tar -czf "$log_archive" -C "$FLINK_HOME" log >/dev/null 2>&1 \ + && log_info "Flink logs archived to: $log_archive" \ + || log_warn "Failed to archive Flink logs" + fi + fi + + # Strip the keys we appended. A second run against a reused Flink home would + # otherwise append them twice, and a duplicate key is a hard YAML parse failure + # that stops the cluster from starting at all. + if [[ -n "$FLINK_CONF" && -f "$FLINK_CONF" ]]; then + local key + for key in "${CONFIG_KEYS_SET[@]:-}"; do + [[ -n "$key" ]] || continue + delete_config_key "$key" + done + log_info "Reverted ${#CONFIG_KEYS_SET[@]} appended config key(s)" + fi + + # The killed TaskManager's entry stays in /tmp/flink-*-taskexecutor.pid, which + # only nudges the next local run's log-file index upward. That file is shared by + # any Flink cluster this user runs, so it is left alone rather than risking a + # concurrent cluster's shutdown for a cosmetic gain. + + # The work directory holds the verdict file, the handshake markers and the + # checkpoints — everything needed to explain a failure. Remove it only when the + # run both recorded assertions and is exiting cleanly; otherwise keep it and say + # where it is. A non-zero exit code is its own reason to keep: the setup steps and + # several assertions abort under set -e without recording anything, so judging by + # the recorded results alone would delete the diagnostics for exactly those. + if [[ -n "$WORK_DIR" && -d "$WORK_DIR" ]]; then + local keep=0 state + for state in "${RESULT_STATES[@]:-}"; do + [[ "$state" == "FAIL" ]] && keep=1 + done + (( ${#RESULT_STATES[@]} == 0 )) && keep=1 + (( exit_code != 0 )) && keep=1 + if (( keep == 1 )); then + log_warn "Keeping diagnostics in $WORK_DIR (verdict, handshake markers, checkpoints)" + else + rm -rf "$WORK_DIR" + fi + fi + + print_summary + exit "$exit_code" +} +trap cleanup EXIT + +print_summary() { + log_section "Test summary" + local total=${#RESULT_NAMES[@]} + if (( total == 0 )); then + # Exit non-zero rather than return: a run that recorded no assertion verified + # nothing, and reporting that as success is the precise failure this whole + # script exists to make impossible. + log_error "No assertion was recorded, so nothing was verified" + exit 1 + fi + local passed=0 + local failed=0 + local i + for (( i = 0; i < total; i++ )); do + local name="${RESULT_NAMES[$i]}" + local state="${RESULT_STATES[$i]}" + if [[ "$state" == "PASS" ]]; then + printf " ${GREEN}PASS${NC} %s\n" "$name" + passed=$((passed + 1)) + else + printf " ${RED}FAIL${NC} %s\n" "$name" + failed=$((failed + 1)) + fi + done + printf "\nTotal: %d Passed: %d Failed: %d\n" "$total" "$passed" "$failed" + + if (( failed > 0 )); then + log_error "$failed assertion(s) failed" + exit 1 + fi +} + +record_result() { + RESULT_NAMES+=("$1") + RESULT_STATES+=("$2") +} + +# --------------------------------------------------------------------------- +# JSON extraction. python3 rather than jq, whose presence on the runner is not +# guaranteed. +# +# Exit 1 means "I could not read this": empty body, unparsable JSON, a path that +# does not exist, or a leaf of the wrong type. Callers MUST keep that distinct +# from "I read a value and did not like it yet" — reporting the first as the +# second turns a wrong field name into a phantom timeout. +# --------------------------------------------------------------------------- +json_query() { + local mode="$1" path="$2" + python3 -c ' +import json +import sys + +mode, path = sys.argv[1], sys.argv[2] +raw = sys.stdin.read() +if not raw.strip(): + sys.exit(1) +try: + doc = json.loads(raw) +except ValueError: + sys.exit(1) + +# The cluster config endpoint answers with a list of {"key":..,"value":..} pairs, +# and its keys contain dots, so it cannot go through the path walker below. +if mode == "conf": + if not isinstance(doc, list): + sys.exit(1) + for item in doc: + if isinstance(item, dict) and item.get("key") == path: + value = item.get("value") + print("" if value is None else value) + sys.exit(0) + sys.exit(1) + +node = doc +for part in path.split("."): + if isinstance(node, list) and part.isdigit() and int(part) < len(node): + node = node[int(part)] + elif isinstance(node, dict) and part in node: + node = node[part] + else: + sys.exit(1) + +if mode == "nullable": + # The key must be present; only its value may be null. A missing key is a + # read failure, so a misspelled field cannot masquerade as "not yet". + print("null" if node is None else "present") +elif mode == "len": + if not isinstance(node, list): + sys.exit(1) + print(len(node)) +elif mode == "int": + if isinstance(node, bool) or not isinstance(node, int): + sys.exit(1) + print(node) +elif mode == "str": + if not isinstance(node, str): + sys.exit(1) + print(node) +else: + sys.exit(2) +' "$mode" "$path" +} + +rest_get() { + curl -fsS --max-time "$CURL_MAX_TIME" "$REST_URL$1" 2>/dev/null +} + +rest_field() { + local path="$1" mode="$2" field="$3" + rest_get "$path" | json_query "$mode" "$field" +} + +# --------------------------------------------------------------------------- +# Compare an observed value against a spec: eq:V, ge:N, nonnull. +# +# Returns 0 on a match, 1 on a mismatch, and 2 when the comparison cannot be +# evaluated at all — an unknown operator, or a non-integer operand for ge. Callers +# must keep 2 apart from 1: the first means the spec is wrong, the second means the +# condition is not satisfied yet. +# --------------------------------------------------------------------------- +value_matches() { + local observed="$1" spec="$2" + local op="${spec%%:*}" want="${spec#*:}" + case "$op" in + eq) [[ "$observed" == "$want" ]] ;; + ge) + # (( )) evaluates its operands as shell arithmetic, so a non-numeric + # observation would be treated as a variable name and abort the script + # under set -u instead of simply not matching. Return 2 so the caller can + # tell "cannot compare" from "does not match". + if [[ ! "$observed" =~ ^-?[0-9]+$ ]] || [[ ! "$want" =~ ^-?[0-9]+$ ]]; then + log_error "value_matches: 'ge' needs two integers, got observed='$observed' want='$want'" + return 2 + fi + (( observed >= want )) + ;; + nonnull) [[ "$observed" == "present" ]] ;; + *) log_error "value_matches: unknown comparison '$spec'"; return 2 ;; + esac +} + +# --------------------------------------------------------------------------- +# Poll a REST field until it satisfies a spec. +# +# $1 label human name of the step, used in both failure messages +# $2 path REST path, e.g. /jobs/<id>/checkpoints +# $3 probe "mode:field" that must ALWAYS parse on a healthy endpoint +# $4 target "mode:field" being tested +# $5 spec comparison, see value_matches +# $6 timeout seconds +# +# The probe is what separates the two failure modes. If the probe never parses we +# are not talking to the endpoint we think we are; if the probe parses but the +# target never does, the target's field name is wrong (or this Flink build omits +# it) — neither is evidence that the condition was false. +# --------------------------------------------------------------------------- +wait_for_rest() { + local label="$1" path="$2" probe="$3" target="$4" spec="$5" timeout="$6" + local probe_mode="${probe%%:*}" probe_field="${probe#*:}" + local target_mode="${target%%:*}" target_field="${target#*:}" + + local probe_seen=0 target_seen=0 last="" + local body value rc + # Deadline, not accumulated sleeps: curl and the two python3 spawns per iteration + # are not free, so counting only the sleeps would let a nominal budget overrun by + # several times against a slow endpoint — and these budgets are what keep the + # recovery inside the tool's own release deadline. + local deadline=$((SECONDS + timeout)) + log_info "$label: polling GET $path for $target_field ($spec), budget ${timeout}s" + while (( SECONDS < deadline )); do + body=$(rest_get "$path") || body="" + if [[ -n "$body" ]]; then + if printf '%s' "$body" | json_query "$probe_mode" "$probe_field" >/dev/null; then + probe_seen=1 + fi + if value=$(printf '%s' "$body" | json_query "$target_mode" "$target_field"); then + target_seen=1 + last="$value" + rc=0 + value_matches "$value" "$spec" || rc=$? + if (( rc == 0 )); then + log_ok "$label: $target_field is $value" + return 0 + elif (( rc > 1 )); then + log_error "$label: comparison '$spec' cannot be evaluated against '$value'" + return 1 + fi + fi + fi + sleep "$POLL_INTERVAL" + done + + # Order matters: an observed target is reported even when the probe never parsed, + # because in that state the condition really was evaluated and really was false, + # and claiming otherwise would withhold the one value a reader needs. + if (( target_seen == 1 )); then + log_error "$label: '$target_field' from GET $REST_URL$path was last '$last' after ${timeout}s and never satisfied '$spec'." + if (( probe_seen == 0 )); then + log_warn "$label: the probe field '$probe_field' never parsed, so the response shape is only partly as expected." + fi + elif (( probe_seen == 1 )); then + log_error "$label: parsed '$probe_field' from GET $REST_URL$path, so the endpoint is right, but never parsed '$target_field' in ${timeout}s. Either the field name is wrong or this Flink build omits it. This is NOT evidence that the condition was false." + else + log_error "$label: never parsed '$probe_field' or '$target_field' from GET $REST_URL$path in ${timeout}s. The endpoint did not answer, or its response shape is not what this script expects. This is NOT evidence that the condition was false." + fi + return 1 +} + +# --------------------------------------------------------------------------- +# Wait for an exact filename. Never a glob: every file the job publishes is +# written to "<name>.tmp" and renamed, so a crash between the two steps leaves a +# permanent ".tmp" twin that a glob would happily read as the real thing. +# --------------------------------------------------------------------------- +wait_for_file() { + local label="$1" file="$2" timeout="$3" + local deadline=$((SECONDS + timeout)) + log_info "$label: waiting for $file, budget ${timeout}s" + while (( SECONDS < deadline )); do + if [[ -f "$file" ]]; then + log_ok "$label: $file appeared" + return 0 + fi + sleep "$POLL_INTERVAL" + done + local dir + dir="$(dirname "$file")" + log_error "$label: $file did not appear within ${timeout}s" + if [[ -f "$file.tmp" ]]; then + log_error "$label: found a leftover $file.tmp — the writer was interrupted between write and rename" + fi + if [[ -d "$dir" ]]; then + local listing + listing=$(find "$dir" -maxdepth 1 -mindepth 1 2>/dev/null | sed 's|.*/||' | sort | tr '\n' ' ') + log_info "$label: $dir currently holds: $listing" + else + log_error "$label: the directory $dir does not exist" + fi + return 1 +} + +install_flink_distribution() { + local install_dir="$1" + + # Reuse install.sh's download, archive validation and extraction logic, but + # stop before it installs a *released* Flink Agents JAR. That step derives its + # download URL from the Flink version, and no such artifact is published for + # every Flink version this test runs against — asking for one that does not + # exist would abort the install. This test must run the artifacts built from + # the current checkout anyway, which stage_dist_jars puts in place below. + FLINK_AGENTS_INSTALL_SH_NO_RUN=1 \ + FLINK_VERSION="$FLINK_VERSION" \ + INSTALL_FLINK=Yes \ + INSTALL_DIR="$install_dir" \ + NO_PROMPT=1 \ + bash -c ' + source "$1" + plan_flink + install_flink_if_needed + ' _ "$ROOT_DIR/tools/install.sh" +} + +# Replaces install.sh's setup_python_env, which would populate the venv with a +# released flink-agents. This creates a plain venv and nothing more; the packages +# arrive later, from tools/build.sh's `uv pip install dist/*.whl` and from +# install_built_python_package. +prepare_python_venv() { + if [[ -f "$VENV_DIR/pyvenv.cfg" && -x "$VENV_DIR/bin/python" ]]; then + # Reuse is unconditional on the interpreter: unlike install.sh, this accepts + # a venv built by a Python outside the range python/pyproject.toml declares. + log_info "Reusing Python venv: $VENV_DIR" + return + fi + if [[ -e "$VENV_DIR" ]] \ + && [[ ! -d "$VENV_DIR" \ + || -n "$(find "$VENV_DIR" -mindepth 1 -print -quit 2>/dev/null)" ]]; then + log_error "VENV_DIR is not an empty directory or a valid venv: $VENV_DIR" + return 1 + fi + + local python_bin="${PYTHON_BIN:-python3}" + log_info "Creating Python venv: $VENV_DIR" + "$python_bin" -m venv "$VENV_DIR" +} + +install_flink() { + log_section "Install Flink standalone (version $FLINK_VERSION)" + + # Anchor VENV_DIR to the repo so a fresh and a reused Flink installation share + # one Python environment, and so later steps can address it by path. + export VENV_DIR="${VENV_DIR:-$ROOT_DIR/.flink-agents-env}" + + if [[ -n "${FLINK_HOME:-}" && -x "$FLINK_HOME/bin/flink" ]]; then + log_info "Reusing existing FLINK_HOME: $FLINK_HOME" + export FLINK_HOME + else + local install_dir="${INSTALL_DIR:-$HOME/.local/flink}" + log_info "Installing the Flink distribution with tools/install.sh helpers" + install_flink_distribution "$install_dir" + export FLINK_HOME="${install_dir}/flink-${FLINK_VERSION}" + + if [[ ! -x "$FLINK_HOME/bin/flink" ]]; then + log_error "Flink installation not found at expected path: $FLINK_HOME" + exit 1 + fi + log_ok "Flink installed at: $FLINK_HOME" + fi + + # The distribution ships the PyFlink jar under opt/, outside the lib/ directory + # the cluster loads. install.sh copies it across as part of --enable-pyflink, + # which is no longer reached from here, so the copy happens here — on both branches + # above, since a reused FLINK_HOME need not have been prepared by install.sh. + local pyflink_jar="$FLINK_HOME/opt/flink-python-${FLINK_VERSION}.jar" + if [[ ! -f "$pyflink_jar" ]]; then + log_error "PyFlink JAR not found in Flink distribution: $pyflink_jar" + exit 1 + fi + cp "$pyflink_jar" "$FLINK_HOME/lib/" + + prepare_python_venv + + # Both branches above fall through to here, so the venv is activated whether + # FLINK_HOME was reused or freshly installed. That is load-bearing: the + # TaskManager daemon inherits this shell's PATH and resolves a bare `python` + # from it, and this script restarts the TaskManager mid-test — so an + # unactivated shell would give the replacement TaskManager a different + # interpreter from the original. Activation settles which interpreter that is + # and nothing more — what is installed in it is install_built_python_package's + # business, and that runs later. + if [[ ! -x "$VENV_DIR/bin/python" ]]; then + log_error "Expected Python venv not found at: $VENV_DIR" + exit 1 + fi + # shellcheck disable=SC1091 + source "$VENV_DIR/bin/activate" + log_ok "Activated Python venv: $VENV_DIR" + + FLINK_CONF="$FLINK_HOME/conf/config.yaml" + if [[ ! -f "$FLINK_CONF" ]]; then + log_error "Flink config not found: $FLINK_CONF" + exit 1 + fi +} + +# The wheel this run must exercise is the one tools/build.sh just produced, so it is +# installed from python/dist rather than from PyPI. apache-flink is pinned to the +# Flink version the cluster runs, because the submitting client and the TaskManager +# both execute this same interpreter. +install_built_python_package() { + local wheel + wheel=$(find "$ROOT_DIR/python/dist" -maxdepth 1 -name '*.whl' | head -n 1) + if [[ -z "$wheel" ]]; then + log_error "Python wheel not found after build in: $ROOT_DIR/python/dist" + return 1 + fi + + log_info "Installing $(basename "$wheel") and apache-flink==$FLINK_VERSION into $VENV_DIR" + "$VENV_DIR/bin/python" -m pip install --quiet \ + "$wheel" "apache-flink==$FLINK_VERSION" + + if ! "$VENV_DIR/bin/python" -c 'import flink_agents, pyflink' >/dev/null 2>&1; then + log_error "The built Flink Agents wheel or PyFlink is not importable from: $VENV_DIR/bin/python" + return 1 + fi + + export PYFLINK_CLIENT_EXECUTABLE="$VENV_DIR/bin/python" + log_ok "Installed the built wheel and PyFlink into: $VENV_DIR" +} + +build_project() { + log_section "Build flink-agents (Java + Python)" + ( + cd "$ROOT_DIR" + SKIP_SPOTLESS_CHECK=true bash tools/build.sh + ) + install_built_python_package + log_ok "Build completed" +} + +stage_dist_jars() { + log_section "Stage dist uber jar into \$FLINK_HOME/lib" + + local project_version + project_version=$(sed -n 's/.*<version>\(.*\)<\/version>.*/\1/p' \ + "$ROOT_DIR/pom.xml" | head -n 2 | tail -n 1) + log_info "Detected project version: $project_version" + + # The flink-version uber jar already bundles the common deps. + local flink_jar="$ROOT_DIR/dist/flink-${FLINK_MAJOR_MINOR}/target/flink-agents-dist-flink-${FLINK_MAJOR_MINOR}-${project_version}.jar" + + if [[ ! -f "$flink_jar" ]]; then + log_error "Flink dist jar not found: $flink_jar" + exit 1 + fi + + # Drop any flink-agents-dist jar already in lib/ before copying. A reused + # FLINK_HOME may still carry one — from an earlier tools/install.sh run, or from + # an earlier run of this test at a different project version — and a jar of a + # different version has a different filename, so the copy below would not + # replace it and both would sit on the classpath. + rm -f "$FLINK_HOME/lib/"/flink-agents-dist-*.jar + cp "$flink_jar" "$FLINK_HOME/lib/" + log_ok "Staged: $(basename "$flink_jar")" +} + +prepare_work_dirs() { + log_section "Create input, handshake, verdict and checkpoint directories" + WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/flink-agents-recovery.XXXXXX")" + HANDSHAKE_DIR="$WORK_DIR/handshake" + VERDICT_DIR="$WORK_DIR/verdict" + CHECKPOINT_DIR="$WORK_DIR/checkpoints" + INPUT_DIR="$WORK_DIR/input" + # The name is load-bearing. Flink's default file filter skips any name beginning + # with '.' or '_', and applies that even to a path the source was handed directly, + # which would leave the job running with nothing to read and nothing logged. + INPUT_FILE="$INPUT_DIR/trigger.txt" + # The job must never create these itself: a tool that provisions its own + # handshake directory cannot tell "the harness set me up" from "my path is wrong". + mkdir -p "$HANDSHAKE_DIR" "$VERDICT_DIR" "$CHECKPOINT_DIR" "$INPUT_DIR" + + # One line is all the source needs: the job builds the record from a constant it + # shares with the assertion and ignores this content. Written under a temporary + # name and renamed, so the file is whole the instant it appears under the name the + # source reads. Submission is several steps away, and that distance is what + # actually orders the two — the rename is redundancy, not the ordering mechanism. + printf 'trigger\n' > "$INPUT_FILE.tmp" + mv "$INPUT_FILE.tmp" "$INPUT_FILE" + # An empty file produces no record and no error. Nothing that depends on a record + # then runs, the agent's identity report included, so the run surfaces as the + # runtime-identity wait expiring with nothing pointing at the input. A wrong path + # needs no guard here: enumeration is eager, so the job fails within seconds with + # "Could not enumerate file splits". + if [[ ! -s "$INPUT_FILE" ]]; then + log_error "Trigger file is missing or empty: $INPUT_FILE" + return 1 + fi + + log_ok "Work directory: $WORK_DIR" +} + +# --------------------------------------------------------------------------- +# Config editing. Delete-then-append, so applying the same key twice is a no-op +# rather than a duplicate YAML key. +# +# A flat dotted key at column 0 is read correctly regardless of the nested blocks +# the shipped file uses, because Flink flattens the parsed document before +# building its Configuration. Appending (never prepending) also means our value +# wins any collision with a nested block, since the flattened map is populated in +# document order. +# --------------------------------------------------------------------------- +delete_config_key() { + local config_key="$1" + local tmp + tmp="$(mktemp)" + # grep -v exits 1 when it filters everything out, which set -e would treat as + # fatal. The dots in the key are regex "any char"; harmless for these keys. + grep -v "^${config_key}: " "$FLINK_CONF" > "$tmp" || true + mv "$tmp" "$FLINK_CONF" +} + +set_config_key() { + local config_key="$1" value="$2" + delete_config_key "$config_key" + printf '%s: %s\n' "$config_key" "$value" >> "$FLINK_CONF" + CONFIG_KEYS_SET+=("$config_key") +} + +configure_flink() { + log_section "Configure checkpointing and restart strategy" + + # Without an interval, checkpointing is simply off — the option has no default. + # That is the one misconfiguration that would let this whole test pass having + # verified nothing, which is why the effective value is read back below. + set_config_key "execution.checkpointing.interval" "${CHECKPOINT_INTERVAL_MS}ms" + set_config_key "execution.checkpointing.dir" "file://$CHECKPOINT_DIR" + # Every other Python e2e test in this repo disables restarts. This one must not: + # without a restart strategy the job dies on the TaskManager loss instead of + # recovering from its checkpoint. + set_config_key "restart-strategy.type" "fixed-delay" + set_config_key "restart-strategy.fixed-delay.attempts" "$RESTART_ATTEMPTS" + set_config_key "resourcemanager.standalone.start-up-time" "$STANDALONE_STARTUP_TIME" + + log_ok "Appended ${#CONFIG_KEYS_SET[@]} config key(s) to $FLINK_CONF" +} + +# --------------------------------------------------------------------------- +# Read the keys back from the running JobManager. Writing to a file only proves +# we wrote to a file. +# +# What this does and does not establish: /jobmanager/config echoes the loaded +# configuration including keys Flink does not recognize, so a value matching here +# proves the append reached the configuration the JobManager parsed — not that the +# option has any effect. The one key where that distinction matters is the +# checkpoint interval, and it is asserted separately and authoritatively by +# assert_checkpointing_enabled once a job exists. +# --------------------------------------------------------------------------- +assert_effective_config() { + log_section "Verify the JobManager loaded the config we wrote" + + local exact_keys=( + "execution.checkpointing.dir=file://$CHECKPOINT_DIR" + "restart-strategy.type=fixed-delay" + "restart-strategy.fixed-delay.attempts=$RESTART_ATTEMPTS" + "resourcemanager.standalone.start-up-time=$STANDALONE_STARTUP_TIME" + ) + + local body + body=$(rest_get "/jobmanager/config") || body="" + if [[ -z "$body" ]]; then + log_error "Could not read GET $REST_URL/jobmanager/config" + record_result "effective-config" "FAIL" + return 1 + fi + + local ok=1 entry key want got + for entry in "${exact_keys[@]}"; do + key="${entry%%=*}"; want="${entry#*=}" + if got=$(printf '%s' "$body" | json_query conf "$key"); then + if [[ "$got" == "$want" ]]; then + log_ok "config $key = $got" + else + log_error "config $key is '$got', expected '$want'" + ok=0 + fi + else + log_error "config $key is absent from the cluster configuration — the append did not take effect" + ok=0 + fi + done + + if (( ok == 0 )); then + record_result "effective-config" "FAIL" + return 1 + fi + record_result "effective-config" "PASS" +} + +# --------------------------------------------------------------------------- +# The authoritative check that checkpointing is actually on for this job. +# +# /jobs/{id}/checkpoints/config is served from the job's own CheckpointCoordinator +# configuration rather than from the raw config file, so unlike /jobmanager/config +# it cannot echo a key back that Flink ignored. It 404s outright when the job has +# no checkpointing, and reports the interval in exact milliseconds. +# +# This is the guard on the one misconfiguration that would let the whole test pass +# having verified nothing. +# --------------------------------------------------------------------------- +assert_checkpointing_enabled() { + log_section "Verify checkpointing is enabled for this job" + + local body + body=$(rest_get "/jobs/$JOB_ID/checkpoints/config") || body="" + if [[ -z "$body" ]]; then + log_error "Could not read GET $REST_URL/jobs/$JOB_ID/checkpoints/config. This endpoint answers only when the job has checkpointing configured, so an empty or 404 response means checkpointing is off and nothing would be captured to recover." + record_result "checkpointing-enabled" "FAIL" + return 1 + fi + + local interval + if ! interval=$(printf '%s' "$body" | json_query int "interval"); then + log_error "Could not read 'interval' from GET $REST_URL/jobs/$JOB_ID/checkpoints/config; the response was: $body" + record_result "checkpointing-enabled" "FAIL" + return 1 + fi + if [[ "$interval" != "$CHECKPOINT_INTERVAL_MS" ]]; then + log_error "The job's checkpoint interval is ${interval}ms, expected ${CHECKPOINT_INTERVAL_MS}ms. The value written to $FLINK_CONF is not the value in effect." + record_result "checkpointing-enabled" "FAIL" + return 1 + fi + log_ok "checkpointing-enabled: interval ${interval}ms" + + # The two-checkpoint containment gate assumes checkpoints do not overlap, which + # is Flink's default of one concurrent checkpoint. Read from the same + # authoritative endpoint rather than inferred. + local max_concurrent + if max_concurrent=$(printf '%s' "$body" | json_query int "max_concurrent"); then + log_info "checkpointing-enabled: max_concurrent = $max_concurrent" + (( max_concurrent == 1 )) || log_warn "More than one concurrent checkpoint is allowed, so the two-checkpoint containment gate is weaker than intended: the second completion no longer implies it started after the first finished." + else + log_warn "Could not read 'max_concurrent'; the containment gate assumes it is 1" + fi + + record_result "checkpointing-enabled" "PASS" +} + +start_cluster() { + log_section "Start Flink standalone cluster" + "$FLINK_HOME/bin/start-cluster.sh" + + log_info "Waiting for JobManager REST API at $REST_URL ..." + local deadline=$((SECONDS + CLUSTER_TIMEOUT)) + while (( SECONDS < deadline )); do + if curl -fsS "$REST_URL/overview" >/dev/null 2>&1; then Review Comment: Could this poll use `rest_get` or add `curl --max-time`? The current request is unbounded, so a single stalled connection can exceed `CLUSTER_TIMEOUT` and potentially hang until the workflow-level timeout. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
