fix: fail closed on conflicting incident_links observation metadata

Legacy NULL-scope dedupe only compared Gitea targets, so duplicate rows
with the same provider key and issue but different fingerprint/status/
event_count/etc. collapsed to the lowest link_id and silently dropped
observation data. Migration now compares all meaningful observation
fields and refuses to discard conflicts (#619 RC3 / #613).
This commit is contained in:
2026-07-09 23:04:17 -04:00
parent 16cd871fbd
commit 2429d9d2e8
3 changed files with 193 additions and 13 deletions
+105 -13
View File
@@ -317,13 +317,68 @@ class ControlPlaneDB:
finally:
conn.close()
# Observation fields that must agree before collapsing legacy duplicates.
# Deleting a row would drop any of these; silent loss is forbidden (#619 RC3).
_INCIDENT_LINK_OBS_COMPARE_FIELDS: tuple[str, ...] = (
"provider_short_id",
"provider_permalink",
"fingerprint",
"gitea_org",
"gitea_repo",
"gitea_issue_number",
"linked_pr_numbers",
"first_seen",
"last_seen",
"event_count",
"status",
"release_resolved_at",
"last_sync_at",
)
@staticmethod
def _norm_incident_obs_value(field: str, value: Any) -> Any:
"""Normalize optional observation values for equality during migration.
NULL and empty string are treated as equivalent for optional text
fields (legacy rows often omit them). Integer fields keep NULL
distinct from zero so event_count=0 vs NULL is not collapsed away.
"""
if field in ("gitea_issue_number", "event_count"):
if value is None or value == "":
return None
return int(value)
if value is None:
return ""
return str(value)
def _incident_link_observation_identity(
self, row: sqlite3.Row, cols: set[str]
) -> tuple[Any, ...]:
"""Stable comparable identity of all meaningful observation metadata."""
parts: list[Any] = []
for field in self._INCIDENT_LINK_OBS_COMPARE_FIELDS:
if field not in cols:
continue
# Row keys match column names; missing keys treated as absent.
try:
raw = row[field]
except (IndexError, KeyError):
raw = None
parts.append((field, self._norm_incident_obs_value(field, raw)))
return tuple(parts)
def _migrate_incident_links_null_scope(self, conn: sqlite3.Connection) -> None:
"""Collapse legacy NULL-scope duplicates, then normalize to '' (#619 RC2).
"""Collapse legacy NULL-scope duplicates, then normalize to '' (#619 RC).
Order is mandatory: SQLite UNIQUE treats multiple NULLs as distinct, so
normalizing NULL→'' first can hit the UNIQUE constraint and abort
migration. Deduplicate under the *normalized* key first, fail closed if
Gitea targets conflict within a group, then coerce NULLs to ''.
Gitea targets **or** any other meaningful observation metadata conflict
within a group (no silent data loss), then coerce NULLs to ''.
Policy: **fail closed** — never invent a merge of fingerprint/status/
event_count/etc. Only identical (after optional-text NULL≈'') rows may
collapse to the lowest ``link_id``.
"""
cols = {
row[1]
@@ -332,17 +387,41 @@ class ControlPlaneDB:
if not cols:
return
# Build SELECT from present columns so partial legacy schemas still migrate.
# Scope fields are normalized in the SELECT aliases used for grouping.
required = ("link_id", "provider", "provider_issue_id", "gitea_org", "gitea_repo", "gitea_issue_number")
if not all(c in cols for c in required):
return
select_parts = [
"link_id",
"provider",
"IFNULL(provider_base_url, '') AS base_url"
if "provider_base_url" in cols
else "'' AS base_url",
"IFNULL(provider_org, '') AS p_org"
if "provider_org" in cols
else "'' AS p_org",
"IFNULL(provider_project, '') AS p_project"
if "provider_project" in cols
else "'' AS p_project",
"provider_issue_id",
"gitea_org",
"gitea_repo",
"gitea_issue_number",
]
for field in self._INCIDENT_LINK_OBS_COMPARE_FIELDS:
if field in (
"gitea_org",
"gitea_repo",
"gitea_issue_number",
):
continue # already selected
if field in cols:
select_parts.append(field)
rows = conn.execute(
"""
SELECT link_id, provider,
IFNULL(provider_base_url, '') AS base_url,
IFNULL(provider_org, '') AS p_org,
IFNULL(provider_project, '') AS p_project,
provider_issue_id,
gitea_org, gitea_repo, gitea_issue_number
FROM incident_links
ORDER BY link_id ASC
"""
f"SELECT {', '.join(select_parts)} FROM incident_links ORDER BY link_id ASC"
).fetchall()
groups: dict[tuple[str, str, str, str, str], list[sqlite3.Row]] = {}
@@ -374,7 +453,20 @@ class ControlPlaneDB:
"incident_links migration fail closed: conflicting Gitea "
f"targets for provider key {key!r}: {sorted(targets)}"
)
keep_id = int(members[0]["link_id"]) # lowest link_id (ORDER BY)
# Same Gitea target is not enough: fingerprint/status/event_count/
# permalinks/timestamps/etc. must also agree or we lose data.
obs_identities = {
self._incident_link_observation_identity(m, cols) for m in members
}
if len(obs_identities) > 1:
raise ControlPlaneError(
"incident_links migration fail closed: conflicting "
"observation metadata for provider key "
f"{key!r} (same Gitea target but differing fingerprint/"
"status/event_count/permalink/timestamps/or related fields); "
"refusing silent discard of duplicate rows"
)
# lowest link_id kept (ORDER BY link_id ASC)
for m in members[1:]:
to_delete.append(int(m["link_id"]))