Skip to content

feat(job): client-side idempotency key for run_job (#427) - #430

Merged
padak merged 1 commit into
feat/sdk-hardening-426-427-428from
feat/427-job-idempotency
Jun 16, 2026
Merged

feat(job): client-side idempotency key for run_job (#427)#430
padak merged 1 commit into
feat/sdk-hardening-426-427-428from
feat/427-job-idempotency

Conversation

@padak

@padak padak commented Jun 16, 2026

Copy link
Copy Markdown
Member

Part 2 of 3 of the SDK-hardening collector branch (#426/#427/#428). Builds on the typed JobResult from #428.

What

Closes #427. Optional --idempotency-key on job run (+ Client.run_job(idempotency_key=...)) so a replayed, interrupted build step does not create a duplicate side-effecting job.

Why client-side

The Keboola Queue API POST /jobs accepts no client-supplied idempotency token. Verified against the live OpenAPI spec (queue.keboola.com/docs/swagger.yaml, v1.3.8) and the server source (keboola/job-queue apps/public-api/src/JobDefinition.php): an internal deduplicationId exists (DB-unique, ~Nov 2023) but is daemon-only for fan-out child jobs and is never read from the public create-job request. So dedup must be client-side.

How

  • JobIdempotencyStore — persistent key -> {job_id, component_id, config_id, branch_id} map; atomic write under an fcntl lock at 0600, mirroring ConfigStore. Corrupt-file tolerant (never wedges a run).
  • run_idempotent_job — probe-before-create policy:
    • prior still-running / non-failed → return it (idempotent_replay=true, no new side effect)
    • prior failed (error/terminated/cancelled) → re-run
    • --force-rerun → always fresh
    • key reused for a different component/config → INVALID_ARGUMENT (never returns the wrong job)
    • prior purged (404) → re-run; any other fetch error → propagate (never silently duplicate)
  • Service path defaults the store to <config-dir>/job_idempotency.json. The config-dir-free SDK facade requires the consumer to supply a store (constructor or per-call) and raises ValueError if a key is passed with no store.
  • JobResult.idempotent_replay added; CLI prints a note in human mode, --json carries the flag.

Scope / limits

Dedup is per store file (per machine). A replay from a different machine that does not share the file is not deduplicated. A follow-up upstream request to expose the Queue's internal deduplicationId would enable DB-enforced cross-machine dedup; until then this is the safe interim.

Docs

JobIdempotencyStore exported from the package root. Synced: CLAUDE.md command list, AGENT_CONTEXT, commands-reference.md, gotchas.md (since v0.63.0), library-workflow.md.

Tests

tests/test_job_idempotency_store.py (store CRUD, atomic/0600, corrupt-file, full dispatch policy incl. 404/500/collision/force-rerun), service wiring in tests/test_services.py, facade in tests/test_lib.py, CLI flag forwarding + replay note in tests/test_cli.py. make check green (4068 passed). No new CLI command → no E2E command required (the dedup logic is client-side, covered by unit tests).


Open in Devin Review

Add an optional `--idempotency-key` to `job run` (and `Client.run_job`)
so an agentic orchestrator can replay an interrupted, side-effecting
build step without creating a duplicate job.

The Keboola Queue API `POST /jobs` accepts NO client-supplied idempotency
token -- verified against the live OpenAPI spec (v1.3.8) and the server
source (an internal `deduplicationId` exists but is daemon-only, never
read from the public create-job request). So dedup is implemented
client-side:

- JobIdempotencyStore: a persistent `key -> {job_id, component, config}`
  map (atomic write under an fcntl lock, 0600), mirroring ConfigStore.
- run_idempotent_job: probe-before-create. A prior still-running or
  non-failed job is returned (no new side effect); a prior FAILED run is
  re-run; `--force-rerun` always creates fresh. Reusing a key for a
  different component/config raises INVALID_ARGUMENT rather than returning
  the wrong job. A purged (404) prior re-runs; any other fetch error
  propagates (never silently duplicates).
- Service path defaults the store to `<config-dir>/job_idempotency.json`;
  the config-dir-free SDK facade requires the consumer to supply a store
  (constructor or per-call) and raises if a key is given without one.
- JobResult gains `idempotent_replay`; CLI prints a note, --json carries it.

Exports JobIdempotencyStore from the package root. Docs synced (CLAUDE.md
command list, AGENT_CONTEXT, commands-reference, gotchas since v0.63.0,
library-workflow). Unit tests cover the store, the dispatch policy, the
service wiring, the facade, and the CLI flag. make check green (4068).
@padak
padak merged commit b4ec7c1 into feat/sdk-hardening-426-427-428 Jun 16, 2026
1 check was pending
@padak
padak deleted the feat/427-job-idempotency branch June 16, 2026 20:57

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 0 potential issues.

Open in Devin Review

padak added a commit that referenced this pull request Jun 17, 2026
#426/#427/#428) (#432)

* feat(lib): typed SDK return models + py.typed marker (#428) (#429)

Typed pydantic return models (JobResult/QueryResult/UploadTableResult/SyncPushResult/ConfigDetailResult) + py.typed marker + typed facade wrappers. Part 1/3 of the SDK-hardening collector.

* feat(job): client-side idempotency key for run_job (#427) (#430)

Optional --idempotency-key on job run + Client.run_job: client-side dedup store (Queue API has no server token, verified vs live spec). Part 2/3 of the SDK-hardening collector.

* feat(sync): clone composite + flow-task configId remap (#426) (#431)

kbagent sync clone + SyncService.clone_project: copy a reference tree, apply bucket/variable/instance overrides, push fresh with flow-task + variable-link remap (new push Phase D). Part 3/3 of the SDK-hardening collector.

* fix(review): address #432 review findings (Devin + kbagent-pr-reviewer)

- context.py AGENT_CONTEXT (Devin BUG): the `sync clone` block was inserted
  between the `sync push` one-liner and its multi-paragraph continuation, so
  push semantics (encryption fail-closed, fresh-CREATE, --branch) mis-read as
  clone properties. Move the clone block AFTER the full push description.
- keboola-expert.md (NB-1): add a `sync clone` row to the §2 tool matrix so the
  agent recommends the composite for "provision a new project from a reference",
  not the manual pull+edit+push flow.
- library-workflow.md (NB-2): clarify CloneResult DOCUMENTS the dict shape that
  SyncService.clone_project returns (the service returns a plain dict; wrap via
  model_validate) -- it is not returned as a typed instance.
- test_result_models.py (NB-2): add CloneResult contract tests (embedded
  SyncPushResult, ok property, dry_run-without-push) + cover it in the base loop.
- test_e2e.py (NB-3): document why the clone E2E step is --dry-run only (no fresh
  second project in the single-project E2E harness; push path is unit-covered).
- job_idempotency_store.py (NIT-2): comment that force_rerun intentionally
  bypasses the collision guard (don't hoist the check out of the branch).
- sync-workflow.md: note that `sync clone --dry-run` still writes --target-dir.

NIT-1 (formatter: Any) intentionally NOT applied: all sibling _format_* helpers
in sync.py use `Any` -- matching the established pattern. NB-4 (sync_service.py
LOC split) tracked as a follow-up tech-debt task. make check green (4092).
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.

1 participant