Skip to content

feat(0.22.0): queue polling parity — exponential curve + log tail + auto-cancel - #202

Closed
ottomansky wants to merge 5 commits into
keboola:release/0.22.0from
ottomansky:fiia/pr4-queue-parity
Closed

feat(0.22.0): queue polling parity — exponential curve + log tail + auto-cancel#202
ottomansky wants to merge 5 commits into
keboola:release/0.22.0from
ottomansky:fiia/pr4-queue-parity

Conversation

@ottomansky

Copy link
Copy Markdown
Contributor

Close FIIA P0-3: kbagent job run --wait now matches the polling contract FIIA currently depends on in the official keboola-as-code Go CLI. After this merges FIIA can drop its custom Queue-polling code and depend on kbagent for job run + log tail + timeout-cancel.

Summary

  • Polling cadence: --wait now walks an exponential curve (2s × 30 → 5s × 48 → 15s forever) via new JOB_POLL_CURVE. Legacy fixed 1s behaviour preserved behind --poll-strategy fixed for tests and very short jobs.
  • Log tail on terminal non-success: on FAILED / WARNING / TERMINATED, kbagent fetches the last N Storage Events (default 200, max 5000, 0 disables via --log-tail-lines N) and surfaces them as logTail on the JSON result or details.logTail on errors.
  • Auto-cancel on --timeout: if the local deadline elapses, kbagent issues kill_job against the remote and exits 7 (EXIT_JOB_TIMEOUT_TERMINATED) with the cancelled job dict + logTail attached. If the kill itself fails, surfaces QUEUE_JOB_TIMEOUT (exit 4, retryable=True) so scripts can distinguish "we killed it" from "local gave up, remote may still run".
  • KeboolaApiError.details: optional structured payload carries {job, logTail} on timeout-terminate and {logTail} on queue failures. JSON envelope adds error.details only when non-empty.

Resolution order / behaviour

Scenario Outcome Exit Error code Payload
Job finishes success return job dict 0 resolvedVariableValuesId
Job finishes error raise 1 QUEUE_JOB_FAILED details.logTail
Job finishes warning/terminated return, attach logTail to dict 0 logTail on result
Local --timeout + kill ok raise 7 JOB_TIMEOUT_TERMINATED details.job + details.logTail
Local --timeout + kill fails raise 4 QUEUE_JOB_TIMEOUT details.logTail

Architecture

  • KeboolaClient.wait_for_queue_job(poll_strategy=...) dispatches through a new _iter_poll_intervals() generator. Deadline check trims the last sleep so we never overshoot by one interval. ValueError on invalid strategy (programming error; service layer validates first).
  • KeboolaClient.fetch_job_events(run_id, limit) wraps Storage API GET /v2/storage/events?runId=... — verified live. The Queue API has no /jobs/{id}/events route despite the name (my first attempt returned 404, caught by E2E before PR).
  • JobService._handle_wait_error() centralises failure enrichment; reuses existing kill_job rather than adding a duplicate terminate_job.
  • _safe_fetch_log_tail() / _terminate_and_wait() never raise — log-tail and kill are convenience surfaces; a secondary failure must not mask the primary error.
  • Three-layer boundary respected: client does HTTP, service does orchestration + validation, command does Typer/output only.

Pre-PR review loop

Three Explore agents in parallel, orthogonal focuses:

  • Wire-contract (endpoint paths, poll curve cadence, runId resolution, exit-code mapping): no critical/high findings. Caught my initial /jobs/{id}/events mistake before E2E and before PR.
  • Silent-failure audit: flagged (a) _terminate_and_wait returns None from three paths without distinguishing them, (b) empty-string runId swallow, (c) _handle_wait_error fall-through lacked a debug log. All addressed with logger.debug(...) disambiguation calls. No HIGH/CRITICAL.
  • Test coverage: full matrix of new/changed code paths mapped to tests. All user-facing paths covered. 4 low-severity defensive-only branches untested (fetch_job_events with limit<=0, wait_for_queue_job with non-dict result, _terminate_and_wait kill-throws + GET-returns-non-terminal race, empty-string runId). No tests passing for the wrong reason.

