Commit 06cfa01
authored
SEP-1859: Record per-entity sync freshness and failure state (#1436)
Adds four per-entity sync-health columns to the inventory entities and
writes them from the syncer's own per-entity boundary.
**Inventory service.** `SyncHealthBase` carries `last_synced_at`,
`last_sync_error`, `sync_failing_since` and `consecutive_failures`; it
is mixed into `Node`, `Service`, `Schema` and `Table` and into their
read responses, and deliberately not into the `*Write` models — the same
shape `RetiredAtBase` already has. One Alembic revision on the inventory
track adds the four columns to all four tables; `consecutive_failures`
is `NOT NULL` with a `0` server default so existing rows backfill, and
the default is kept for the rolling-upgrade window.
**A narrow write path.** `POST /{entity}/{id}/sync-health` on each of
the four routers takes a `SyncHealthWrite` carrying the *outcome* —
`success` or `failure`, an `error`, and the `attempted_at` the syncer
began with — rather than the column values, so a future data `PUT` never
has to carry sync bookkeeping. All four carry `IsServicePrincipalDep`:
the columns are written only by the syncer and are not
operator-editable. All four resolve the entity through the retirable
dep, so a retirement concurrent with the sync does not turn bookkeeping
into a failed sync item.
**Server-side transitions.** `SyncHealthManagerMixin.record_sync_health`
applies one atomic `UPDATE` per outcome. The increment and the
first-failure-only rule are expressed in SQL (`consecutive_failures +
1`, and a `CASE` keeping the earlier of the stored `sync_failing_since`
and this attempt), so no increment is lost to a read-modify-write.
`last_synced_at` is stamped with the attempt time, not the moment the
report arrived. The syncer reads that time from the clock directly
rather than through `utc_now`, whose second truncation would hand two
attempts within one second the same ordering key — and the guards admit
an equal one, so the later arrival would win whichever attempt was
actually newer.
Two ordering guards keep a late report from overwriting a newer one.
Both statements carry `last_synced_at IS NULL OR last_synced_at <=
:attempted_at`, so a stale failure cannot restart a run a newer success
closed and a stale success cannot pull the freshness backwards. The
success statement additionally carries `sync_failing_since IS NULL OR
sync_failing_since <= :attempted_at`: a failure deliberately never moves
`last_synced_at`, so the first guard is blind to one, and without the
second an older success arriving late would clear a run a newer attempt
had just opened — reporting a clean row whose latest attempt failed.
Two failures of one run are ordered by construction rather than by a
guard: `sync_failing_since` keeps the *earlier* of the stored value and
the reported attempt (a portable `CASE`, which subsumes the `COALESCE`
it replaces since the null row falls to the `ELSE`), so the run stays
opened at its true start whichever report lands first. `last_sync_error`
is the one field arrival order still decides; see Known limitations.
`attempted_at` is refused when it sits more than
`SYNC_ATTEMPT_MAX_CLOCK_SKEW` (5 minutes) ahead of the inventory
service's clock. The guards admit anything not older than the stored
attempt, so a reporter running fast would otherwise stamp a freshness
nothing later could supersede — and would lock itself out for the whole
interval once its clock was corrected. Refusing leaves the entity
looking stale, the direction the rest of this mechanism already errs in.
**Syncer side.** A new `app/sep/sync/health.py` holds the whole
reporting mechanism: the attempt marker, the genuine-attempt semantics,
the error-description contract and the best-effort POST. `BaseSyncer`
reaches it through a `sync_health` property and nests `record(...)`
*inside* `manage_sync_item` in all four `sync_*` methods, so a failure
is recorded before that boundary marks the SyncItem failed. A success is
recorded only once the block called `mark_compared()`, which is what
excludes the filtered-out `fetch_* -> None` early return — that return
leaves `manage_sync_item` on the same clean-exit path a real sync takes,
so the clean exit alone cannot be trusted. `hold_entity`, the four
`retire_*` methods and `sync_inventory` are not wrapped and write
nothing.
**Which levels a syncer owns** is policy, declared per class as a
`mirrors_entity_levels` ClassVar beside the existing
`reads_retired_entities`: `PMMSyncer` for Node and Service, the MySQL
syncer for Schema and Table, `SystemFactsSyncer` explicitly none. A
syncer that only traverses a level to reach its children confirms
nothing about that entity's mirrored values, and refreshing them there
would clear a failing PMM mirror into a false all-clear — the MySQL and
system-facts runs interleave with PMM's on the same Node and Service
rows.
A `SyncFailError` reaching the reporter is the one exception the block
does not attribute to its own entity. It can only come from a *nested*
level — this level's own boundary raises it after the reporter has
already exited — and the syncers walk to children from inside the
parent's `perform_*_sync`, so a child's failure passes through the
parent. Whether it does at all depends on `break_on_error`, since
otherwise the child's own boundary swallows it; attributing it upward
would report a node as failing whose own fields were just confirmed, and
would make the columns describe an identical outcome differently in the
two modes. The child records its failure on its own row.
`INVENTORY_PATH_SEGMENTS` moves verbatim from `app/sep/sync/models.py`
to a new leaf `app/sep/sync/constants.py` so `health.py` can build
entity paths without importing `models.py`, which imports `health.py`.
**What `last_sync_error` may hold.** The column is durable and readable
through the ordinary inventory read routes — which are
`IsAuthenticatedDep`, while the write requires the service principal —
so `_describe_sync_error` is an allowlist rather than a scrubber over
arbitrary text. An `HTTPException` contributes only its status code,
because its `detail` is built from the remote response body. Otherwise
the full message is kept only for the exception classes named in
`_MESSAGE_SAFE_ERRORS`, each of which interpolates nothing but sync
bookkeeping; everything else contributes its type name alone. Membership
is by *exact type* (`type(error) in _MESSAGE_SAFE_ERRORS`), not
`isinstance`, so a subclass added later has to be opted in deliberately
instead of inheriting persistence from its base — a subclass is free to
interpolate remote context the base never did.
`ExecutorHostNotFoundError` is the case that makes the distinction
load-bearing: it is a `SyncError`, it reaches this path from the
`fetch_schema` / `fetch_table` task-target lookup, and its message
carries the entire Tasks-API executor-host map. The type name always
leads, because several exceptions here stringify to `""` and an empty
error is refused by the write model. The exception itself is re-raised
to the boundary that logs it with a traceback.
### Bundled fixes
- `tests/app/inventory/migrations/test_mandatory_pmm_origin.py` compared
whole rows across an upgrade/downgrade boundary, so any column added by
a later revision broke it. The captured expectation is now restricted to
the columns the pre-origin schema declares; the values and the
pre-origin column set are still both asserted.
- `app/sep/sync/syncers/system_facts/syncer.py` lost two pre-existing
`:vartype` directives from the class docstring this change edits —
annotations are the source of truth, and the docstring gate treats a
directive inside an edited docstring as in scope.
- Three existing syncer tests asserted `post.assert_awaited_once()` /
`assert_not_awaited()` on a client that now also carries sync-health
writes. They assert on the entity POSTs specifically, via a shared
`entity_posts` helper, so the original claim ("exactly one create",
"nothing created or revived") is unchanged.1 parent 093ff75 commit 06cfa01
35 files changed
Lines changed: 4380 additions & 83 deletions
File tree
- app
- inventory
- migrations/versions
- routes
- sep/sync
- syncers
- mysql
- system_facts
- changelog.d
- frontend/packages/api
- specs
- src/generated
- tests/app
- inventory
- migrations
- routes
- sep/sync
- syncers
- mysql
- system_facts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| 18 | + | |
18 | 19 | | |
19 | 20 | | |
20 | 21 | | |
| |||
40 | 41 | | |
41 | 42 | | |
42 | 43 | | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
43 | 58 | | |
44 | 59 | | |
45 | 60 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| |||
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
42 | 47 | | |
43 | 48 | | |
44 | 49 | | |
| |||
52 | 57 | | |
53 | 58 | | |
54 | 59 | | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
55 | 63 | | |
56 | 64 | | |
57 | 65 | | |
| |||
94 | 102 | | |
95 | 103 | | |
96 | 104 | | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
97 | 234 | | |
98 | 235 | | |
99 | 236 | | |
| |||
154 | 291 | | |
155 | 292 | | |
156 | 293 | | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
157 | 343 | | |
158 | 344 | | |
159 | 345 | | |
| |||
1411 | 1597 | | |
1412 | 1598 | | |
1413 | 1599 | | |
1414 | | - | |
| 1600 | + | |
1415 | 1601 | | |
1416 | 1602 | | |
1417 | 1603 | | |
| |||
1479 | 1665 | | |
1480 | 1666 | | |
1481 | 1667 | | |
1482 | | - | |
| 1668 | + | |
| 1669 | + | |
| 1670 | + | |
1483 | 1671 | | |
1484 | 1672 | | |
1485 | 1673 | | |
| |||
1635 | 1823 | | |
1636 | 1824 | | |
1637 | 1825 | | |
1638 | | - | |
| 1826 | + | |
| 1827 | + | |
| 1828 | + | |
1639 | 1829 | | |
1640 | 1830 | | |
1641 | 1831 | | |
| |||
1664 | 1854 | | |
1665 | 1855 | | |
1666 | 1856 | | |
1667 | | - | |
| 1857 | + | |
| 1858 | + | |
| 1859 | + | |
1668 | 1860 | | |
1669 | 1861 | | |
1670 | 1862 | | |
| |||
Lines changed: 90 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
0 commit comments