[FIX] P1 ingestion: retry su collisione incident_code + dedup race graceful + try/catch CCM (findings review)

- ingestIncident: insert in loop (max 5) -> rigenera incident_code su collisione UNIQUE
  (sotto carico SIEM il random a 6 cifre poteva collidere -> 500 = alert perso). Inoltre la
  race su external_ref (due alert simultanei) ora ritorna 200 dedup invece di 500.
- controlsMonitoring (services): UPDATE auto-stale avvolto in try/catch come la gemella in
  AuditController (degrada con grazia se control_evidence_auto manca).
Verificato E2E: ingest 201, dedup 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-30 11:40:50 +02:00
parent 5413730b00
commit 43c6a87c5f

View File

@ -2369,7 +2369,40 @@ class ServicesController extends BaseController
$data['final_report_due'] = date('Y-m-d H:i:s', $t + 30 * 86400);
}
$incidentId = Database::insert('incidents', $data);
// Insert resiliente: retry su collisione incident_code (random 6 cifre),
// e gestione graceful della race su external_ref (dedup concorrente → 200).
$incidentId = null;
for ($attempt = 0; $attempt < 5; $attempt++) {
try {
$incidentId = Database::insert('incidents', $data);
break;
} catch (Throwable $e) {
$msg = $e->getMessage();
if (stripos($msg, 'Duplicate') === false && stripos($msg, '1062') === false) {
throw $e; // errore non-duplicate: propaga
}
// Race su external_ref: un altro processo ha gia ingerito lo stesso alert
if ($externalRef !== null && stripos($msg, 'uq_incident_external_ref') !== false) {
$dup = Database::fetchOne(
'SELECT id, incident_code FROM incidents WHERE organization_id = ? AND external_ref = ?',
[$orgId, $externalRef]
);
if ($dup) {
$this->jsonSuccess([
'id' => (int) $dup['id'],
'incident_code' => $dup['incident_code'],
'deduplicated' => true,
], 'Alert già ingerito (dedup concorrente)', 200);
return;
}
}
// Altrimenti: collisione su incident_code → rigenera e riprova
$data['incident_code'] = $this->generateCode('INC');
}
}
if ($incidentId === null) {
$this->jsonError('Impossibile registrare l\'incidente (codice duplicato)', 500, 'INSERT_FAILED');
}
Database::insert('incident_timeline', [
'incident_id' => $incidentId,
@ -2626,23 +2659,28 @@ class ServicesController extends BaseController
$this->requireApiKey('read:compliance');
$orgId = $this->currentOrgId;
// Auto-stale: controlli la cui ultima evidenza è scaduta vanno marcati stale
Database::query(
"UPDATE compliance_controls cc
LEFT JOIN (
SELECT t.control_code, t.valid_until, t.status
FROM control_evidence_auto t
JOIN (SELECT control_code, MAX(collected_at) mx FROM control_evidence_auto
WHERE organization_id = ? GROUP BY control_code) m
ON m.control_code = t.control_code AND m.mx = t.collected_at
WHERE t.organization_id = ?
) last ON last.control_code = cc.control_code
SET cc.monitoring_status = 'stale'
WHERE cc.organization_id = ?
AND cc.monitoring_status NOT IN ('not_monitored')
AND last.valid_until IS NOT NULL AND last.valid_until < NOW()",
[$orgId, $orgId, $orgId]
);
// Auto-stale: controlli la cui ultima evidenza è scaduta vanno marcati stale.
// Degrada con grazia se la tabella evidenze non esiste (coerente con AuditController).
try {
Database::query(
"UPDATE compliance_controls cc
LEFT JOIN (
SELECT t.control_code, t.valid_until, t.status
FROM control_evidence_auto t
JOIN (SELECT control_code, MAX(collected_at) mx FROM control_evidence_auto
WHERE organization_id = ? GROUP BY control_code) m
ON m.control_code = t.control_code AND m.mx = t.collected_at
WHERE t.organization_id = ?
) last ON last.control_code = cc.control_code
SET cc.monitoring_status = 'stale'
WHERE cc.organization_id = ?
AND cc.monitoring_status NOT IN ('not_monitored')
AND last.valid_until IS NOT NULL AND last.valid_until < NOW()",
[$orgId, $orgId, $orgId]
);
} catch (Throwable $e) {
error_log('[CCM-services] ' . $e->getMessage());
}
$rows = Database::fetchAll(
'SELECT control_code, title, status, monitoring_status, last_checked_at, freshness_days, implementation_percentage