|
@@ -11,8 +11,14 @@
|
|
|
// channel_mask, quiet hours
|
|
// channel_mask, quiet hours
|
|
|
// 4. inminent_colapse bypasses quiet hours
|
|
// 4. inminent_colapse bypasses quiet hours
|
|
|
//
|
|
//
|
|
|
-// The resolver is one SQL round-trip (a single CTE) so it's
|
|
|
|
|
-// roughly the same DB cost as M1.
|
|
|
|
|
|
|
+// M3: the resolver also resolves channel='telegram' targets
|
|
|
|
|
+// from the same subscription set, when:
|
|
|
|
|
+//
|
|
|
|
|
+// - the subscription's channel_mask contains 'telegram', AND
|
|
|
|
|
+// - the individual has a non-null telegram_chat_id
|
|
|
|
|
+//
|
|
|
|
|
+// The query is one DB round-trip (a single CTE) so it's
|
|
|
|
|
+// roughly the same DB cost as M1/M2.
|
|
|
//
|
|
//
|
|
|
// Hard-fail: if the resolver returns zero targets, routerd drops
|
|
// Hard-fail: if the resolver returns zero targets, routerd drops
|
|
|
// the alert with a log line. We do NOT silently fall back to a
|
|
// the alert with a log line. We do NOT silently fall back to a
|
|
@@ -55,21 +61,23 @@ func New(pool *postgres.Pool, logger *slog.Logger) *Resolver {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ResolveTargets returns every (individual, channel, endpoint) tuple
|
|
// ResolveTargets returns every (individual, channel, endpoint) tuple
|
|
|
-// that should receive the alert. M2 contract:
|
|
|
|
|
|
|
+// that should receive the alert. M3 contract:
|
|
|
//
|
|
//
|
|
|
// - hard-fail (return zero targets) if no recipient matches
|
|
// - hard-fail (return zero targets) if no recipient matches
|
|
|
// - hard-fail (return zero targets) if the company has no source
|
|
// - hard-fail (return zero targets) if the company has no source
|
|
|
// row for the alert's source_id
|
|
// row for the alert's source_id
|
|
|
// - inminent_colapse bypasses quiet hours
|
|
// - inminent_colapse bypasses quiet hours
|
|
|
// - one Target per (individual, channel) where channel ∈
|
|
// - one Target per (individual, channel) where channel ∈
|
|
|
-// sub.channel_mask AND the source has at least one active
|
|
|
|
|
-// endpoint for that channel
|
|
|
|
|
|
|
+// sub.channel_mask AND the individual has at least one
|
|
|
|
|
+// active endpoint for that channel
|
|
|
//
|
|
//
|
|
|
-// For M2 we only resolve 'fcm' endpoints. Other channels listed
|
|
|
|
|
-// in channel_mask pass through; the corresponding deliverd
|
|
|
|
|
-// worker is M3+.
|
|
|
|
|
|
|
+// For M3 we resolve 'fcm' and 'telegram'. Other channels listed
|
|
|
|
|
+// in channel_mask pass through with empty endpoint, which the
|
|
|
|
|
+// routerd caller is expected to drop (or which M3+ deliverd-X
|
|
|
|
|
+// workers can pick up).
|
|
|
//
|
|
//
|
|
|
-// One DB round-trip via a single CTE.
|
|
|
|
|
|
|
+// One DB round-trip via a single CTE that UNION ALLs an
|
|
|
|
|
+// fcm-resolution branch and a telegram-resolution branch.
|
|
|
func (r *Resolver) ResolveTargets(ctx context.Context, a *alert.Alert) ([]Target, error) {
|
|
func (r *Resolver) ResolveTargets(ctx context.Context, a *alert.Alert) ([]Target, error) {
|
|
|
if a == nil {
|
|
if a == nil {
|
|
|
return nil, fmt.Errorf("nil alert")
|
|
return nil, fmt.Errorf("nil alert")
|
|
@@ -78,35 +86,6 @@ func (r *Resolver) ResolveTargets(ctx context.Context, a *alert.Alert) ([]Target
|
|
|
return nil, fmt.Errorf("invalid severity %q", a.Severity)
|
|
return nil, fmt.Errorf("invalid severity %q", a.Severity)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // The query does the following in one round-trip:
|
|
|
|
|
- //
|
|
|
|
|
- // cands: set of individual_ids implied by:
|
|
|
|
|
- // (a) source.allowed_targets (groups, individuals, broadcast)
|
|
|
|
|
- // (b) routing_rules where match_expr matches the alert
|
|
|
|
|
- // `broadcast` expands to every active individual in the
|
|
|
|
|
- // company.
|
|
|
|
|
- //
|
|
|
|
|
- // cands ⊗ subscriptions (active only, joined on individual_id +
|
|
|
|
|
- // source_id) — gives us per-(individual, source) settings.
|
|
|
|
|
- //
|
|
|
|
|
- // Filters:
|
|
|
|
|
- // - min_severity rank <= alert severity rank
|
|
|
|
|
- // - quiet hours in subscriber's tz, bypassed for inminent_colapse
|
|
|
|
|
- // - channel_mask contains the channel we want to deliver to
|
|
|
|
|
- //
|
|
|
|
|
- // Output is one row per (individual, channel) where the channel
|
|
|
|
|
- // is in the subscription's channel_mask AND the individual has
|
|
|
|
|
- // at least one active endpoint for that channel.
|
|
|
|
|
- //
|
|
|
|
|
- // The current_time_in_tz calculation uses a server-side function
|
|
|
|
|
- // call so we don't have to think about it in Go.
|
|
|
|
|
- //
|
|
|
|
|
- // Note: we deliberately do NOT use a CTE-with-Window-Function for
|
|
|
|
|
- // routing_rules — M2's resolver picks ALL matching rules (priority
|
|
|
|
|
- // order doesn't matter yet, see SPEC §6 + M2 honest flag in
|
|
|
|
|
- // PROMPT.md) and unions their targets. M3+ can add priority
|
|
|
|
|
- // semantics when needed.
|
|
|
|
|
-
|
|
|
|
|
const q = `
|
|
const q = `
|
|
|
WITH src AS (
|
|
WITH src AS (
|
|
|
SELECT id, company_id, allowed_targets, match_expr
|
|
SELECT id, company_id, allowed_targets, match_expr
|
|
@@ -135,11 +114,6 @@ allowed_individual_ids AS (
|
|
|
SELECT id
|
|
SELECT id
|
|
|
FROM individuals
|
|
FROM individuals
|
|
|
WHERE company_id = $1 AND status = 'active'
|
|
WHERE company_id = $1 AND status = 'active'
|
|
|
-
|
|
|
|
|
- -- The broadcast is included whenever the source row exists.
|
|
|
|
|
- -- This is the M2 "fall back to everyone" behavior; the user's
|
|
|
|
|
- -- Q2 answer was hard-fail-when-zero, so this query only runs
|
|
|
|
|
- -- if src exists. See comment below.
|
|
|
|
|
),
|
|
),
|
|
|
rule_individual_ids AS (
|
|
rule_individual_ids AS (
|
|
|
-- routing_rules that match the alert
|
|
-- routing_rules that match the alert
|
|
@@ -165,7 +139,8 @@ candidates AS (
|
|
|
SELECT individual_id FROM rule_individual_ids WHERE individual_id IS NOT NULL
|
|
SELECT individual_id FROM rule_individual_ids WHERE individual_id IS NOT NULL
|
|
|
),
|
|
),
|
|
|
-- For each candidate individual, for each channel in their
|
|
-- For each candidate individual, for each channel in their
|
|
|
--- subscription's channel_mask, produce one row.
|
|
|
|
|
|
|
+-- subscription's channel_mask, produce one row. Quiet hours
|
|
|
|
|
+-- and min_severity filtering happens here.
|
|
|
sub_expanded AS (
|
|
sub_expanded AS (
|
|
|
SELECT
|
|
SELECT
|
|
|
s.individual_id,
|
|
s.individual_id,
|
|
@@ -210,52 +185,52 @@ filtered AS (
|
|
|
$4::text = 'inminent_colapse'
|
|
$4::text = 'inminent_colapse'
|
|
|
OR se.quiet_hours_start IS NULL
|
|
OR se.quiet_hours_start IS NULL
|
|
|
OR se.quiet_hours_end IS NULL
|
|
OR se.quiet_hours_end IS NULL
|
|
|
- OR NOT (
|
|
|
|
|
- -- 'now in tz' is between start and end, with wrap
|
|
|
|
|
- -- support. The local-time comparison happens in Go
|
|
|
|
|
- -- via the per-row filter below; here we just
|
|
|
|
|
- -- include all rows and let Go do the tz math.
|
|
|
|
|
- FALSE
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ OR NOT (FALSE)
|
|
|
)
|
|
)
|
|
|
|
|
+),
|
|
|
|
|
+-- FCM branch: pick the first active token for each individual.
|
|
|
|
|
+fcm_rows AS (
|
|
|
|
|
+ SELECT
|
|
|
|
|
+ f.individual_id,
|
|
|
|
|
+ 'fcm'::text AS channel,
|
|
|
|
|
+ t.token AS endpoint,
|
|
|
|
|
+ COALESCE(t.locale, i.locale, 'en') AS locale,
|
|
|
|
|
+ f.quiet_hours_start,
|
|
|
|
|
+ f.quiet_hours_end,
|
|
|
|
|
+ f.tz
|
|
|
|
|
+ FROM filtered f
|
|
|
|
|
+ JOIN individuals i ON i.id = f.individual_id AND i.status = 'active'
|
|
|
|
|
+ JOIN fcm_tokens t ON t.individual_id = f.individual_id AND t.status = 'active'
|
|
|
|
|
+ WHERE f.channel = 'fcm'
|
|
|
|
|
+),
|
|
|
|
|
+-- Telegram branch: pick telegram_chat_id (only if linked).
|
|
|
|
|
+-- Quiet hours still apply; the resolver passes the row through
|
|
|
|
|
+-- and the Go-side filter handles the bypass.
|
|
|
|
|
+tg_rows AS (
|
|
|
|
|
+ SELECT
|
|
|
|
|
+ f.individual_id,
|
|
|
|
|
+ 'telegram'::text AS channel,
|
|
|
|
|
+ i.telegram_chat_id::text AS endpoint,
|
|
|
|
|
+ COALESCE(i.locale, 'en') AS locale,
|
|
|
|
|
+ f.quiet_hours_start,
|
|
|
|
|
+ f.quiet_hours_end,
|
|
|
|
|
+ f.tz
|
|
|
|
|
+ FROM filtered f
|
|
|
|
|
+ JOIN individuals i ON i.id = f.individual_id AND i.status = 'active'
|
|
|
|
|
+ WHERE f.channel = 'telegram'
|
|
|
|
|
+ AND i.telegram_chat_id IS NOT NULL
|
|
|
)
|
|
)
|
|
|
-SELECT
|
|
|
|
|
- f.individual_id,
|
|
|
|
|
- f.channel,
|
|
|
|
|
- COALESCE(
|
|
|
|
|
- (SELECT t.token FROM fcm_tokens t
|
|
|
|
|
- WHERE t.individual_id = f.individual_id
|
|
|
|
|
- AND t.status = 'active'
|
|
|
|
|
- ORDER BY t.id
|
|
|
|
|
- LIMIT 1),
|
|
|
|
|
- ''
|
|
|
|
|
- ) AS endpoint,
|
|
|
|
|
- COALESCE(
|
|
|
|
|
- (SELECT COALESCE(t.locale, i.locale, 'en')
|
|
|
|
|
- FROM individuals i
|
|
|
|
|
- LEFT JOIN fcm_tokens t ON t.individual_id = i.id AND t.status = 'active'
|
|
|
|
|
- WHERE i.id = f.individual_id
|
|
|
|
|
- ORDER BY t.id
|
|
|
|
|
- LIMIT 1),
|
|
|
|
|
- 'en'
|
|
|
|
|
- ) AS locale,
|
|
|
|
|
- f.quiet_hours_start,
|
|
|
|
|
- f.quiet_hours_end,
|
|
|
|
|
- f.tz
|
|
|
|
|
-FROM filtered f
|
|
|
|
|
-WHERE f.channel = 'fcm' -- M2: only fcm is resolvable; other channels
|
|
|
|
|
- -- pass through as channel='…' but with
|
|
|
|
|
- -- empty endpoint, dropped in Go.
|
|
|
|
|
- AND EXISTS (
|
|
|
|
|
- SELECT 1 FROM fcm_tokens t
|
|
|
|
|
- WHERE t.individual_id = f.individual_id
|
|
|
|
|
- AND t.status = 'active'
|
|
|
|
|
- )
|
|
|
|
|
-ORDER BY f.individual_id, f.channel;
|
|
|
|
|
|
|
+SELECT individual_id, channel, endpoint, locale,
|
|
|
|
|
+ quiet_hours_start, quiet_hours_end, tz
|
|
|
|
|
+FROM fcm_rows
|
|
|
|
|
+UNION ALL
|
|
|
|
|
+SELECT individual_id, channel, endpoint, locale,
|
|
|
|
|
+ quiet_hours_start, quiet_hours_end, tz
|
|
|
|
|
+FROM tg_rows
|
|
|
|
|
+ORDER BY channel, individual_id;
|
|
|
`
|
|
`
|
|
|
|
|
|
|
|
// $5 and $6: keys/values from alert.Data for routing rule data match.
|
|
// $5 and $6: keys/values from alert.Data for routing rule data match.
|
|
|
- // We pass them as parallel arrays; the SQL uses ANY() with both.
|
|
|
|
|
var dataKeys, dataVals []string
|
|
var dataKeys, dataVals []string
|
|
|
for k, v := range a.Data {
|
|
for k, v := range a.Data {
|
|
|
dataKeys = append(dataKeys, k)
|
|
dataKeys = append(dataKeys, k)
|
|
@@ -293,23 +268,14 @@ ORDER BY f.individual_id, f.channel;
|
|
|
return nil, err
|
|
return nil, err
|
|
|
}
|
|
}
|
|
|
// Final quiet-hours check (Go side, in the subscriber's tz).
|
|
// Final quiet-hours check (Go side, in the subscriber's tz).
|
|
|
- // SQL filter above already short-circuited non-bypass
|
|
|
|
|
- // alerts to "always allow" when there's no quiet window;
|
|
|
|
|
- // here we just need to compute the actual local time and
|
|
|
|
|
- // compare. We use the system local time converted to `tz`
|
|
|
|
|
- // via a fixed offset lookup — for M2 we only support UTC
|
|
|
|
|
- // and fixed offsets in `tz` like "UTC+5:30" via the
|
|
|
|
|
- // standard Go time package.
|
|
|
|
|
if a.Severity != alert.SeverityInminentColapse && qStart != nil && qEnd != nil {
|
|
if a.Severity != alert.SeverityInminentColapse && qStart != nil && qEnd != nil {
|
|
|
if inQuietHours(now, *qStart, *qEnd, tz) {
|
|
if inQuietHours(now, *qStart, *qEnd, tz) {
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- // M2 only emits endpoints for 'fcm'. Other channels drop
|
|
|
|
|
- // here. When M3 adds telegram, the SQL UNION gets
|
|
|
|
|
- // telegram_chat_id from individuals; the channel='fcm'
|
|
|
|
|
- // filter on the SELECT becomes channel = ANY($channels).
|
|
|
|
|
- if t.Channel != "fcm" || t.Endpoint == "" {
|
|
|
|
|
|
|
+ if t.Endpoint == "" {
|
|
|
|
|
+ // The SQL filters the obvious case (NULL
|
|
|
|
|
+ // telegram_chat_id), but be defensive.
|
|
|
continue
|
|
continue
|
|
|
}
|
|
}
|
|
|
out = append(out, t)
|
|
out = append(out, t)
|
|
@@ -322,7 +288,7 @@ ORDER BY f.individual_id, f.channel;
|
|
|
// Hard-fail: don't silently broadcast. Log the empty result
|
|
// Hard-fail: don't silently broadcast. Log the empty result
|
|
|
// so the operator can debug. The routerd caller is expected
|
|
// so the operator can debug. The routerd caller is expected
|
|
|
// to nack the alert and (in M3+) send to a `dlq.no_recipients`
|
|
// to nack the alert and (in M3+) send to a `dlq.no_recipients`
|
|
|
- // subject. For M2 we just log.
|
|
|
|
|
|
|
+ // subject. For M3 we just log.
|
|
|
r.logger.Warn("no recipients resolved",
|
|
r.logger.Warn("no recipients resolved",
|
|
|
"company_id", a.CompanyID,
|
|
"company_id", a.CompanyID,
|
|
|
"source_id", a.SourceID,
|
|
"source_id", a.SourceID,
|
|
@@ -342,15 +308,14 @@ ORDER BY f.individual_id, f.channel;
|
|
|
func inQuietHours(now time.Time, start, end time.Time, tz string) bool {
|
|
func inQuietHours(now time.Time, start, end time.Time, tz string) bool {
|
|
|
loc, err := time.LoadLocation(tz)
|
|
loc, err := time.LoadLocation(tz)
|
|
|
if err != nil {
|
|
if err != nil {
|
|
|
- // Fall back to UTC. Acceptable for M2; M2.5+ can add
|
|
|
|
|
- // tzdata support to the binary.
|
|
|
|
|
|
|
+ // Fall back to UTC. Acceptable for M2/M3; M2.5+ can
|
|
|
|
|
+ // add tzdata support to the binary.
|
|
|
loc = time.UTC
|
|
loc = time.UTC
|
|
|
}
|
|
}
|
|
|
nowLocal := now.In(loc)
|
|
nowLocal := now.In(loc)
|
|
|
|
|
|
|
|
// We only care about HH:MM:SS of nowLocal.
|
|
// We only care about HH:MM:SS of nowLocal.
|
|
|
nowT := time.Date(0, 1, 1, nowLocal.Hour(), nowLocal.Minute(), nowLocal.Second(), 0, time.UTC)
|
|
nowT := time.Date(0, 1, 1, nowLocal.Hour(), nowLocal.Minute(), nowLocal.Second(), 0, time.UTC)
|
|
|
- // Normalize start/end to the same "wall clock" reference.
|
|
|
|
|
sT := time.Date(0, 1, 1, start.Hour(), start.Minute(), start.Second(), 0, time.UTC)
|
|
sT := time.Date(0, 1, 1, start.Hour(), start.Minute(), start.Second(), 0, time.UTC)
|
|
|
eT := time.Date(0, 1, 1, end.Hour(), end.Minute(), end.Second(), 0, time.UTC)
|
|
eT := time.Date(0, 1, 1, end.Hour(), end.Minute(), end.Second(), 0, time.UTC)
|
|
|
|
|
|