Test plan

  • uv run ruff check src/ tests/ — clean
  • uv run ruff format --check src/ tests/ — clean
  • Full non-E2E suite: 1863 passed, 14 failures are all pre-existing Windows POSIX/chmod issues (confirmed by stashing + running against main at 9253aa2).
  • Live E2E against project 1143 (99_Playground_Max, europe-west3): 4/4 passed in 63s.
    • test_log_tail_surfaced_on_queue_job_failed — deterministic python raiseQUEUE_JOB_FAILED + non-empty details.logTail
    • test_timeout_triggers_remote_kill_and_exits_seven — 120s sleep + --timeout 8 → exit 7, remote status ∈ {terminated, cancelled, terminating}
    • test_fixed_poll_strategy_reaches_completion — 3s job + --poll-strategy fixed → exit 0, status=success
    • test_fetch_job_events_returns_list_on_real_job — direct client call against /v2/storage/events?runId=...

Files touched

  • src/keboola_agent_cli/constants.pyJOB_POLL_CURVE, VALID_POLL_STRATEGIES, DEFAULT_POLL_STRATEGY, DEFAULT_LOG_TAIL_LINES, MAX_LOG_TAIL_LINES, JOB_TERMINATE_GRACE_SECONDS, EXIT_JOB_TIMEOUT_TERMINATED.
  • src/keboola_agent_cli/client.py_iter_poll_intervals, wait_for_queue_job(poll_strategy), fetch_job_events.
  • src/keboola_agent_cli/services/job_service.py_safe_fetch_log_tail, _attach_log_tail, _terminate_and_wait, extended run_job, new _handle_wait_error.
  • src/keboola_agent_cli/commands/job.py--poll-strategy, --log-tail-lines, _render_log_tail, formatter.error(details=...).
  • src/keboola_agent_cli/commands/_helpers.pymap_error_to_exit_code gains QUEUE_JOB_TIMEOUT → 4 and JOB_TIMEOUT_TERMINATED → 7.
  • src/keboola_agent_cli/errors.pyKeboolaApiError.details.
  • src/keboola_agent_cli/models.py + output.pyErrorResponse.details (omitted when empty).
  • src/keboola_agent_cli/hints/definitions/job.py — hint step for log-tail fetch + cadence note.
  • src/keboola_agent_cli/commands/context.py, CLAUDE.md, plugin commands-reference.md — doc updates for the new flags / exit codes.
  • pyproject.toml, plugin.json, changelog.py — 0.21.1 → 0.22.0.
  • tests/test_client.py (+214), tests/test_services.py (+298), tests/test_cli.py (+200), tests/test_e2e.py (+289).

Breaking changes

  • Default polling cadence under --wait changes from fixed 1s to the exponential curve. Scripts that depended on tight 1s polling can opt back in with --poll-strategy fixed. Documented in the changelog.
  • QUEUE_JOB_TIMEOUT now maps to exit code 4 (was 1). Scripts that distinguished by exit code should update; the error code itself is unchanged.
  • JOB_TIMEOUT_TERMINATED is a new error code + new exit 7. Pre-0.22.0 versions of kbagent never produced this.

…uto-cancel

Close FIIA P0-3: `kbagent job run --wait` now matches the cadence and
semantics of the official keboola-as-code Go CLI that FIIA currently
depends on, so FIIA can drop its custom Queue-polling code.

Resolution order / behaviour changes:
  * `wait_for_queue_job` polls on an exponential curve (2s x 30 -> 5s x
    48 -> 15s) instead of the fixed 1s interval. `--poll-strategy fixed`
    keeps the legacy cadence for tests and very short jobs.
  * On FAILED / WARNING / TERMINATED terminal states, fetch the last
    --log-tail-lines events (default 200, 0 disables, max 5000) via the
    Storage Events API `GET /v2/storage/events?runId=...` and attach
    them as `logTail` on the JSON result or `details.logTail` on errors.
    The Queue API has no /jobs/{id}/events route despite the name;
    verified 404 against queue.europe-west3.gcp.keboola.com.
  * If the local --timeout elapses before the remote finishes, issue
    `kill_job` against the Queue, poll briefly for terminal state, and
    raise `JOB_TIMEOUT_TERMINATED` (exit 7) with the cancelled job +
    logTail in `details`. If the kill itself fails, surface the legacy
    `QUEUE_JOB_TIMEOUT` (exit 4, retryable=True) so scripts can tell
    "we killed it" from "local gave up, remote may still run".

