Skip to content

Commit 06cfa01

Browse files
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

app/inventory/constants.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
"""Define constants for the Inventory service."""
1717

18+
from datetime import timedelta
1819
from enum import StrEnum
1920
from typing import Final
2021

@@ -40,6 +41,20 @@
4041
"System observation not collected yet for this service"
4142
)
4243

44+
#: Longest error text stored on an entity's ``last_sync_error``. The manager
45+
#: truncates to this, so a caller may send an exception message of any length.
46+
SYNC_ERROR_MAX_LENGTH: Final = 1000
47+
48+
#: How far ahead of this service's clock a reported ``attempted_at`` may sit
49+
#: before the report is refused. The ordering guards admit anything not older
50+
#: than the stored attempt, so a reporter whose clock runs fast stamps a
51+
#: freshness no later report can supersede until wall-clock time catches up —
52+
#: and one whose clock is then corrected is locked out for the whole interval.
53+
#: Refusing leaves the entity looking stale, which is the direction the rest of
54+
#: this mechanism already errs in. Wide enough to absorb the drift between two
55+
#: containers of one deployment without absorbing a misconfigured clock.
56+
SYNC_ATTEMPT_MAX_CLOCK_SKEW: Final = timedelta(minutes=5)
57+
4358

4459
class RetirableEntityName(StrEnum):
4560
"""Name the inventory entity types that carry a retirement tombstone.

app/inventory/crud.py

Lines changed: 198 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from typing import Any, ClassVar, Final, TYPE_CHECKING
2222

2323
from fastapi import HTTPException
24-
from sqlalchemy import and_, func, literal, Update, update
24+
from sqlalchemy import and_, case, func, literal, Update, update
2525
from sqlalchemy.exc import IntegrityError
2626
from sqlalchemy.orm import aliased, joinedload
2727
from sqlalchemy.sql import ColumnElement, ColumnExpressionArgument
@@ -38,7 +38,12 @@
3838
)
3939
from app.core.pagination import Pagination
4040
from app.core.utils.date_time import utc_now
41-
from app.inventory.constants import ACTIVE_RETIREMENT_KEY, RetirableEntityName
41+
from app.core.utils.strings import shorten_text
42+
from app.inventory.constants import (
43+
ACTIVE_RETIREMENT_KEY,
44+
RetirableEntityName,
45+
SYNC_ERROR_MAX_LENGTH,
46+
)
4247
from app.inventory.models import (
4348
ExternalIdentityAlias,
4449
HostSystemObservation,
@@ -52,6 +57,9 @@
5257
Service,
5358
ServiceSystemObservation,
5459
SourceEnum,
60+
SyncHealthBase,
61+
SyncHealthWrite,
62+
SyncOutcomeEnum,
5563
Table,
5664
)
5765

@@ -94,6 +102,135 @@ def _revive(
94102
)
95103

96104

105+
#: Keeps the sync-health writes off SQLAlchemy's post-UPDATE session sync. Its
106+
#: "evaluate" strategy re-runs the guards below in Python against whatever the
107+
#: identity map holds, comparing a stored timestamp that is naive on an engine
108+
#: dropping the offset (SQLite) against a timezone-aware attempt time — which
109+
#: raises ``TypeError`` rather than falling through to a fetch. Sessions are
110+
#: built with ``expire_on_commit=False``, so a loaded instance stays stale for
111+
#: the rest of the request; no route reads one of these rows back after the
112+
#: write, and one that starts to must re-read rather than trust the instance.
113+
_UNSYNCHRONIZED: Final = {"synchronize_session": False}
114+
115+
116+
def _not_superseded(
117+
model: type[SyncHealthBase], attempted_at: datetime
118+
) -> ColumnElement[bool]:
119+
"""Match only rows whose recorded sync is not newer than this attempt.
120+
121+
Reports cross the service boundary over HTTP, so arrival order is not
122+
attempt order. Comparing against ``last_synced_at`` discards a report from
123+
an attempt that had already been superseded by a completed later one.
124+
125+
:param model: The table being written.
126+
:param attempted_at: When the reporting syncer began its attempt.
127+
:return: The guard predicate.
128+
"""
129+
return or_(
130+
col(model.last_synced_at).is_(None),
131+
col(model.last_synced_at) <= attempted_at,
132+
)
133+
134+
135+
def _no_newer_failure(
136+
model: type[SyncHealthBase], attempted_at: datetime
137+
) -> ColumnElement[bool]:
138+
"""Match only rows whose open failure run did not start after this attempt.
139+
140+
A failure never moves ``last_synced_at``, so :func:`_not_superseded` cannot
141+
see one: an older success arriving late would otherwise clear a run a newer
142+
attempt had just opened, reporting a clean row whose latest attempt failed.
143+
``sync_failing_since`` names the *earliest* failure of the run, so a success
144+
landing between two failures of one run is still admitted — closing that
145+
would take a column recording the newest attempt seen.
146+
147+
:param model: The table being written.
148+
:param attempted_at: When the reporting syncer began its attempt.
149+
:return: The guard predicate.
150+
"""
151+
return or_(
152+
col(model.sync_failing_since).is_(None),
153+
col(model.sync_failing_since) <= attempted_at,
154+
)
155+
156+
157+
def _record_sync_success(
158+
model: type[SyncHealthBase],
159+
*whereclause: ColumnExpressionArgument[bool],
160+
synced_at: datetime,
161+
) -> Update:
162+
"""Build the UPDATE stamping a clean sync on every row matching the clauses.
163+
164+
``synced_at`` is the syncer's attempt time, not the moment this statement
165+
runs, so ``last_synced_at`` answers "when was this confirmed against its
166+
source" rather than "when did the report arrive".
167+
168+
:param model: The table to record the success in.
169+
:param whereclause: Clauses narrowing the rows to write.
170+
:param synced_at: When the reporting syncer began its attempt.
171+
:return: The UPDATE statement.
172+
"""
173+
return (
174+
update(model)
175+
.where(
176+
_not_superseded(model, synced_at),
177+
_no_newer_failure(model, synced_at),
178+
*whereclause,
179+
)
180+
.values(
181+
last_synced_at=synced_at,
182+
last_sync_error=None,
183+
sync_failing_since=None,
184+
consecutive_failures=0,
185+
)
186+
.execution_options(**_UNSYNCHRONIZED)
187+
)
188+
189+
190+
def _record_sync_failure(
191+
model: type[SyncHealthBase],
192+
*whereclause: ColumnExpressionArgument[bool],
193+
error: str,
194+
failed_at: datetime,
195+
) -> Update:
196+
"""Build the UPDATE recording a failed sync on every row matching the clauses.
197+
198+
``sync_failing_since`` keeps the earlier of the stored value and this
199+
attempt rather than being assigned, so it names the *first* failure after
200+
the last success even when two failures of one run arrive out of order —
201+
coalescing alone would leave it on whichever landed first. The counter is
202+
incremented in SQL so concurrent runs cannot lose an increment to a
203+
read-modify-write. ``last_sync_error`` is still assigned unconditionally,
204+
so an out-of-order pair leaves the older message there; naming the newest
205+
failure would take a column recording the newest attempt seen, which the
206+
entity does not carry. ``last_synced_at`` is deliberately absent: a failure
207+
never moves it — which is also why the guard compares against it rather
208+
than being skipped here.
209+
210+
:param model: The table to record the failure in.
211+
:param whereclause: Clauses narrowing the rows to write.
212+
:param error: The bounded description to store.
213+
:param failed_at: When the reporting syncer began its attempt.
214+
:return: The UPDATE statement.
215+
"""
216+
return (
217+
update(model)
218+
.where(_not_superseded(model, failed_at), *whereclause)
219+
.values(
220+
last_sync_error=error,
221+
consecutive_failures=col(model.consecutive_failures) + 1,
222+
sync_failing_since=case(
223+
(
224+
col(model.sync_failing_since) < failed_at,
225+
col(model.sync_failing_since),
226+
),
227+
else_=failed_at,
228+
),
229+
)
230+
.execution_options(**_UNSYNCHRONIZED)
231+
)
232+
233+
97234
def _retained(
98235
model: type[RetirableSQLModel],
99236
retired_before: datetime,
@@ -154,6 +291,55 @@ def _retained_descendant_exists(
154291
)
155292

156293

294+
class SyncHealthManagerMixin(BaseSQLModelManager):
295+
"""Record the outcome of one syncer attempt on an entity."""
296+
297+
@classmethod
298+
async def record_sync_health(
299+
cls,
300+
session: AsyncSession,
301+
instance: RetirableSQLModel,
302+
outcome: SyncHealthWrite,
303+
) -> None:
304+
"""Apply one sync outcome to an entity's four sync-health columns.
305+
306+
The statement is hand-built rather than routed through ``update``, for
307+
the reason :meth:`RetirableManagerMixin.retire`'s is: the transitions
308+
are expressed in SQL so an increment cannot be lost to a
309+
read-modify-write, and the write must reach a row the manager's own
310+
retired filter would hide.
311+
312+
:param session: The asynchronous database session to use.
313+
:param instance: The entity the outcome was observed for. Typed as the
314+
retirable base ``retire`` takes rather than as ``SyncHealthBase``,
315+
which carries the columns but not the ``id`` this addresses the row
316+
by; ``cls.Model`` is what confines the write to a level that has
317+
them.
318+
:param outcome: What the syncer reported.
319+
:raises ValueError: If the outcome names no branch here, which an
320+
outcome added to :class:`SyncOutcomeEnum` without a transition
321+
would. Failing loudly beats routing it to the failure branch, where
322+
the body model does not require an ``error``.
323+
"""
324+
if outcome.outcome is SyncOutcomeEnum.SUCCESS:
325+
statement = _record_sync_success(
326+
cls.Model,
327+
col(cls.Model.id) == instance.id,
328+
synced_at=outcome.attempted_at,
329+
)
330+
elif outcome.error is not None:
331+
statement = _record_sync_failure(
332+
cls.Model,
333+
col(cls.Model.id) == instance.id,
334+
error=shorten_text(outcome.error, SYNC_ERROR_MAX_LENGTH),
335+
failed_at=outcome.attempted_at,
336+
)
337+
else:
338+
raise ValueError(f"No sync-health transition for {outcome.outcome!r}")
339+
await cls._exec(session, statement) # call-shape-dup-ok: the manager idiom
340+
await session.commit()
341+
342+
157343
class RetirableManagerMixin(BaseSQLModelManager):
158344
"""Confine an entity's reads to the tombstone policy.
159345
@@ -1411,7 +1597,7 @@ async def unlink_identity(
14111597
await cls._commit_identity_change(session, statements, rows)
14121598

14131599

1414-
class NodeManager(AliasableManagerMixin, BaseSQLModelManager):
1600+
class NodeManager(AliasableManagerMixin, SyncHealthManagerMixin, BaseSQLModelManager):
14151601
"""Manage Node operations, including retrieval, listing, and retirement.
14161602
14171603
:ivar Model: The SQLModel class this manager is responsible for (``Node``).
@@ -1479,7 +1665,9 @@ async def _identity_source(
14791665
return entity.source
14801666

14811667

1482-
class ServiceManager(AliasableManagerMixin, BaseSQLModelChildManager):
1668+
class ServiceManager(
1669+
AliasableManagerMixin, SyncHealthManagerMixin, BaseSQLModelChildManager
1670+
):
14831671
"""Manage Service operations, including retrieval, listing, and retirement.
14841672
14851673
:ivar Model: The SQLModel class this manager is responsible for (``Service``).
@@ -1635,7 +1823,9 @@ async def _require_revivable(
16351823
)
16361824

16371825

1638-
class SchemaManager(RetirableManagerMixin, BaseSQLModelChildManager):
1826+
class SchemaManager(
1827+
RetirableManagerMixin, SyncHealthManagerMixin, BaseSQLModelChildManager
1828+
):
16391829
"""Manage Schema operations, including retrieval, listing, and retirement.
16401830
16411831
:ivar Model: The SQLModel class this manager is responsible for (`Schema`).
@@ -1664,7 +1854,9 @@ class SchemaManager(RetirableManagerMixin, BaseSQLModelChildManager):
16641854
)
16651855

16661856

1667-
class TableManager(RetirableManagerMixin, BaseSQLModelChildManager):
1857+
class TableManager(
1858+
RetirableManagerMixin, SyncHealthManagerMixin, BaseSQLModelChildManager
1859+
):
16681860
"""Manage Table operations, including retrieval, listing, and retirement.
16691861
16701862
:ivar Model: The SQLModel class this manager is responsible for (`Table`).
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Copyright (C) 2026 Percona LLC
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU Affero General Public License as published by
5+
# the Free Software Foundation, either version 3 of the License, or
6+
# (at your option) any later version.
7+
#
8+
# This program is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU Affero General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU Affero General Public License
14+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
15+
16+
"""add sync health columns to inventory entities
17+
18+
Revision ID: 9f2c14d6b8a7
19+
Revises: 3c39abf7a429
20+
Create Date: 2026-08-31 22:00:00.000000
21+
22+
Add ``last_synced_at``, ``last_sync_error``, ``sync_failing_since`` and
23+
``consecutive_failures`` to the four inventory entities, so a row records how
24+
recently and how successfully the syncer that mirrors it last confirmed it.
25+
26+
The three timestamps and the error text are nullable, which is the correct
27+
reading for a row nothing has reported on yet. ``consecutive_failures`` is NOT
28+
NULL with a ``0`` server default so existing rows land on "not failing" rather
29+
than on an ambiguous NULL; the default is kept rather than dropped, exactly as
30+
``retirement_key``'s is in ``c7d1e94ab3f2``, so a release still running the
31+
previous code can insert without the column.
32+
"""
33+
34+
import sqlalchemy as sa
35+
from alembic import op
36+
37+
# revision identifiers, used by Alembic.
38+
revision = "9f2c14d6b8a7"
39+
down_revision = "3c39abf7a429"
40+
branch_labels = None
41+
depends_on = None
42+
43+
#: Every entity a syncer mirrors, and therefore every table carrying the
44+
#: per-entity sync-health columns.
45+
_SYNCABLE_TABLES = ("node", "service", "schema", "table")
46+
47+
#: The columns added to each table, in the order they are added.
48+
_SYNC_HEALTH_COLUMN_NAMES = (
49+
"last_synced_at",
50+
"last_sync_error",
51+
"sync_failing_since",
52+
"consecutive_failures",
53+
)
54+
55+
56+
def upgrade() -> None:
57+
"""Add the four sync-health columns to every syncable table.
58+
59+
The columns are built inline per table rather than hoisted into a shared
60+
tuple: a ``Column`` binds to the first table it is added to.
61+
"""
62+
for table_name in _SYNCABLE_TABLES:
63+
op.add_column(
64+
table_name,
65+
sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=True),
66+
)
67+
op.add_column(
68+
table_name,
69+
sa.Column("last_sync_error", sa.Text(), nullable=True),
70+
)
71+
op.add_column(
72+
table_name,
73+
sa.Column("sync_failing_since", sa.DateTime(timezone=True), nullable=True),
74+
)
75+
op.add_column(
76+
table_name,
77+
sa.Column(
78+
"consecutive_failures",
79+
sa.Integer(),
80+
nullable=False,
81+
server_default=sa.text("0"),
82+
),
83+
)
84+
85+
86+
def downgrade() -> None:
87+
"""Drop the sync-health columns, reversing the order they were added in."""
88+
for table_name in reversed(_SYNCABLE_TABLES):
89+
for column_name in reversed(_SYNC_HEALTH_COLUMN_NAMES):
90+
op.drop_column(table_name, column_name)

0 commit comments

Comments
 (0)