fix(iace/mitigations): idempotent CreateMitigation + UNIQUE(hazard_id, name)
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 56s
CI / iace-gt-coverage (push) Successful in 27s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 56s
CI / iace-gt-coverage (push) Successful in 27s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 17s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
[migration-approved]
The init-handler was non-idempotent. A second click on "Neu initialisieren
in Grenzen" inserted every engine-suggested mitigation a second time —
e.g. the Bremsscheibe project ended up with 5 (hazard_id, name) duplicate
pairs (HMI-Usability-Pruefung, Eindeutiges visuelles Feedback,
Betriebsarten-Anzeige, Sicher begrenzter Bewegungsbereich, …). 45 such
duplicates accumulated across all projects.
Migration 030_iace_mitigation_unique.sql:
1. Picks one winning row per (hazard_id, name) using a stable rank:
is_relevant DESC (expert decision wins over engine default)
status DESC (verified > implemented > planned)
created_at DESC (newest beats older on otherwise-equal rows)
and deletes the losers (Bremsscheibe: 5 rows; total: 45).
2. Adds UNIQUE constraint iace_mitigations_hazard_name_uniq
(hazard_id, name).
Store-Layer (CreateMitigation):
INSERT … ON CONFLICT (hazard_id, name) DO NOTHING RETURNING id.
pgx.ErrNoRows from RETURNING → look up the existing row and return that.
Callers (engine init + manual add) always get a usable Mitigation; the
second click is silently swallowed instead of failing.
Frontend dedupe in groupByTitle stays — it covers any pre-existing
duplicates that survived the migration in edge cases (multi-row write
in flight, etc.). With the UNIQUE constraint live, the in-memory
dedupe is a belt-and-suspenders safety net rather than the load-bearing
mechanism.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,13 @@ func (s *Store) CreateMitigation(ctx context.Context, req CreateMitigationReques
|
|||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := s.pool.Exec(ctx, `
|
// ON CONFLICT DO NOTHING + RETURNING id makes the call idempotent against
|
||||||
|
// the iace_mitigations_hazard_name_uniq constraint (migration 030).
|
||||||
|
// A repeated Engine-init no longer creates duplicates; the second click
|
||||||
|
// is silently swallowed. If nothing was inserted we look up the existing
|
||||||
|
// row and return that, so the caller always gets a usable Mitigation.
|
||||||
|
var insertedID uuid.UUID
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
INSERT INTO iace_mitigations (
|
INSERT INTO iace_mitigations (
|
||||||
id, hazard_id, reduction_type, name, description,
|
id, hazard_id, reduction_type, name, description,
|
||||||
status, verification_method, verification_result,
|
status, verification_method, verification_result,
|
||||||
@@ -40,13 +46,27 @@ func (s *Store) CreateMitigation(ctx context.Context, req CreateMitigationReques
|
|||||||
$11, $12,
|
$11, $12,
|
||||||
$13, $14
|
$13, $14
|
||||||
)
|
)
|
||||||
|
ON CONFLICT (hazard_id, name) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
m.ID, m.HazardID, string(m.ReductionType), m.Name, m.Description,
|
m.ID, m.HazardID, string(m.ReductionType), m.Name, m.Description,
|
||||||
string(m.Status), "", "",
|
string(m.Status), "", "",
|
||||||
nil, uuid.Nil,
|
nil, uuid.Nil,
|
||||||
m.IsRelevant, m.IsCustomerStandard,
|
m.IsRelevant, m.IsCustomerStandard,
|
||||||
m.CreatedAt, m.UpdatedAt,
|
m.CreatedAt, m.UpdatedAt,
|
||||||
)
|
).Scan(&insertedID)
|
||||||
|
|
||||||
|
if err == pgx.ErrNoRows {
|
||||||
|
// Conflict — return the existing row for the same (hazard_id, name).
|
||||||
|
var existingID uuid.UUID
|
||||||
|
if lookupErr := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT id FROM iace_mitigations WHERE hazard_id = $1 AND name = $2`,
|
||||||
|
m.HazardID, m.Name,
|
||||||
|
).Scan(&existingID); lookupErr != nil {
|
||||||
|
return nil, fmt.Errorf("create mitigation lookup-on-conflict: %w", lookupErr)
|
||||||
|
}
|
||||||
|
return s.getMitigation(ctx, existingID)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create mitigation: %w", err)
|
return nil, fmt.Errorf("create mitigation: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- Migration 030: De-duplicate iace_mitigations + add UNIQUE(hazard_id, name)
|
||||||
|
-- ==========================================================================
|
||||||
|
-- The mitigation init-handler used to be non-idempotent: a second click on
|
||||||
|
-- "Neu initialisieren in Grenzen" inserted every engine-suggested mitigation
|
||||||
|
-- a second time. The Bremsscheibe benchmark accumulated 5 such duplicate
|
||||||
|
-- (hazard_id, name) pairs (HMI-Usability, Eindeutiges-Feedback,
|
||||||
|
-- Betriebsarten-Anzeige, Sicher begrenzter Bewegungsbereich, …).
|
||||||
|
--
|
||||||
|
-- Frontend mitigates the symptom with a per-hazard dedupe in groupByTitle,
|
||||||
|
-- but the DB still carries the redundant rows: confusing for SQL audits,
|
||||||
|
-- and the cleanup work was deferred only because the expert had not yet
|
||||||
|
-- decided which copy to keep.
|
||||||
|
--
|
||||||
|
-- This migration:
|
||||||
|
-- 1. Picks the winning row per (hazard_id, name) using a stable rank:
|
||||||
|
-- is_relevant DESC (expert decision survives)
|
||||||
|
-- status DESC (verified > implemented > planned)
|
||||||
|
-- created_at DESC (newest wins when nothing else differs)
|
||||||
|
-- and deletes the rest.
|
||||||
|
-- 2. Adds a UNIQUE constraint so future engine init runs cannot
|
||||||
|
-- re-create the duplicates.
|
||||||
|
-- ==========================================================================
|
||||||
|
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY hazard_id, name
|
||||||
|
ORDER BY is_relevant DESC,
|
||||||
|
CASE status
|
||||||
|
WHEN 'verified' THEN 2
|
||||||
|
WHEN 'implemented' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END DESC,
|
||||||
|
created_at DESC
|
||||||
|
) AS rn
|
||||||
|
FROM iace_mitigations
|
||||||
|
)
|
||||||
|
DELETE FROM iace_mitigations
|
||||||
|
WHERE id IN (SELECT id FROM ranked WHERE rn > 1);
|
||||||
|
|
||||||
|
ALTER TABLE iace_mitigations
|
||||||
|
DROP CONSTRAINT IF EXISTS iace_mitigations_hazard_name_uniq;
|
||||||
|
|
||||||
|
ALTER TABLE iace_mitigations
|
||||||
|
ADD CONSTRAINT iace_mitigations_hazard_name_uniq
|
||||||
|
UNIQUE (hazard_id, name);
|
||||||
Reference in New Issue
Block a user