Architecture:
  * `KeboolaApiError.details: dict` carries structured context without
    touching the stable top-level JSON envelope; ErrorResponse gains
    an optional `details` field, emitted only when non-empty.
  * Reuses existing `kill_job` (POST /jobs/{id}/kill) -- did not add a
    duplicate `terminate_job` method; the real endpoint is `/kill`.

Tests:
  * tests/test_client.py: 13 new unit tests — curve math, fixed fallback,
    strategy validation, deadline-trim, QUEUE_JOB_FAILED/TIMEOUT paths,
    fetch_job_events list/dict/limit/unknown-shape.
  * tests/test_services.py: 10 new unit tests — poll_strategy threading,
    log-tail attach on warning/terminated, timeout-terminate + fallback,
    log-tail fetch failure swallowed, negative/invalid-arg validation.
  * tests/test_cli.py: 6 new tests — flag forwarding, click.Choice reject,
    range-check reject, exit-7 JOB_TIMEOUT_TERMINATED, exit-4 fallback.
  * tests/test_e2e.py: 4 new live tests against a real Keboola project
    — guaranteed-fail python job produces non-empty logTail; --timeout
    < runtime produces exit 7 + remote kill confirmed; --poll-strategy
    fixed reaches completion; direct fetch_job_events on a finished job.

Pre-PR 3-agent review loop: wire-contract (queue vs storage endpoint,
poll curve cadence, exit-code mapping), silent-failure audit (broad
except, falsy-check skips, exception-mutation pattern), test-coverage
enumeration (per-branch). No critical/high findings; observability
improvements applied (debug logs on unhandled wait codes, terminate
None-return disambiguation, empty-runId guard).
…sort + deadline cap

Round 1 of the post-PR review loop surfaced two actionable findings; this
commit addresses them plus a handful of medium-severity robustness items.

CRITICAL — exception chaining / no mutation:
  _handle_wait_error previously mutated the caught exception's .details
  dict in-place via _attach_log_tail and re-raised the same instance.
  That violates Python's exception chaining guarantee (no `from exc`)
  and risks logTail contamination if an upstream harness ever stored
  and re-raised the same KeboolaApiError. Replace with:
    - a new _enrich_with_tail helper that returns a fresh details dict
      without touching the original
    - `raise KeboolaApiError(..., details=...) from exc` in every
      specialised path (QUEUE_JOB_FAILED, QUEUE_JOB_TIMEOUT + kill ok,
      QUEUE_JOB_TIMEOUT + kill failed fallback)
    - bare `raise` for the fall-through path (observability log already
      in place from the pre-PR review)
  _attach_log_tail is gone.

HIGH — defensive ordering in _safe_fetch_log_tail:
  Storage Events API returns newest -> oldest today, but our code trusted
  that ordering via a comment alone. Sort explicitly by `created` DESC
  with missing-field events sinking to the bottom, so a future API
  ordering change cannot silently invert the surfaced tail.

MEDIUM — deadline overshoot in _terminate_and_wait:
  The polling loop always slept a full second after each GET, even when
  the grace deadline had only milliseconds left. Cap the sleep at
  `min(1.0, max(0.0, deadline - monotonic()))` so the grace period is
  honoured precisely. Added `remaining = deadline - monotonic()` check
  at the top of each iteration with explicit break on <= 0.

MEDIUM — test robustness:
  * TestWaitForQueueJob monotonic mocks now use a clamped callable
    (last-value after exhaustion) instead of a bare `iter([...])`, so a
    refactor adding observability calls produces a sensible reading
    instead of an uninformative StopIteration.
  * New test_run_job_unhandled_wait_code_bubbles_up_unchanged locks the
    observability fall-through for error codes we don't specialise.
  * New test_run_job_failure_exception_chaining_does_not_mutate_original
    proves the CRITICAL fix: original exception.details stays empty,
    the re-raised instance is new, and `__cause__` is set.
  * New TestSafeFetchLogTailDefensiveSort covers the defensive sort with
    both happy-path and missing-`created` edge cases.
  * E2E class now derives its alias from `request.node.name` so
    pytest-xdist parallel runs can't collide on a shared project alias.

