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:
+105
-13
@@ -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"]))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user