Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/broken-sweep-filter-not-detector.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
---
"@objectstack/service-automation": patch
---

fix(service-automation): the documented broken-sweep predicate is a first FILTER, not the detector (#12685)

`patch`, and not empty: `sys_automation_run`'s field descriptions are shipped,
translated, operator-facing text — they are what an admin reads in Setup while
wiring an alert they will then trust for months. No counter, no schema and no
engine behaviour changes here; the run summary measured by #4354 is correct and
untouched.

## The wrong claim

`acted_count` advertised `selected_count > 0 AND acted_count = 0 AND
unmeasured_count = 0` as *the* broken-sweep signal, unqualified. Measured A/B on
one graph pair through the real engine — a healthy idempotent sweep (re-select
the same records, gate each one on "was this already handled") and a dead gate
(#4347's shape, the gate sitting in front of the lookup) — **both** report
`selected > 0, acted 0, unmeasured 0`. The predicate cannot make the one
distinction it was advertised to make.

"Over N consecutive runs" does not rescue it either: the healthy steady state
trips it on *every* run for as long as the outstanding work stands, so it is
persistent rather than transient. Consecutiveness filters flapping, which is a
different failure.

Why a wrong sentence here is worse than a wrong sentence elsewhere: a detector
that fires during normal operation gets muted, and a muted broken-sweep detector
is the same silence #4347 produced — with the added cost that it now *looks*
monitored.

## What the descriptions say now

- `acted_count` states the predicate as the **first filter** and names the
discriminator: a healthy skip is accounted for by a read the run performed
(the lookup the gate depends on shows `runs > 0` and `selected > 0` in
`summary_json.nodes[]`), while a dead gate skips just as often with nothing
behind it (`runs: 0`, or `selected: 0`).
- `skipped_count` points at the same fold — `gates[]` names which edge closed
and how often, `nodes[]` says whether the lookup behind it found anything.
- `unmeasured_count` keeps its own point (why the third clause exists) and now
calls the query a filter rather than an alert.

The discriminating data was already shipped by #4354; nothing new is measured
and no detector is implemented in the platform. `run-summary.test.ts` pins the
pair as executable evidence: both shapes match the filter, and the per-node fold
separates them. `content/docs/automation/flows.mdx` carries the same correction
with the measured table and the two authoring shapes that make a sweep's signal
quiet in its healthy steady state.
88 changes: 75 additions & 13 deletions content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -290,8 +290,8 @@ defineStack({
```

A step that calls a declared writer is counted as an effect the platform cannot
measure (`unmeasured`), never as zero — so the broken-sweep query
`selected > 0 AND acted = 0 AND unmeasured = 0` stops firing on that flow, and
measure (`unmeasured`), never as zero — so the broken-sweep filter
`selected > 0 AND acted = 0 AND unmeasured = 0` stops matching that flow, and
keeps working on every other flow that calls a function. Declaring changes what
is *reported*, not what is *allowed*: an undeclared writer is still counted as
having written nothing, and no runtime check can catch it — a function is
Expand DownExpand Up@@ -785,17 +785,18 @@ child dispatched an uncountable effect knows its own `acted` is incomplete.

The same counts land on `sys_automation_run` as **queryable columns**
(`selected_count`, `acted_count`, `skipped_count`, `unmeasured_count`, plus a
`summary_json` breakdown), so a broken sweep is something you can alert on
`summary_json` breakdown), so a broken sweep is something you can query for
rather than notice:

```typescript
// Runs that selected work and did none of it, newest first.
const suspect = await engine.find('sys_automation_run', {
// CANDIDATE runs: selected work, did none of it, and measured everything they
// did do. The first filter of the detector — not the detector; see below.
const candidates = await engine.find('sys_automation_run', {
where: {
status: 'completed',
selected_count: { $gt: 0 },
acted_count: 0,
// Without this clause the alert fires on every healthy connector-driven
// Without this clause the filter matches every healthy connector-driven
// flow: those runs report acted 0 because the count is INCOMPLETE, not zero.
unmeasured_count: 0,
started_at: { $gte: since },
Expand All@@ -804,13 +805,74 @@ const suspect = await engine.find('sys_automation_run', {
});
```

`selected > 0 && acted == 0 && unmeasured == 0` over several consecutive runs is
a near-perfect broken-sweep detector — the case that is otherwise invisible,
because nobody is watching automation until it has already been dead for a
month. A single such run is not proof of anything: a sweep whose work is all
already done reports the same thing legitimately, which is why the signal is
*consecutive* runs, and why the platform reports the counts rather than raising
the alarm itself.
#### The filter is not the detector

<Callout type="warn">
Do not wire an alert straight to that query. It matches a **healthy idempotent
sweep** exactly as it matches a dead gate, and it does so on every run — so the
alert gets muted, and a muted broken-sweep detector is the same silence these
counters exist to end, with the added cost that it now *looks* monitored.
</Callout>

Any flow that re-selects the same records each run and gates each one on "was
this already handled" satisfies the filter in its healthy steady state, for as
long as the prior work stands — and that is the ordinary way to write an
idempotent sweep, not an exotic shape. Measured on one graph pair driven through
the engine (pinned in `run-summary.test.ts`), where the two runs differ only in
where the gate sits relative to the lookup:

| run | totals | the filter | the lookup behind the gate | gate skips |
| :--- | :--- | :--- | :--- | :--- |
| **healthy steady state** — every stalled deal already nudged | selected 6, acted 0 | **matches** | `runs: 3`, `selected: 3` | 3 |
| **dead gate** — gate in front of the lookup, never opens | selected 3, acted 0 | **matches** | `runs: 0` | 3 |
| **wrong gate** — lookup runs, finds nothing, gate closes anyway | selected 3, acted 0 | **matches** | `runs: 3`, `selected: 0` | 3 |
| genuinely idle — nothing stalled | selected 0, acted 0 | quiet | — | 0 |

Requiring the match **over N consecutive runs does not rescue it**: a healthy
idempotent sweep trips it on *every* run while the outstanding work stands, so
the steady state is persistent rather than transient. Consecutiveness filters
flapping, which is a different failure.

#### What separates them: the per-node fold

`summary_json` answers the question the totals cannot — **were the skips
accounted for?** A gate that closes is not a defect; a gate that closes on
nothing is.

- **Healthy** — the run reached the state that justifies each skip. The lookup
the gate depends on ran (`runs` covering the gate's `skipped`) and *found*
something (`selected > 0`). Every skip has a find behind it.
- **Broken** — the same gate closed just as often with nothing behind it: the
lookup never ran (`runs: 0` — the gate sits upstream of it) or ran and found
nothing (`selected: 0`). Nothing the run measured justifies a single skip.

```typescript
// Convict a candidate: name the node whose read your gate depends on.
const summary = JSON.parse(run.summary_json as string) as FlowRunSummary;
const skips = summary.gates.reduce((n, g) => n + g.skipped, 0);
const lookup = summary.nodes.find((n) => n.nodeId === 'find_existing_task');
const broken = skips > 0
&& (!lookup || lookup.runs === 0 || (lookup.selected ?? 0) < skips);
```

`gates[]` names *which* edge closed and how often, so the alert can point at a
node instead of at a flow. `detailOmitted` marks the one case this read cannot
work with: persistence dropped `nodes` / `gates` to keep the row bounded, and
only the totals survive.

How you author the sweep decides how good its signal can be:

1. **Exclude the already-handled records in the query** wherever the filter can
express it. The healthy steady state then reports `selected: 0` and never
matches at all — the quietest detector available.
2. Where the "already handled?" answer lives in another object (an open task, a
sent notification), **make it a real lookup node**. The run then records the
find that accounts for the skip, which is the evidence the rule above reads.

A gate deciding on a field of a record the run already read leaves no second
read to account for its skips; there the summary cannot separate a correct
decision from a stuck one, because the difference is in the data rather than in
the counts.

Rows written before summaries existed carry `null` counts, not `0` — "not
measured" must not read as "measured zero". The log line defaults to `info`;
Expand Down
Loading
Loading