Commit a2f4d03
authored
SEP-1894: Reconcile a sync run abandoned with no items left active (#1454)
## Summary
- A worker killed between its last `SyncItem` write and
`BaseSyncer.__aexit__` leaves a `SyncInstance` stuck at `RUNNING` with
zero `PENDING`/`RUNNING` items, and nothing reconciles it: the stale-run
reclaim only ran once `create()` had already detected an item conflict,
and its candidate query was keyed on non-terminal items — so an
all-terminal run was invisible to both halves. The inventory sync-status
endpoint kept reporting that run as running until ten later runs aged it
out of the window.
- `reclaim_stale_runs()` gains a second candidate class: a `RUNNING` run
whose items have all gone terminal. It is still grouped over `SyncItem`
rows, so a run that wrote no item at all — which carries no activity to
measure — stays out of scope, and `NOT IN` against the existing
in-progress subquery keeps the two candidate sets disjoint. `create()`
now calls the reclaim whether or not an item conflict exists, and reads
the conflicting items once, after the reclaim — reclaim → check on both
paths.
- The fence `UPDATE` re-asserts item staleness alongside the instance
status. The candidate query commits separately from the fence, so a
worker that resumed and either touched an existing item or opened a new
one is progressing after all and must not be failed retroactively.
`snapshot_complete` is deliberately left `NULL` on a reclaim, so a
partially applied run is never counted as a complete generation.
### Review round (`81582d60b`)
- **The pre-reclaim conflict read was dead on the reclaim path.** It was
assigned before the reclaim and then unconditionally overwritten
whenever `stale_after` was set, and `reclaim_stale_runs` consumes none
of it, so on the primary path that `SELECT` fed no later statement. The
read now happens once, below the reclaim block, which leaves both paths
behaving identically and drops a round trip per run start.
`test_create_refuses_a_run_that_resumed_during_the_reclaim` still fails
if the read moves back above the reclaim.
- **The fence's staleness re-assertion reused the candidate subquery
bare**, so it compiled to a `GROUP BY` over the whole `syncitem` table
with no syncer or candidate predicate. It now repeats the
`in_(stale_instance_ids)` narrowing, which lands as a `WHERE` before the
`GROUP BY` and makes the subquery index-seekable. The semantics are
unchanged because every group is one instance's items, so removing other
instances' rows cannot move a surviving group's `max()` — and the two
spare-conditions
(`test_reclaim_spares_a_run_whose_worker_touched_an_item`,
`test_reclaim_spares_an_idle_run_that_started_a_new_item`) still fail
when the predicate is dropped altogether.
### Risks
- **A live long-running run can now be fenced.** A run that writes its
top-level item terminal early and then keeps working has no heartbeat at
all, so once its last item write is older than `stale_after` this path
will reclaim it. Bounded by the documented contract on `stale_run_after`
("must exceed the longest expected runtime of a sync", shipped as
3600s), and it fails safe: `is_still_owned()` gates every retirement and
`finalize_run()` is guarded on `status == RUNNING`, so a wrongly-fenced
run stops retiring and leaves `snapshot_complete` `NULL` rather than
advancing the missing-grace counter. Heartbeating
`SyncInstance.updated_at` during a run would remove the exposure and is
out of scope here.
- **No new blocking mode.** The reclaim only ever moves a row from
`PENDING`/`RUNNING` to `FAILED` and releases items, and `create()` still
gates refusal on items, so it cannot start refusing runs it accepts
today. This is also why the follow-up advisory-lock ticket must not
replace the item-based check with a `SyncInstance.status` constraint —
and why the `create()` sequence is worth a look in review, since that
ticket will wrap it in a lock.
- **Clock sources differ**, pre-existing and inherited unchanged:
`updated_at` is written by `onupdate=func.now()` (server clock) while
the `having` compares against Python `utc_now()`. Not introduced here
and deliberately not fixed here.
The existing candidate query has to stay item-keyed rather than
instance-status-keyed: a reclaim interrupted between its fence and its
item release leaves an already-`FAILED` instance whose items are still
held, and only an item-keyed set still finds it to finish the release.
## Tested
- [ ] Plant the drift row against the dev DB: a `SyncInstance` for the
PMM syncer with `status='running'` plus one `SyncItem` with
`status='success'` and `updated_at` backdated beyond `STALE_RUN_AFTER`.
- [ ] `GET /api/inventory/sync-status` reports that run as `running` in
`last_runs`.
- [ ] Trigger an inventory sync, then re-read `GET
/api/inventory/sync-status`: the planted run now reads `failed` with
`snapshot_complete` null, and the new run is present and healthy.
- [ ] Add a second, recent `SyncItem` to the planted run and confirm a
sync leaves it `running` (progress is respected).
Automated: `tests/app/sep/test_crud.py` 67 passed; blast radius
(`test_crud` + `sync/` + `apps/inventory/`) 680 passed, 2 skipped;
`app/sep/crud.py` at 96% with no missing branches. Each new guard was
mutation-checked — dropping the fence's staleness predicate or widening
the new candidate class back to `PENDING` fails exactly the tests
written for it.
CI's `python / test` job has failed once on this branch, at `645a45d98`:
`tests/app/sep/test_config.py::TestDiagnosticsDeliveryInputs::test_defaults_to_not_configured`,
raising `APPS.15.MODULE_NAME Value error, No module named
app.sep.apps._scaffold_ci_scriptforward` — a scaffolding app name
reaching the settings the test builds, so workspace-dependent and
unrelated to these files. The later full runs on this branch are green.
Locally, `tests/app/sep/test_import_boundary.py` fails a varying subset
under xdist (3 tests one run, 2 the next) while passing 62/62 in
isolation, on a clean `main` too — the same environment-dependent class,
also unrelated.
## Checklist
- [x] New/modified functions have type hints and rST docstrings
- [x] New tests added for new features or bug fixes
- [ ] All tests pass locally (`make test`)
- [x] Pre-commit hooks pass (`make run-pre-commit`)
- [ ] Database migrations generated if models changed (`make
makemigrations`)
- [ ] User-facing changes documented (README, inline help, UI text)
- [ ] Configuration changes documented with examples
- [x] Changelog fragment added under `changelog.d/` if the change is
user-facing (`make changelog-add`), or confirmed N/A (internal-only
change, or a same-release-cycle fix for an unreleased sibling ticket)1 parent c16578d commit a2f4d03
3 files changed
Lines changed: 445 additions & 38 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
228 | 228 | | |
229 | 229 | | |
230 | 230 | | |
231 | | - | |
232 | | - | |
233 | | - | |
234 | | - | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
235 | 236 | | |
236 | 237 | | |
237 | 238 | | |
| |||
243 | 244 | | |
244 | 245 | | |
245 | 246 | | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
246 | 252 | | |
247 | 253 | | |
248 | 254 | | |
249 | | - | |
250 | | - | |
251 | | - | |
252 | | - | |
253 | | - | |
254 | | - | |
255 | 255 | | |
256 | 256 | | |
257 | 257 | | |
| |||
291 | 291 | | |
292 | 292 | | |
293 | 293 | | |
294 | | - | |
295 | | - | |
296 | | - | |
297 | | - | |
298 | | - | |
299 | | - | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
300 | 301 | | |
301 | 302 | | |
302 | 303 | | |
| |||
323 | 324 | | |
324 | 325 | | |
325 | 326 | | |
326 | | - | |
| 327 | + | |
| 328 | + | |
327 | 329 | | |
328 | | - | |
329 | 330 | | |
330 | | - | |
| 331 | + | |
331 | 332 | | |
332 | | - | |
333 | | - | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
334 | 346 | | |
335 | 347 | | |
336 | 348 | | |
337 | 349 | | |
338 | 350 | | |
339 | | - | |
340 | | - | |
341 | | - | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
342 | 358 | | |
343 | 359 | | |
344 | 360 | | |
345 | 361 | | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
346 | 367 | | |
347 | 368 | | |
348 | 369 | | |
349 | 370 | | |
350 | 371 | | |
351 | 372 | | |
352 | | - | |
| 373 | + | |
353 | 374 | | |
354 | | - | |
355 | | - | |
| 375 | + | |
| 376 | + | |
356 | 377 | | |
357 | 378 | | |
358 | 379 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
0 commit comments