Skip to content

Commit b074126

Browse files
committed
fix: batch notification queries to eliminate N+1 cascading (#1625)
2 parents dfd6b23 + c89ac13 commit b074126

2 files changed

Lines changed: 164 additions & 36 deletions

File tree

backend/secuscan/executor.py

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,25 +1468,25 @@ async def _upsert_findings_and_report(self, db, task_id: str, owner_id: str, plu
14681468
result=parsed,
14691469
)
14701470
findings_data: List[Dict[str, Any]] = []
1471-
for finding in structured_result.get("findings", []):
1472-
findings_data.append(
1473-
await self._persist_finding(
1474-
db,
1475-
owner_id=owner_id,
1476-
task_id=task_id,
1477-
plugin_id=plugin_id,
1478-
target=target,
1479-
finding=finding,
1471+
async with db.transaction():
1472+
for finding in structured_result.get("findings", []):
1473+
findings_data.append(
1474+
await self._persist_finding(
1475+
db,
1476+
owner_id=owner_id,
1477+
task_id=task_id,
1478+
plugin_id=plugin_id,
1479+
target=target,
1480+
finding=finding,
1481+
)
14801482
)
1481-
)
14821483

1483-
structured_result["findings"] = findings_data
1484-
structured_result["severity_counts"] = self._build_severity_counts(findings_data)
1485-
structured_result["finding_groups"] = build_finding_groups(findings_data)
1486-
structured_result["asset_summary"] = build_asset_summary(findings_data, asset_services)
1487-
structured_result["scan_diff"] = build_scan_diff(findings_data, previous_findings)
1484+
structured_result["findings"] = findings_data
1485+
structured_result["severity_counts"] = self._build_severity_counts(findings_data)
1486+
structured_result["finding_groups"] = build_finding_groups(findings_data)
1487+
structured_result["asset_summary"] = build_asset_summary(findings_data, asset_services)
1488+
structured_result["scan_diff"] = build_scan_diff(findings_data, previous_findings)
14881489

1489-
async with db.transaction():
14901490
await db.execute(
14911491
"UPDATE tasks SET structured_json = ? WHERE id = ?",
14921492
(json.dumps(structured_result), task_id)
@@ -1534,25 +1534,25 @@ async def _upsert_findings_and_report_from_scanner(self, db, task_id: str, owner
15341534
result=result,
15351535
)
15361536
findings_data: List[Dict[str, Any]] = []
1537-
for finding in structured_result.get("findings", []):
1538-
findings_data.append(
1539-
await self._persist_finding(
1540-
db,
1541-
owner_id=owner_id,
1542-
task_id=task_id,
1543-
plugin_id=plugin_id,
1544-
target=target,
1545-
finding=finding,
1537+
async with db.transaction():
1538+
for finding in structured_result.get("findings", []):
1539+
findings_data.append(
1540+
await self._persist_finding(
1541+
db,
1542+
owner_id=owner_id,
1543+
task_id=task_id,
1544+
plugin_id=plugin_id,
1545+
target=target,
1546+
finding=finding,
1547+
)
15461548
)
1547-
)
15481549

1549-
structured_result["findings"] = findings_data
1550-
structured_result["severity_counts"] = self._build_severity_counts(findings_data)
1551-
structured_result["finding_groups"] = build_finding_groups(findings_data)
1552-
structured_result["asset_summary"] = build_asset_summary(findings_data, asset_services)
1553-
structured_result["scan_diff"] = build_scan_diff(findings_data, previous_findings)
1550+
structured_result["findings"] = findings_data
1551+
structured_result["severity_counts"] = self._build_severity_counts(findings_data)
1552+
structured_result["finding_groups"] = build_finding_groups(findings_data)
1553+
structured_result["asset_summary"] = build_asset_summary(findings_data, asset_services)
1554+
structured_result["scan_diff"] = build_scan_diff(findings_data, previous_findings)
15541555

1555-
async with db.transaction():
15561556
await db.execute(
15571557
"UPDATE tasks SET structured_json = ? WHERE id = ?",
15581558
(json.dumps(structured_result), task_id)

backend/secuscan/notification_service.py

Lines changed: 132 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,84 @@ async def deliver_via_rule(
629629
)
630630

631631

632+
async def _deliver_rule_batch(
633+
db: Database,
634+
rule: Dict[str, Any],
635+
findings: List[Dict[str, Any]],
636+
) -> List[DeliveryResult]:
637+
"""Deliver a batched notification for one rule across multiple findings.
638+
639+
Sends a single webhook per (rule, task) containing an array of findings,
640+
then records individual delivery records in a batch insert.
641+
"""
642+
results: List[DeliveryResult] = []
643+
channel = str(rule.get("channel_type", "")).lower()
644+
target = str(rule.get("target_url_or_email", ""))
645+
646+
if channel == NotificationChannelType.WEBHOOK.value:
647+
payloads = [build_alert_payload(finding, rule) for finding in findings]
648+
batch_payload = {
649+
"event": "finding.alert.batch",
650+
"rule": {
651+
"id": rule.get("id"),
652+
"name": rule.get("name"),
653+
"severity_threshold": rule.get("severity_threshold"),
654+
"channel_type": rule.get("channel_type"),
655+
},
656+
"findings": payloads,
657+
"count": len(payloads),
658+
}
659+
660+
async def _send_and_record():
661+
ok, error = await send_webhook(target, batch_payload)
662+
status = (
663+
NotificationDeliveryStatus.SUCCESS if ok else NotificationDeliveryStatus.FAILED
664+
)
665+
for finding in findings:
666+
await record_delivery(
667+
db, str(rule["id"]), str(finding["id"]), status, error,
668+
)
669+
return [
670+
DeliveryResult(
671+
rule_id=str(rule["id"]),
672+
finding_id=str(finding["id"]),
673+
status=status,
674+
error_message=error,
675+
)
676+
for finding in findings
677+
]
678+
679+
results = await _send_and_record()
680+
elif channel == NotificationChannelType.EMAIL.value:
681+
for finding in findings:
682+
payload = build_alert_payload(finding, rule)
683+
ok, error = await send_email(target, payload)
684+
status = (
685+
NotificationDeliveryStatus.SUCCESS if ok else NotificationDeliveryStatus.FAILED
686+
)
687+
await record_delivery(db, str(rule["id"]), str(finding["id"]), status, error)
688+
results.append(
689+
DeliveryResult(
690+
rule_id=str(rule["id"]),
691+
finding_id=str(finding["id"]),
692+
status=status,
693+
error_message=error,
694+
)
695+
)
696+
else:
697+
for finding in findings:
698+
results.append(
699+
DeliveryResult(
700+
rule_id=str(rule["id"]),
701+
finding_id=str(finding["id"]),
702+
status=NotificationDeliveryStatus.FAILED,
703+
error_message=f"Unsupported channel type: {channel}",
704+
)
705+
)
706+
707+
return results
708+
709+
632710
async def process_finding_notifications(
633711
db: Database,
634712
finding_id: str,
@@ -651,14 +729,64 @@ async def process_task_notifications(
651729
db: Database,
652730
task_id: str,
653731
) -> List[DeliveryResult]:
654-
"""Evaluate notifications for every finding produced by a task."""
732+
"""Evaluate notifications for every finding produced by a task.
733+
734+
Batches all findings and rules upfront (2 queries), then evaluates
735+
(finding, rule) pairs in-memory to eliminate N+1 query cascading.
736+
Delivery history records are batched and webhook payloads are
737+
deduplicated per (rule, task).
738+
"""
655739
findings = await db.fetchall(
656-
"SELECT id FROM findings WHERE task_id = ? ORDER BY discovered_at ASC",
740+
"SELECT * FROM findings WHERE task_id = ? ORDER BY discovered_at ASC",
657741
(task_id,),
658742
)
743+
if not findings:
744+
return []
745+
746+
rules = await db.fetchall(
747+
"SELECT * FROM notification_rules WHERE is_active = 1 ORDER BY created_at ASC"
748+
)
749+
if not rules:
750+
return []
751+
752+
existing_deliveries = await db.fetchall(
753+
"""
754+
SELECT nh.rule_id, nh.finding_id
755+
FROM notification_history nh
756+
JOIN findings f ON f.id = nh.finding_id
757+
WHERE f.task_id = ? AND nh.status = ?
758+
""",
759+
(task_id, NotificationDeliveryStatus.SUCCESS.value),
760+
)
761+
already_delivered = {(row["rule_id"], row["finding_id"]) for row in existing_deliveries}
762+
763+
rule_map: Dict[str, Dict[str, Any]] = {str(r["id"]): r for r in rules}
764+
pending_by_rule: Dict[str, List[Dict[str, Any]]] = {}
765+
766+
for finding in findings:
767+
finding_id = str(finding["id"])
768+
for rule in rules:
769+
rule_id = str(rule["id"])
770+
771+
if not bool(rule.get("is_active")):
772+
continue
773+
774+
if not severity_meets_threshold(
775+
str(finding.get("severity", "info")),
776+
str(rule.get("severity_threshold", "info")),
777+
):
778+
continue
779+
780+
if (rule_id, finding_id) in already_delivered:
781+
continue
782+
783+
pending_by_rule.setdefault(rule_id, []).append(finding)
784+
659785
results: List[DeliveryResult] = []
660-
for row in findings:
661-
results.extend(await process_finding_notifications(db, str(row["id"])))
786+
for rule_id, pending_findings in pending_by_rule.items():
787+
rule = rule_map[rule_id]
788+
results.extend(await _deliver_rule_batch(db, rule, pending_findings))
789+
662790
return results
663791

664792

0 commit comments

Comments
 (0)