mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-27 09:06:47 +00:00
prevent duplicate concurrent deliveries
This commit is contained in:
@@ -220,6 +220,14 @@ class HealthPersistence:
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_delivery_claims (
|
||||||
|
fingerprint TEXT PRIMARY KEY,
|
||||||
|
claim_token TEXT NOT NULL,
|
||||||
|
claimed_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS digest_pending (
|
CREATE TABLE IF NOT EXISTS digest_pending (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -287,6 +295,7 @@ class HealthPersistence:
|
|||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_notif_sent_at ON notification_history(sent_at)')
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_notif_sent_at ON notification_history(sent_at)')
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_notif_severity ON notification_history(severity)')
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_notif_severity ON notification_history(severity)')
|
||||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_nls_ts ON notification_last_sent(last_sent_ts)')
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_nls_ts ON notification_last_sent(last_sent_ts)')
|
||||||
|
cursor.execute('CREATE INDEX IF NOT EXISTS idx_notification_claimed_at ON notification_delivery_claims(claimed_at)')
|
||||||
|
|
||||||
# ── Disk Observations System ──
|
# ── Disk Observations System ──
|
||||||
# Registry of all physical disks seen by the system
|
# Registry of all physical disks seen by the system
|
||||||
@@ -419,7 +428,7 @@ class HealthPersistence:
|
|||||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
tables = {row[0] for row in cursor.fetchall()}
|
tables = {row[0] for row in cursor.fetchall()}
|
||||||
required_tables = {'errors', 'events', 'system_capabilities', 'user_settings',
|
required_tables = {'errors', 'events', 'system_capabilities', 'user_settings',
|
||||||
'notification_history', 'notification_last_sent',
|
'notification_history', 'notification_last_sent', 'notification_delivery_claims',
|
||||||
'disk_registry', 'disk_observations',
|
'disk_registry', 'disk_observations',
|
||||||
'excluded_storages', 'excluded_interfaces'}
|
'excluded_storages', 'excluded_interfaces'}
|
||||||
missing = required_tables - tables
|
missing = required_tables - tables
|
||||||
|
|||||||
@@ -392,6 +392,8 @@ DEFAULT_COOLDOWNS = {
|
|||||||
'updates': 86400,
|
'updates': 86400,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_DELIVERY_CLAIM_TTL = 900
|
||||||
|
|
||||||
|
|
||||||
# ─── Storm Protection ────────────────────────────────────────────
|
# ─── Storm Protection ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -807,6 +809,8 @@ class NotificationManager:
|
|||||||
|
|
||||||
# Cooldown tracking: {fingerprint: last_sent_timestamp}
|
# Cooldown tracking: {fingerprint: last_sent_timestamp}
|
||||||
self._cooldowns: Dict[str, float] = {}
|
self._cooldowns: Dict[str, float] = {}
|
||||||
|
self._delivery_claims: Dict[str, str] = {}
|
||||||
|
self._delivery_claim_lock = threading.Lock()
|
||||||
|
|
||||||
# Storm protection
|
# Storm protection
|
||||||
self._group_limiter = GroupRateLimiter()
|
self._group_limiter = GroupRateLimiter()
|
||||||
@@ -1217,50 +1221,37 @@ class NotificationManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # Continue if check fails
|
pass # Continue if check fails
|
||||||
|
|
||||||
# Cooldown check (does NOT stamp yet — see audit Tier 6, cooldown order).
|
claim_token = self._claim_delivery(event)
|
||||||
# If we stamped here, a rate-limit hit or a "no channel enabled for this
|
if claim_token is None:
|
||||||
# event_type" situation would burn a 24h cooldown on a delivery that
|
|
||||||
# never reached anyone.
|
|
||||||
if not self._passes_cooldown(event):
|
|
||||||
return
|
return
|
||||||
|
delivered = False
|
||||||
|
try:
|
||||||
|
template = TEMPLATES.get(event.event_type, {})
|
||||||
|
group = template.get('group', 'other')
|
||||||
|
if not self._group_limiter.allow(group):
|
||||||
|
return
|
||||||
|
|
||||||
# Group rate limit check.
|
severity = event.severity
|
||||||
template = TEMPLATES.get(event.event_type, {})
|
event.data['severity'] = severity
|
||||||
group = template.get('group', 'other')
|
rendered = render_template(event.event_type, event.data)
|
||||||
if not self._group_limiter.allow(group):
|
|
||||||
return
|
enriched_data = dict(event.data)
|
||||||
|
enriched_data['_rendered_fields'] = rendered.get('fields', [])
|
||||||
# Use the properly mapped severity from the event, not from template defaults.
|
enriched_data['_body_html'] = rendered.get('body_html', '')
|
||||||
# event.severity was set by _map_severity which normalises to CRITICAL/WARNING/INFO.
|
enriched_data['_event_type'] = event.event_type
|
||||||
severity = event.severity
|
enriched_data['_group'] = TEMPLATES.get(event.event_type, {}).get('group', 'other')
|
||||||
|
|
||||||
# Inject the canonical severity into data so templates see it too.
|
if '_journal_context' in event.data:
|
||||||
event.data['severity'] = severity
|
enriched_data['_journal_context'] = event.data['_journal_context']
|
||||||
|
|
||||||
# Render message from template (structured output)
|
delivered = self._dispatch_to_channels(
|
||||||
rendered = render_template(event.event_type, event.data)
|
rendered['title'], rendered['body'], severity,
|
||||||
|
event.event_type, enriched_data, event.source
|
||||||
# Enrich data with structured fields for channels that support them
|
)
|
||||||
enriched_data = dict(event.data)
|
finally:
|
||||||
enriched_data['_rendered_fields'] = rendered.get('fields', [])
|
self._finish_delivery_claim(
|
||||||
enriched_data['_body_html'] = rendered.get('body_html', '')
|
event.fingerprint, claim_token, delivered=delivered,
|
||||||
enriched_data['_event_type'] = event.event_type
|
)
|
||||||
enriched_data['_group'] = TEMPLATES.get(event.event_type, {}).get('group', 'other')
|
|
||||||
|
|
||||||
# Pass journal context if available (for AI enrichment)
|
|
||||||
if '_journal_context' in event.data:
|
|
||||||
enriched_data['_journal_context'] = event.data['_journal_context']
|
|
||||||
|
|
||||||
# Send through all active channels (AI applied per-channel with detail_level).
|
|
||||||
# Stamp cooldown only if at least one channel actually delivered — otherwise
|
|
||||||
# a misconfigured per-channel toggle would silently lock the event under a
|
|
||||||
# 24h cooldown until someone re-enables it. Audit Tier 6.
|
|
||||||
delivered = self._dispatch_to_channels(
|
|
||||||
rendered['title'], rendered['body'], severity,
|
|
||||||
event.event_type, enriched_data, event.source
|
|
||||||
)
|
|
||||||
if delivered:
|
|
||||||
self._record_cooldown(event.fingerprint)
|
|
||||||
|
|
||||||
def _dispatch_to_channels(self, title: str, body: str, severity: str,
|
def _dispatch_to_channels(self, title: str, body: str, severity: str,
|
||||||
event_type: str, data: Dict, source: str) -> bool:
|
event_type: str, data: Dict, source: str) -> bool:
|
||||||
@@ -1888,17 +1879,7 @@ class NotificationManager:
|
|||||||
print(f"[NotificationManager] quiet cleanup failed for "
|
print(f"[NotificationManager] quiet cleanup failed for "
|
||||||
f"{ch_name}: {e}")
|
f"{ch_name}: {e}")
|
||||||
|
|
||||||
def _passes_cooldown(self, event: NotificationEvent) -> bool:
|
def _cooldown_seconds(self, event: NotificationEvent) -> int:
|
||||||
"""Check if the event passes cooldown rules WITHOUT stamping.
|
|
||||||
|
|
||||||
Splits the historical `_check_cooldown` into a pure predicate plus
|
|
||||||
`_record_cooldown` (separate stamp). Lets the caller check rate-limit
|
|
||||||
and per-channel filters first — if any of those drop the event, we
|
|
||||||
avoid burning a 24h cooldown on a delivery that never happened.
|
|
||||||
Audit Tier 6 (Notification stack #4 + cooldown/per-channel interaction).
|
|
||||||
"""
|
|
||||||
now = time.time()
|
|
||||||
|
|
||||||
# Determine cooldown period
|
# Determine cooldown period
|
||||||
template = TEMPLATES.get(event.event_type, {})
|
template = TEMPLATES.get(event.event_type, {})
|
||||||
group = template.get('group', 'system')
|
group = template.get('group', 'system')
|
||||||
@@ -1962,15 +1943,112 @@ class NotificationManager:
|
|||||||
_URGENT_EVENTS = {'system_shutdown', 'system_reboot'}
|
_URGENT_EVENTS = {'system_shutdown', 'system_reboot'}
|
||||||
if event.event_type in _URGENT_EVENTS and cooldown_str is None:
|
if event.event_type in _URGENT_EVENTS and cooldown_str is None:
|
||||||
cooldown = 5
|
cooldown = 5
|
||||||
|
|
||||||
# Check against last sent time using stable fingerprint. Stamp is
|
return cooldown
|
||||||
# deferred to `_record_cooldown()` — only invoked once the event has
|
|
||||||
# passed rate-limit AND at least one channel actually delivered it.
|
def _passes_cooldown(self, event: NotificationEvent) -> bool:
|
||||||
|
"""Check the in-memory cooldown without reserving a delivery."""
|
||||||
|
now = time.time()
|
||||||
|
cooldown = self._cooldown_seconds(event)
|
||||||
last_sent = self._cooldowns.get(event.fingerprint, 0)
|
last_sent = self._cooldowns.get(event.fingerprint, 0)
|
||||||
if now - last_sent < cooldown:
|
if now - last_sent < cooldown:
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _claim_delivery(self, event: NotificationEvent) -> Optional[str]:
|
||||||
|
"""Reserve an event fingerprint before any slow channel work begins."""
|
||||||
|
fingerprint = event.fingerprint
|
||||||
|
now = time.time()
|
||||||
|
token = f'{os.getpid()}:{threading.get_ident()}:{time.time_ns()}'
|
||||||
|
|
||||||
|
with self._delivery_claim_lock:
|
||||||
|
if fingerprint in self._delivery_claims:
|
||||||
|
return None
|
||||||
|
if not self._passes_cooldown(event):
|
||||||
|
return None
|
||||||
|
self._delivery_claims[fingerprint] = token
|
||||||
|
|
||||||
|
allowed = True
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||||
|
conn.execute('PRAGMA journal_mode=WAL')
|
||||||
|
conn.execute('PRAGMA busy_timeout=5000')
|
||||||
|
conn.execute('BEGIN IMMEDIATE')
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_delivery_claims (
|
||||||
|
fingerprint TEXT PRIMARY KEY,
|
||||||
|
claim_token TEXT NOT NULL,
|
||||||
|
claimed_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
conn.execute(
|
||||||
|
'DELETE FROM notification_delivery_claims WHERE claimed_at < ?',
|
||||||
|
(int(now - _DELIVERY_CLAIM_TTL),),
|
||||||
|
)
|
||||||
|
row = conn.execute(
|
||||||
|
'SELECT last_sent_ts FROM notification_last_sent WHERE fingerprint = ?',
|
||||||
|
(fingerprint,),
|
||||||
|
).fetchone()
|
||||||
|
if row and now - float(row[0]) < self._cooldown_seconds(event):
|
||||||
|
self._cooldowns[fingerprint] = float(row[0])
|
||||||
|
allowed = False
|
||||||
|
else:
|
||||||
|
cursor = conn.execute('''
|
||||||
|
INSERT OR IGNORE INTO notification_delivery_claims
|
||||||
|
(fingerprint, claim_token, claimed_at) VALUES (?, ?, ?)
|
||||||
|
''', (fingerprint, token, int(now)))
|
||||||
|
allowed = cursor.rowcount == 1
|
||||||
|
conn.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f'[NotificationManager] Delivery claim fallback: {exc}')
|
||||||
|
finally:
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not allowed:
|
||||||
|
with self._delivery_claim_lock:
|
||||||
|
if self._delivery_claims.get(fingerprint) == token:
|
||||||
|
self._delivery_claims.pop(fingerprint, None)
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
|
def _finish_delivery_claim(self, fingerprint: str, token: str,
|
||||||
|
*, delivered: bool) -> None:
|
||||||
|
"""Commit a delivered cooldown or release an unsuccessful claim."""
|
||||||
|
now = time.time()
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||||
|
conn.execute('PRAGMA journal_mode=WAL')
|
||||||
|
conn.execute('PRAGMA busy_timeout=5000')
|
||||||
|
conn.execute('BEGIN IMMEDIATE')
|
||||||
|
if delivered:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT OR REPLACE INTO notification_last_sent
|
||||||
|
(fingerprint, last_sent_ts, count)
|
||||||
|
VALUES (?, ?, COALESCE(
|
||||||
|
(SELECT count + 1 FROM notification_last_sent WHERE fingerprint = ?), 1
|
||||||
|
))
|
||||||
|
''', (fingerprint, int(now), fingerprint))
|
||||||
|
conn.execute('''
|
||||||
|
DELETE FROM notification_delivery_claims
|
||||||
|
WHERE fingerprint = ? AND claim_token = ?
|
||||||
|
''', (fingerprint, token))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f'[NotificationManager] Delivery claim completion fallback: {exc}')
|
||||||
|
if delivered:
|
||||||
|
self._persist_cooldown(fingerprint, now)
|
||||||
|
finally:
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
with self._delivery_claim_lock:
|
||||||
|
if delivered:
|
||||||
|
self._cooldowns[fingerprint] = now
|
||||||
|
if self._delivery_claims.get(fingerprint) == token:
|
||||||
|
self._delivery_claims.pop(fingerprint, None)
|
||||||
|
|
||||||
def _record_cooldown(self, fingerprint: str):
|
def _record_cooldown(self, fingerprint: str):
|
||||||
"""Stamp the cooldown for a fingerprint that was actually delivered."""
|
"""Stamp the cooldown for a fingerprint that was actually delivered."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|||||||
Reference in New Issue
Block a user