All 46 PR4 unit/CLI tests pass. 4/4 E2E tests pass live against project
1143 in 56s.
Round 2 of the post-PR review loop surfaced user-facing polish items:

HIGH — help text jargon:
  `kbagent job run --help` referenced "FIIA" and "keboola-as-code"; neither
  is meaningful to external users. Replaced with plain-English descriptions
  that explain what the curve actually does (starts fast, relaxes toward
  15s) and when `fixed` is useful. Same cleanup in the command docstring
  and plugin references.

HIGH — JSON envelope shapes not documented:
  AGENT_CONTEXT listed the new exit codes but didn't show the JSON shapes
  consumers need to match with jq. Added a "--json response shapes by
  exit code" section with the four distinct envelopes (exit 0 success,
  exit 1 QUEUE_JOB_FAILED, exit 4 QUEUE_JOB_TIMEOUT, exit 7
  JOB_TIMEOUT_TERMINATED) plus a recommended jq pattern that picks up
  the tail regardless of success/failure.

MEDIUM — hint signature:
  `kbagent --hint client job run` was generating
  `fetch_job_events(job_id=...)` but the real client method takes
  `run_id` (Storage Events endpoint is runId-keyed). Updated the hint to
  emit `fetch_job_events(run_id=str(job.get("runId") or job["id"]), ...)`
  so the generated Python code compiles against our client.

MEDIUM — automation guidance:
  Called out that `--log-tail-lines 0` is recommended for automation
  pipelines to keep the JSON envelope compact, both in the Typer help and
  the plugin reference.

MEDIUM — concurrent-kill idempotency:
  Documented in `_terminate_and_wait`'s docstring that concurrent kill
  attempts (user firing `job terminate` while `job run --wait` is
  auto-cancelling the same job) are safe because the Queue's
  "not in killable states" 400 response falls through the existing
  post-kill GET to confirm terminal state.

No behavioural changes. Same 46/46 unit tests pass; same 4/4 E2E tests
pass live against project 1143 in 59s. SKILL.md regenerated (no diff).

Round-2 review consolidation:
  * Fix-verification agent: all round-1 fixes verified correct, no
    regressions detected.
  * API-ergonomics agent: 2 HIGH (jargon + JSON shapes, both addressed
    here), 2 MEDIUM (hint sig + automation default, addressed).
  * Integration agent: 0 critical/high, 2 MEDIUM -- double-kill
    idempotency (documented), error-code enum for PR9 (intentionally
    deferred, that's PR9's scope).
@ottomansky
ottomansky marked this pull request as ready for review April 22, 2026 20:59
padak
padak previously approved these changes Apr 22, 2026
@padak
padak dismissed their stale review April 22, 2026 21:41

Replacing with English version for broader team visibility.

@padak padak left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve — merge-ready

Solid feature with real value (FIIA P0-3 parity with the Go CLI). All three review rounds addressed in commits. No blockers.

Nice-to-have (follow-up)

  • Magic number: src/keboola_agent_cli/services/job_service.py:150, 156 has time.sleep(min(1.0, ...)) with a hardcoded 1.0. Per CONTRIBUTING.md this should live in constants.py as JOB_TERMINATE_POLL_INTERVAL = 1.0.
  • SKILL.md: Run make skill-gen locally — --poll-strategy and --log-tail-lines should appear in the decision table. The CI freshness check would catch it, but belt + braces.
  • Dead code: src/keboola_agent_cli/commands/job.py:380-383 branch status == "error" in human-mode rendering is effectively unreachable (the service always raises QUEUE_JOB_FAILED). Either remove it, or add a comment "defensive for future service-layer changes".

Positives

  • The JOB_POLL_CURVE curve (2s×30 → 5s×48 → 15s) is elegant; the _iter_poll_intervals generator is well tested.
  • Extending KeboolaApiError.details: dict + ErrorResponse with exclude_none=True keeps the .error.details.* envelope stable.
  • The new exit code 7 (JOB_TIMEOUT_TERMINATED) vs. existing 4 (QUEUE_JOB_TIMEOUT, retryable) nicely distinguishes the reason.
  • Test matrix 406+232+200+293 = 1131 test lines, all passing.

