Skip to content

Commit 5485192

Browse files
joshpankaJosh Panka
andauthored
feat: backfill — re-run a watch action over the previous step's passes (#163)
Adds `magpie backfill`: queue a re-run of one action over the feed items the PREVIOUS step passed (or, for a chain-head target, the watch's feed items in a window), so its output can be regenerated after a config change (e.g. an `extract` action's field set changed) without waiting for new items. Design: - Async job, off the request path. POST /v1/actions/<id>/backfill writes a PENDING WatchActionBackfill row and returns fast; a new `process_due_backfills` cron (stage 1b, before the drain) claims it, does the select/delete/enqueue, marks it terminal; the existing `process_due_runs` drain executes the enqueued runs and `enqueue_next` advances the chain. - Additive by default (fill only never-processed items); `--replace` regenerates the WHOLE chain from the target down (deletes the target's + every downstream action's terminal runs for the matched items, then re-enqueues the target; downstream refills as the chain advances). The idempotent enqueue is what re-creates the deleted runs, so the delete IS the replace. - Crash-recovery mirrors the run drain: guarded-CAS claim/reap/terminal-write, a `replace_deleted_at` delete-once marker stamped before enqueue (a retried job never re-deletes drain-regenerated runs), FAILED transient-until-exhausted (retryable while completed_at is unset, terminal once set). - Memory: the source set is never materialized. Explicit service methods return definitive objects off one private queryset builder per source — a count (int), a Subquery (the `feed_item_id__in` operand for the server-side delete + preview counts), and a keyset iterator (the enqueue stream, pooler-safe unlike a server-side cursor). Preview and executor share resolve_present + chain_from + the terminal predicate so their counts can't drift. CLI: `magpie backfill submit --action <id> <window> [--replace]` / `status <id>` / `list` — a flat top-level noun like `activity`/`delivery` (submit scopes the action via --action; status/list address the job by its own id). `--dry-run` shows a read-only size preview; an interactive submit previews + confirms; a piped submit requires --yes (a pipe can't silently enqueue LLM-cost work). Window is required (no all-retention default). Wire contract in openmagpie-schema (BackfillPreview / BackfillJob / BackfillListResponse + WatchActionBackfillState); `replace` coerced via a DRF BooleanField (parser-agnostic; the JSON/form string "false" can't trigger the destructive delete). Routes parent-qualified: /v1/action-backfills[/<id>] (list + status), submit at /v1/actions/<id>/backfill. process_due_backfills wired into the tick before the drain (+ down-jobs). Docs in apps/core + apps/cli AGENTS.md. Tested: core endpoint/operation/Global suites (additive, replace-whole-chain, delete-once under reap+retry, chain-head source, >page keyset enqueue in autocommit, string-bool coercion) + CLI suite (validation, wiring, dry-run guard, pipe gate, confirm accept/decline). Verified end to end live against a real extract action. Claude-Session: https://claude.ai/code/session_01Xe6qZzz7jCaAhURhD8vmux Co-authored-by: Josh Panka <josh.panka@obris.io>
1 parent c4efb3a commit 5485192

42 files changed

Lines changed: 2750 additions & 60 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cli/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ cli/src/openmagpie/
4141
The command tree splits by how data is used, not by ORM containment.
4242

4343
- **What one parent OWNS nests** (real containment): `feed` + `feed source` + `feed item`, `watch` + `watch action`. This holds whether the child is operator-authored config (`source`, `action`) or server-produced content (`item`, read-only: `list` / `get`, no create / edit / delete). It is a part of exactly one feed/watch, so you address it under that parent.
44-
- **Observability you query is flat and top-level**, filter-first, addressed by a scope flag, never walked through its parents: `activity`, `delivery`. These are run/delivery audit that spans an action over time (not part of the action's definition), so they stand on their own rather than nesting under `watch action`.
44+
- **Action-associated nouns you address flatly are top-level**, filter-first, scoped by a flag, never walked through their parents: `activity`, `delivery`, `backfill`. Runs/delivery audit span an action over time (not part of its definition); a backfill JOB is queued for an action but addressed by its own id (`status`/`list` aren't watch- or action-scoped). All three back onto parent-qualified `action-*` routes yet stand on their own rather than nesting under `watch action`. (`backfill` also has a `submit` mutation — `--action`/`-a` scoped — but the flat placement follows from how its reads are addressed.)
4545

4646
Argument rule, uniform across every noun:
4747

@@ -72,7 +72,7 @@ Short flags are decided once here, not per command. A flag gets a short only whe
7272

7373
`--list` is RETIRED by the reshape, freeing its short: the `summary` vs `list` subcommand split replaced it, so `-l` is now `--limit` everywhere.
7474

75-
**Every read offers machine output.** Uniformly across `feed` / `watch` / `activity` / `delivery`, every `get` and `list` view renders a human table by default and accepts `--jsonl` (NDJSON: one object per row, or the single object for `get`) + `-o <file>` (write there instead of stdout). New read commands MUST carry both. Single-object `get` and the unpaginated lists (`feed source`, `watch action`, which the server returns in one call) are single-shot; the cursor-paginated lists (`feed`, `watch`, `feed item`, `activity`, `delivery`) page as below.
75+
**Every read offers machine output.** Uniformly across `feed` / `watch` / `activity` / `delivery`, every `get` and `list` view renders a human table by default and accepts `--jsonl` (NDJSON: one object per row, or the single object for `get`) + `-o <file>` (write there instead of stdout). New read commands MUST carry both. Single-object `get` and the unpaginated lists (`feed source`, `watch action`, which the server returns in one call) are single-shot; the cursor-paginated lists (`feed`, `watch`, `feed item`, `activity`, `delivery`, `backfill`) page as below. (`backfill` is a flat top-level async-job noun: `submit` scopes the action via `--action`/`-a`, `status <job_id>` / `list` address the job by its own id.)
7676

7777
**Exception -- `activity export` is file-only.** An export is an *artifact* (hand a file to a spreadsheet / Claude), not a browseable read view, so it inverts the contract above: `-o <file>` is REQUIRED (it never streams to stdout), and `--jsonl` selects the file's FORMAT (NDJSON instead of the default CSV) rather than toggling a stdout stream. `magpie activity export -a X --jsonl | jq` is therefore intentionally unsupported -- use `magpie activity list -a X --jsonl` for the stdout-streamable view. A future `delivery export` follows this same file-only shape. This is the ONLY read-surface command exempt from the "accepts `--jsonl` + `-o`, streams to stdout" rule; new browseable views still MUST carry both.
7878

apps/cli/src/openmagpie/api/watch.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import builtins
1212
from typing import Any
1313

14+
from openmagpie_schema.backfill import BackfillJob, BackfillListResponse, BackfillPreview
1415
from openmagpie_schema.watch import (
1516
WatchActionMutationResponse,
1617
WatchActionWire,
@@ -27,6 +28,9 @@
2728
from ..http import MagpieClient
2829

2930
__all__ = [
31+
"BackfillJob",
32+
"BackfillListResponse",
33+
"BackfillPreview",
3034
"WatchActionMutationResponse",
3135
"WatchApi",
3236
"WatchInput",
@@ -122,3 +126,36 @@ def edit_action(
122126

123127
def delete_action(self, action_id: str) -> None:
124128
self._http.delete(routes.actions.detail(action_id))
129+
130+
# ── Backfill (re-run an action over the previous step's passes) ─────
131+
132+
def preview_backfill(self, action_id: str, *, replace: bool, windows: dict[str, str]) -> BackfillPreview:
133+
"""POST `?dry_run=true` — a synchronous size preview, NO job queued. `windows`
134+
are the RAW values (`7d` / ISO); the server resolves them. The caller should
135+
confirm `preview.dry_run` (the server-honored marker) before trusting it."""
136+
raw = self._http.post(
137+
routes.actions.backfill(action_id), json_body={"replace": replace, **windows}, params={"dry_run": "true"}
138+
)
139+
return BackfillPreview.model_validate(raw)
140+
141+
def submit_backfill(self, action_id: str, *, replace: bool, windows: dict[str, str]) -> BackfillJob:
142+
"""POST a backfill of `action_id` — QUEUE it (the cron runs it) and return the
143+
job. `windows` are the RAW values the server resolves against its clock."""
144+
raw = self._http.post(routes.actions.backfill(action_id), json_body={"replace": replace, **windows})
145+
return BackfillJob.model_validate(raw)
146+
147+
def get_backfill(self, backfill_id: str) -> BackfillJob:
148+
"""GET one backfill job's state + progress (the status readback)."""
149+
raw = self._http.get(routes.action_backfills.detail(backfill_id))
150+
return BackfillJob.model_validate(raw)
151+
152+
def list_backfills(self, *, after: str | None = None, limit: int | None = None) -> BackfillListResponse:
153+
"""One page of this account's backfill jobs (newest-first). `after` = id of
154+
the last job from the previous page; `next_cursor` is None when done."""
155+
params: dict[str, Any] = {}
156+
if after:
157+
params["after"] = after
158+
if limit is not None:
159+
params["limit"] = limit
160+
raw = self._http.get(routes.action_backfills.collection, params=params or None)
161+
return BackfillListResponse.model_validate(raw)

apps/cli/src/openmagpie/cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .commands import activity_export as _activity_export # noqa: F401
1212
from .commands.activity import activity_app
1313
from .commands.auth import auth_app
14+
from .commands.backfill import backfill_app
1415
from .commands.delivery import delivery_app
1516
from .commands.feed import feed_app
1617
from .commands.telemetry import telemetry_app
@@ -64,6 +65,11 @@ def main(
6465
name="delivery",
6566
help="Audit an action's outbound webhook calls.",
6667
)
68+
app.add_typer(
69+
backfill_app,
70+
name="backfill",
71+
help="Re-run an action over the previous step's passes: submit / status / list.",
72+
)
6773
app.add_typer(
6874
telemetry_app,
6975
name="telemetry",

apps/cli/src/openmagpie/commands/_shared/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
_print_detail,
5353
_print_next_page,
5454
)
55-
from .timeflags import _DEFAULT_WINDOW, _build_windows
55+
from .timeflags import _DEFAULT_WINDOW, _build_windows, validated_window_params
5656

5757
__all__ = [
5858
"CONTRACT_MISMATCH_MESSAGE",
@@ -90,4 +90,5 @@
9090
"_ts",
9191
"_unreachable_message",
9292
"col",
93+
"validated_window_params",
9394
]

apps/cli/src/openmagpie/commands/_shared/timeflags.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,29 +23,49 @@
2323
_DEFAULT_WINDOW = "7d"
2424

2525

26-
def _build_windows(
26+
def validated_window_params(
2727
*,
2828
occurred_since: str | None = None,
2929
occurred_until: str | None = None,
3030
completed_since: str | None = None,
3131
completed_until: str | None = None,
32-
) -> tuple[dict[str, str], bool]:
32+
) -> dict[str, str]:
3333
"""Map the run-window flags to the RAW query values the server resolves (a
34-
duration like `7d` or an ISO datetime). The values are validated here (a fast
35-
BadParameter, via the shared resolver) but sent RAW -- the server owns the
36-
authoritative resolution (its clock), the until-without-since bound, and the
37-
ordering check. Returns `(windows, defaulted)`: with NO flag set, default to the
38-
last `_DEFAULT_WINDOW` on completion and flag it for the caller to announce."""
34+
duration like `7d` or an ISO datetime) and validate them (format + ordering) via
35+
the shared resolver, raising `typer.BadParameter` on a bad value -- a fast, local
36+
error before the round-trip. The values are still sent RAW: the server owns the
37+
authoritative resolution (its clock) + the until-without-since bound. The EMPTY
38+
result (no flag set) is returned as-is; each caller decides what that means (an
39+
export defaults it, a backfill requires it)."""
3940
raw = run_window_params(
4041
occurred_since=occurred_since,
4142
occurred_until=occurred_until,
4243
completed_since=completed_since,
4344
completed_until=completed_until,
4445
)
4546
try:
46-
resolve_run_windows(raw, now=datetime.now(UTC)) # validate (format + ordering); the raw values are sent
47+
resolve_run_windows(raw, now=datetime.now(UTC))
4748
except ValueError as exc:
4849
raise typer.BadParameter(str(exc)) from None
50+
return raw
51+
52+
53+
def _build_windows(
54+
*,
55+
occurred_since: str | None = None,
56+
occurred_until: str | None = None,
57+
completed_since: str | None = None,
58+
completed_until: str | None = None,
59+
) -> tuple[dict[str, str], bool]:
60+
"""The export's window resolution: `validated_window_params` + the no-window
61+
default. Returns `(windows, defaulted)`: with NO flag set, default to the last
62+
`_DEFAULT_WINDOW` on completion and flag it for the caller to announce."""
63+
raw = validated_window_params(
64+
occurred_since=occurred_since,
65+
occurred_until=occurred_until,
66+
completed_since=completed_since,
67+
completed_until=completed_until,
68+
)
4969
if raw:
5070
return raw, False
5171
return {"completed_since": _DEFAULT_WINDOW}, True

0 commit comments

Comments
 (0)