Per CONTRIBUTING.md, magic numbers belong in constants.py. Replace the
hardcoded 1.0 sleep cadence in _terminate_and_wait's grace-window loop
with JOB_TERMINATE_POLL_INTERVAL so the cadence is documented and
centrally adjustable.
…ring

The service layer always raises QUEUE_JOB_FAILED for failed jobs, so the
elif status == 'error' branch inside job_run's human-mode rendering was
unreachable. Remove it and collapse the trailing else into a flatter
if/elif/else, keeping the warning branch's log-tail attached. A short
inline comment preserves the invariant for future readers in case the
service-layer contract changes.
@ottomansky

Copy link
Copy Markdown
Contributor Author

Addressed the magic-number + dead-code nice-to-haves:

  • refactor(constants): extract JOB_TERMINATE_POLL_INTERVAL magic numberdb23469 — moved 1.0 from services/job_service.py:150,156 into constants.py.
  • refactor(job): remove dead status=='error' branch in human-mode rendering1ee82fb — removed the unreachable branch at commands/job.py:380-383; left a short comment inside the warning branch explaining why error is absent (service always raises QUEUE_JOB_FAILED).

Re: SKILL.md — ran make skill-gen, zero diff. Looking at scripts/generate_skill.py:75, the generator by design only surfaces required parameters in the decision table; --poll-strategy and --log-tail-lines are both optional-with-default, so they stay out. CI freshness check is green. Happy to widen the generator to include optional flags too if that's preferred — didn't want to scope-creep into a generator change on this PR.

Targeted tests: 69/69 pass. make lint format-check skill-check version-check changelog-check all green.

@padak
padak changed the base branch from main to release/0.22.0 April 23, 2026 11:56
padak added a commit that referenced this pull request Apr 23, 2026
…cel (#202)

Exit code 7 (JOB_TIMEOUT_TERMINATED) distinguishes local-timeout-killed from
queue-side failure (exit 4). Log tail N events attached to logTail on failure
or dict on WARNING/TERMINATED. Legacy 1s fixed polling preserved behind
--poll-strategy fixed for tests and very short jobs.

Polling curve: 2s x 30 -> 5s x 48 -> 15s forever.

KeboolaClient.wait_for_queue_job gains poll_strategy kwarg.
KeboolaClient.fetch_job_events wraps Storage API /v2/storage/events?runId=.
JobService._handle_wait_error centralises failure enrichment.

Max's review fixes: existing error codes reused (no new literal strings).
@padak

padak commented Apr 23, 2026

Copy link
Copy Markdown
Member

Integrated into release/0.22.0 as squash commit 0078cf6 (rebased locally on top of v0.21.2 to resolve merge conflicts with main; all Max's review fixes preserved). Original branch fiia/... unchanged.

Full integration PR coming as release/0.22.0 -> main.

@padak padak closed this Apr 23, 2026
padak added a commit that referenced this pull request Apr 23, 2026
- sync-workflow.md: new "Adopting an existing kbc Go CLI checkout" section for sync init --adopt-existing (#201).
- gotchas.md: new entries for exit code 7 (JOB_TIMEOUT_TERMINATED), --poll-strategy fixed, --log-tail-lines N, logTail response semantics (#202); new section for --deny-writes / --deny-destructive (#203); new section for sync init --adopt-existing (#201).
- workspace-workflow.md: new "Orphan detection + garbage collection" section for workspace list --orphaned + workspace gc (#204).
- commands-reference.md: sync init --adopt-existing flag documented.
- README.md: "What it does" table + "All commands" block updated with the 15 new commands shipped in 0.22.0 (flow CRUD, config metadata, workspace GC, storage describe, project pin + firewall, queue polling parity).
ottomansky pushed a commit to ottomansky/keboola-agent-cli that referenced this pull request Apr 30, 2026
…tion

Three PRs' changelog entries were dropped during the git merge --squash
conflict resolution of release/0.22.0:

- keboola#201 ErrorCode enum + sync init --adopt-existing (3 entries)
- keboola#202 queue polling parity (5 entries)
- keboola#205 storage describe-bucket/table/column/batch (6 entries)

Without this fix, 'kbagent changelog' on installed 0.22.0 would show
only 18 of the 32 features shipped in this release. Adding the missing
14 entries before tagging v0.22.0.
@ottomansky
ottomansky deleted the fiia/pr4-queue-parity branch May 11, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants