mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 03:00:29 +00:00
fix(observers): separate packet metrics from presence updates
This commit is contained in:
@@ -55,7 +55,7 @@ jobs:
|
||||
- name: Verify stats and endpoint queries against PostgreSQL 16
|
||||
env:
|
||||
BEACON_TEST_POSTGRES_DSN: postgres://postgres:backup-ci-only@127.0.0.1:5432/postgres?sslmode=disable
|
||||
run: go test ./db -run '^Test(Signal|Paths|PacketEndpointsResolveLive|AnalyticsRetention|AnalyticsRetentionConcurrent|DeleteOldPacketsBatches)Postgres$' -count=1 -v
|
||||
run: go test ./db -run '^Test(Signal|Paths|PacketEndpointsResolveLive|ObserverMetrics|AnalyticsRetention|AnalyticsRetentionConcurrent|DeleteOldPacketsBatches)Postgres$' -count=1 -v
|
||||
|
||||
- name: Verify backup command against PostgreSQL 16
|
||||
run: |
|
||||
|
||||
@@ -550,3 +550,21 @@ sub-hour observer activity, exact observer comparison and current entity/scope
|
||||
counts continue to describe retained raw data or current entities, rather than
|
||||
claiming archived packet detail. Archived summaries expire without waiting for
|
||||
new packet deletions. A failed archive leaves its entire raw batch intact.
|
||||
|
||||
### Observer monitoring metrics
|
||||
|
||||
Observer activity returns complete buckets in `[windowStart, windowEnd)`, plus
|
||||
`generatedAt`, `source` (`raw` or `hourly`) and `summary`. `recordedPackets` is the
|
||||
sum of stored observations in those buckets; repeated broker delivery of the
|
||||
same retained packet/observer pair counts once. Unknown payload types appear as
|
||||
`-1` rather than disappearing. `lastCompleteHour` uses the previous complete UTC
|
||||
hour and includes its own start/end; `latestRecordedAt` is the latest retained
|
||||
reception timestamp. Missing records do not prove downtime. The optional `until`
|
||||
(epoch milliseconds, within the last 30 days) aligns two observers' charts.
|
||||
|
||||
The existing observer `observationCount` remains a legacy cumulative presence
|
||||
counter for compatibility, including status/neighbour events. It is not a
|
||||
period packet total. Broker presence and packet-arrival timestamps are now
|
||||
updated separately; this cannot reconstruct previously overwritten timestamps.
|
||||
Migration 040 repairs archived unknown-type counts from the all-payload observer
|
||||
rollup; radio samples already discarded for those legacy rows remain unknown.
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
-- Copyright 2026 Beacon Contributors
|
||||
-- SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-- Unknown legacy payloads are still recorded packets. Restore their archived
|
||||
-- counts from the all-payload observer rollup; their discarded radio samples
|
||||
-- cannot be recovered. Subtract all existing activity (including prior repairs)
|
||||
-- so migration-journal retries do not double-count or erase newer samples.
|
||||
INSERT INTO analytics_observer_activity_hourly
|
||||
(observer_id,payload_type,bucket,observations,airtime_n,snr_n,rssi_n)
|
||||
SELECT totals.observer_id, -1, totals.bucket, totals.n-COALESCE(activity.n,0),0,0,0
|
||||
FROM (SELECT observer_id,bucket,SUM(observation_count)::bigint AS n
|
||||
FROM analytics_top_observers_by_iata GROUP BY observer_id,bucket) totals
|
||||
LEFT JOIN (SELECT observer_id,bucket,SUM(observations)::bigint AS n
|
||||
FROM analytics_observer_activity_hourly GROUP BY observer_id,bucket) activity
|
||||
USING (observer_id,bucket)
|
||||
WHERE totals.n>COALESCE(activity.n,0)
|
||||
ON CONFLICT (observer_id,payload_type,bucket) DO UPDATE
|
||||
SET observations=analytics_observer_activity_hourly.observations+EXCLUDED.observations;
|
||||
|
||||
DROP MATERIALIZED VIEW mv_observer_activity_hourly;
|
||||
CREATE OR REPLACE VIEW analytics_live_observer_activity_hourly AS
|
||||
SELECT
|
||||
observer_id,
|
||||
COALESCE(payload_type, -1)::smallint AS payload_type,
|
||||
date_trunc('hour', heard_at, 'UTC')::timestamptz AS bucket,
|
||||
COUNT(*)::bigint AS observations,
|
||||
SUM(airtime_ms)::real AS airtime_ms,
|
||||
COUNT(airtime_ms)::bigint AS airtime_n,
|
||||
SUM(snr) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0))::real AS snr_sum,
|
||||
COUNT(snr) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0))::bigint AS snr_n,
|
||||
MIN(snr) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0))::real AS snr_min,
|
||||
SUM(rssi) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0))::bigint AS rssi_sum,
|
||||
COUNT(rssi) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0))::bigint AS rssi_n
|
||||
FROM packet_observations
|
||||
WHERE heard_at > NOW() - INTERVAL '30 days'
|
||||
GROUP BY observer_id, COALESCE(payload_type, -1), date_trunc('hour', heard_at, 'UTC');
|
||||
|
||||
CREATE MATERIALIZED VIEW mv_observer_activity_hourly AS
|
||||
WITH combined AS (
|
||||
SELECT observer_id, payload_type, bucket, observations, airtime_ms, airtime_n, snr_sum, snr_n, snr_min, rssi_sum, rssi_n FROM analytics_live_observer_activity_hourly
|
||||
UNION ALL
|
||||
SELECT observer_id, payload_type, bucket, observations, airtime_ms, airtime_n, snr_sum, snr_n, snr_min, rssi_sum, rssi_n FROM analytics_observer_activity_hourly
|
||||
WHERE bucket >= date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days'
|
||||
)
|
||||
SELECT observer_id, payload_type, bucket, SUM(observations)::bigint AS observations, SUM(airtime_ms)::real AS airtime_ms, SUM(airtime_n)::bigint AS airtime_n, SUM(snr_sum)::real AS snr_sum, SUM(snr_n)::bigint AS snr_n, MIN(snr_min)::real AS snr_min, SUM(rssi_sum)::bigint AS rssi_sum, SUM(rssi_n)::bigint AS rssi_n
|
||||
FROM combined GROUP BY observer_id,payload_type,bucket;
|
||||
|
||||
CREATE UNIQUE INDEX idx_analytics_observer_activity_hourly_view ON mv_observer_activity_hourly (observer_id,payload_type,bucket);
|
||||
|
||||
|
||||
|
||||
-- Preserve the bounded atomic archive, including unknown payload activity.
|
||||
CREATE OR REPLACE FUNCTION archive_delete_packets(cutoff timestamptz, batch_size integer)
|
||||
RETURNS bigint LANGUAGE plpgsql VOLATILE AS $$
|
||||
DECLARE hashes bytea[]; deleted bigint;
|
||||
BEGIN
|
||||
IF batch_size < 1 THEN RAISE EXCEPTION 'batch_size must be positive'; END IF;
|
||||
SELECT array_agg(p.packet_hash) INTO hashes FROM (
|
||||
SELECT ep.packet_hash FROM packets ep
|
||||
WHERE ep.last_heard_at < cutoff
|
||||
ORDER BY ep.last_heard_at, ep.packet_hash
|
||||
LIMIT batch_size FOR UPDATE OF ep SKIP LOCKED
|
||||
) p;
|
||||
|
||||
WITH expired_observations AS MATERIALIZED (
|
||||
SELECT po.* FROM packet_observations po WHERE po.packet_hash = ANY(hashes)
|
||||
),
|
||||
archived_hourly_iata_stats AS (
|
||||
INSERT INTO analytics_hourly_iata_stats (iata, hour, observation_count, unique_packets)
|
||||
SELECT iata, hour, observation_count, unique_packets FROM (
|
||||
SELECT
|
||||
iata,
|
||||
date_trunc('hour', heard_at, 'UTC')::timestamptz AS hour,
|
||||
COUNT(*) AS observation_count,
|
||||
COUNT(DISTINCT packet_hash) AS unique_packets
|
||||
FROM expired_observations
|
||||
WHERE heard_at >= date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days'
|
||||
GROUP BY iata, date_trunc('hour', heard_at, 'UTC')
|
||||
) batch
|
||||
ON CONFLICT (iata,hour) DO UPDATE SET
|
||||
observation_count = analytics_hourly_iata_stats.observation_count + EXCLUDED.observation_count,
|
||||
unique_packets = analytics_hourly_iata_stats.unique_packets + EXCLUDED.unique_packets
|
||||
),
|
||||
archived_payload_breakdown_by_iata AS (
|
||||
INSERT INTO analytics_payload_breakdown_by_iata (iata, payload_type, bucket, count)
|
||||
SELECT iata, payload_type, bucket, count FROM (
|
||||
SELECT
|
||||
iata,
|
||||
payload_type,
|
||||
date_trunc('hour', heard_at, 'UTC')::timestamptz AS bucket,
|
||||
COUNT(*) AS count
|
||||
FROM expired_observations
|
||||
WHERE heard_at >= date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days'
|
||||
AND payload_type IS NOT NULL
|
||||
GROUP BY iata, payload_type, date_trunc('hour', heard_at, 'UTC')
|
||||
) batch
|
||||
ON CONFLICT (iata,payload_type,bucket) DO UPDATE SET
|
||||
count = analytics_payload_breakdown_by_iata.count + EXCLUDED.count
|
||||
),
|
||||
archived_top_observers_by_iata AS (
|
||||
INSERT INTO analytics_top_observers_by_iata (iata, observer_id, bucket, observation_count, display_name, observer_type)
|
||||
SELECT iata, observer_id, bucket, observation_count, display_name, observer_type FROM (
|
||||
SELECT
|
||||
po.iata,
|
||||
po.observer_id,
|
||||
o.display_name,
|
||||
o.observer_type,
|
||||
date_trunc('hour', po.heard_at, 'UTC')::timestamptz AS bucket,
|
||||
COUNT(*) AS observation_count
|
||||
FROM expired_observations po
|
||||
JOIN observers o ON o.id = po.observer_id
|
||||
WHERE po.heard_at >= date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days'
|
||||
GROUP BY po.iata, po.observer_id, o.display_name, o.observer_type, date_trunc('hour', po.heard_at, 'UTC')
|
||||
) batch
|
||||
ON CONFLICT (iata,observer_id,bucket) DO UPDATE SET
|
||||
observation_count = analytics_top_observers_by_iata.observation_count + EXCLUDED.observation_count,
|
||||
display_name = EXCLUDED.display_name,
|
||||
observer_type = EXCLUDED.observer_type
|
||||
),
|
||||
archived_top_talkers_by_iata AS (
|
||||
INSERT INTO analytics_top_talkers_by_iata (iata, sender_name, bucket, message_count, last_sent)
|
||||
SELECT iata, sender_name, bucket, message_count, last_sent FROM (
|
||||
SELECT
|
||||
po.iata,
|
||||
cm.sender_name,
|
||||
date_trunc('hour', cm.sent_at, 'UTC')::timestamptz AS bucket,
|
||||
COUNT(DISTINCT cm.id) AS message_count,
|
||||
MAX(cm.sent_at) AS last_sent
|
||||
FROM channel_messages cm
|
||||
JOIN expired_observations po ON po.packet_hash = cm.packet_hash
|
||||
WHERE cm.sender_name IS NOT NULL
|
||||
AND cm.sent_at >= date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days'
|
||||
GROUP BY po.iata, cm.sender_name, date_trunc('hour', cm.sent_at, 'UTC')
|
||||
) batch
|
||||
ON CONFLICT (iata,sender_name,bucket) DO UPDATE SET
|
||||
message_count = analytics_top_talkers_by_iata.message_count + EXCLUDED.message_count,
|
||||
last_sent = GREATEST(analytics_top_talkers_by_iata.last_sent, EXCLUDED.last_sent)
|
||||
),
|
||||
archived_top_advertisers_by_iata AS (
|
||||
INSERT INTO analytics_top_advertisers_by_iata (iata, node_id, bucket, advert_count, flood_advert_count, direct_advert_count, last_heard, name, node_type)
|
||||
SELECT iata, node_id, bucket, advert_count, flood_advert_count, direct_advert_count, last_heard, name, node_type FROM (
|
||||
SELECT
|
||||
po.iata,
|
||||
n.id AS node_id,
|
||||
n.name,
|
||||
n.node_type,
|
||||
date_trunc('hour', po.heard_at, 'UTC')::timestamptz AS bucket,
|
||||
COUNT(DISTINCT p.packet_hash) AS advert_count,
|
||||
COUNT(DISTINCT p.packet_hash) FILTER (WHERE p.route_type IN (0, 1)) AS flood_advert_count,
|
||||
COUNT(DISTINCT p.packet_hash) FILTER (WHERE p.route_type IN (2, 3)) AS direct_advert_count,
|
||||
MAX(po.heard_at) AS last_heard
|
||||
FROM packets p
|
||||
JOIN expired_observations po ON po.packet_hash = p.packet_hash
|
||||
JOIN nodes n ON n.public_key = p.origin_pubkey
|
||||
WHERE p.payload_type = 4 -- ADVERT
|
||||
AND po.heard_at >= date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days'
|
||||
GROUP BY po.iata, n.id, n.name, n.node_type, date_trunc('hour', po.heard_at, 'UTC')
|
||||
) batch
|
||||
ON CONFLICT (iata,node_id,bucket) DO UPDATE SET
|
||||
advert_count = analytics_top_advertisers_by_iata.advert_count + EXCLUDED.advert_count,
|
||||
flood_advert_count = analytics_top_advertisers_by_iata.flood_advert_count + EXCLUDED.flood_advert_count,
|
||||
direct_advert_count = analytics_top_advertisers_by_iata.direct_advert_count + EXCLUDED.direct_advert_count,
|
||||
last_heard = GREATEST(analytics_top_advertisers_by_iata.last_heard, EXCLUDED.last_heard),
|
||||
name = EXCLUDED.name,
|
||||
node_type = EXCLUDED.node_type
|
||||
),
|
||||
archived_observer_activity_hourly AS (
|
||||
INSERT INTO analytics_observer_activity_hourly (observer_id, payload_type, bucket, observations, airtime_ms, airtime_n, snr_sum, snr_n, snr_min, rssi_sum, rssi_n)
|
||||
SELECT observer_id, payload_type, bucket, observations, airtime_ms, airtime_n, snr_sum, snr_n, snr_min, rssi_sum, rssi_n FROM (
|
||||
SELECT
|
||||
observer_id,
|
||||
COALESCE(payload_type, -1)::smallint AS payload_type,
|
||||
date_trunc('hour', heard_at, 'UTC')::timestamptz AS bucket,
|
||||
COUNT(*)::bigint AS observations,
|
||||
SUM(airtime_ms)::real AS airtime_ms,
|
||||
COUNT(airtime_ms)::bigint AS airtime_n,
|
||||
SUM(snr) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0))::real AS snr_sum,
|
||||
COUNT(snr) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0))::bigint AS snr_n,
|
||||
MIN(snr) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0))::real AS snr_min,
|
||||
SUM(rssi) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0))::bigint AS rssi_sum,
|
||||
COUNT(rssi) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0))::bigint AS rssi_n
|
||||
FROM expired_observations
|
||||
WHERE heard_at >= date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days'
|
||||
GROUP BY observer_id, COALESCE(payload_type, -1), date_trunc('hour', heard_at, 'UTC')
|
||||
) batch
|
||||
ON CONFLICT (observer_id,payload_type,bucket) DO UPDATE SET
|
||||
observations = analytics_observer_activity_hourly.observations + EXCLUDED.observations,
|
||||
airtime_ms = CASE WHEN analytics_observer_activity_hourly.airtime_ms IS NULL AND EXCLUDED.airtime_ms IS NULL THEN NULL ELSE COALESCE(analytics_observer_activity_hourly.airtime_ms, 0) + COALESCE(EXCLUDED.airtime_ms, 0) END,
|
||||
airtime_n = analytics_observer_activity_hourly.airtime_n + EXCLUDED.airtime_n,
|
||||
snr_sum = CASE WHEN analytics_observer_activity_hourly.snr_sum IS NULL AND EXCLUDED.snr_sum IS NULL THEN NULL ELSE COALESCE(analytics_observer_activity_hourly.snr_sum, 0) + COALESCE(EXCLUDED.snr_sum, 0) END,
|
||||
snr_n = analytics_observer_activity_hourly.snr_n + EXCLUDED.snr_n,
|
||||
snr_min = LEAST(analytics_observer_activity_hourly.snr_min, EXCLUDED.snr_min),
|
||||
rssi_sum = CASE WHEN analytics_observer_activity_hourly.rssi_sum IS NULL AND EXCLUDED.rssi_sum IS NULL THEN NULL ELSE COALESCE(analytics_observer_activity_hourly.rssi_sum, 0) + COALESCE(EXCLUDED.rssi_sum, 0) END,
|
||||
rssi_n = analytics_observer_activity_hourly.rssi_n + EXCLUDED.rssi_n
|
||||
),
|
||||
archived_signal_stats_hourly AS (
|
||||
INSERT INTO analytics_signal_stats_hourly (iata, hour, kind, snr_bin, rssi_bin, receptions, snr_samples, snr_sum, rssi_samples, rssi_sum)
|
||||
SELECT iata, hour, kind, snr_bin, rssi_bin, receptions, snr_samples, snr_sum, rssi_samples, rssi_sum FROM (
|
||||
WITH samples AS (
|
||||
SELECT iata, date_trunc('hour', heard_at, 'UTC') AS hour,
|
||||
CASE WHEN NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0)
|
||||
AND snr > '-Infinity'::real AND snr < 'Infinity'::real
|
||||
THEN snr::double precision END AS snr,
|
||||
CASE WHEN NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0)
|
||||
THEN rssi::double precision END AS rssi
|
||||
FROM expired_observations
|
||||
WHERE heard_at >= date_trunc('hour', NOW(), 'UTC') - INTERVAL '720 hours'
|
||||
|
||||
), binned AS (
|
||||
SELECT *, width_bucket(snr, -30, 30, 12) AS snr_bin,
|
||||
width_bucket(rssi, -140, 0, 14) AS rssi_bin
|
||||
FROM samples
|
||||
)
|
||||
SELECT iata, hour, grouping(snr_bin, rssi_bin)::integer AS kind,
|
||||
COALESCE(snr_bin, -1)::integer AS snr_bin,
|
||||
COALESCE(rssi_bin, -1)::integer AS rssi_bin,
|
||||
count(*)::bigint AS receptions,
|
||||
count(snr)::bigint AS snr_samples,
|
||||
COALESCE(sum(snr), 0)::double precision AS snr_sum,
|
||||
count(rssi)::bigint AS rssi_samples,
|
||||
COALESCE(sum(rssi), 0)::double precision AS rssi_sum
|
||||
FROM binned
|
||||
GROUP BY GROUPING SETS ((iata, hour), (iata, hour, snr_bin), (iata, hour, rssi_bin))
|
||||
) batch
|
||||
ON CONFLICT (iata,hour,kind,snr_bin,rssi_bin) DO UPDATE SET
|
||||
receptions = analytics_signal_stats_hourly.receptions + EXCLUDED.receptions,
|
||||
snr_samples = analytics_signal_stats_hourly.snr_samples + EXCLUDED.snr_samples,
|
||||
snr_sum = analytics_signal_stats_hourly.snr_sum + EXCLUDED.snr_sum,
|
||||
rssi_samples = analytics_signal_stats_hourly.rssi_samples + EXCLUDED.rssi_samples,
|
||||
rssi_sum = analytics_signal_stats_hourly.rssi_sum + EXCLUDED.rssi_sum
|
||||
),
|
||||
archived_path_stats_hourly AS (
|
||||
INSERT INTO analytics_path_stats_hourly (iata, hour, category, hash_bytes, entries, receptions)
|
||||
SELECT iata, hour, category, hash_bytes, entries, receptions FROM (
|
||||
WITH classified AS (
|
||||
SELECT iata, heard_at, hash_size, hop_count,
|
||||
CASE WHEN payload_type = 9 THEN 2
|
||||
-- Match meshcore-go IsValidPathLen (1/2/3-byte hashes, max 64 path bytes).
|
||||
WHEN payload_type IS NULL OR payload_type NOT BETWEEN 0 AND 15
|
||||
OR NOT (path_length_byte BETWEEN 0 AND 191
|
||||
AND hash_size BETWEEN 1 AND 3 AND hop_count BETWEEN 0 AND 63
|
||||
AND hash_size = (path_length_byte >> 6) + 1
|
||||
AND hop_count = (path_length_byte & 63)
|
||||
AND hash_size::integer * hop_count::integer <= 64
|
||||
AND COALESCE(octet_length(path_bytes), 0) = hash_size::integer * hop_count::integer)
|
||||
THEN 3
|
||||
WHEN hop_count = 0 THEN 1
|
||||
ELSE 0 END::integer AS category
|
||||
FROM expired_observations
|
||||
WHERE heard_at >= date_trunc('hour', NOW(), 'UTC') - INTERVAL '720 hours'
|
||||
|
||||
), buckets AS (
|
||||
SELECT iata, date_trunc('hour', heard_at, 'UTC') AS hour, category,
|
||||
CASE WHEN category = 0 THEN hash_size ELSE 0 END::integer AS hash_bytes,
|
||||
CASE WHEN category = 0 THEN hop_count ELSE 0 END::integer AS entries
|
||||
FROM classified
|
||||
)
|
||||
SELECT iata, hour, category, hash_bytes, entries, count(*)::bigint AS receptions
|
||||
FROM buckets GROUP BY iata, hour, category, hash_bytes, entries
|
||||
) batch
|
||||
ON CONFLICT (iata,hour,category,hash_bytes,entries) DO UPDATE SET
|
||||
receptions = analytics_path_stats_hourly.receptions + EXCLUDED.receptions
|
||||
)
|
||||
DELETE FROM packets WHERE packet_hash = ANY(hashes);
|
||||
GET DIAGNOSTICS deleted = ROW_COUNT;
|
||||
|
||||
-- Once per completed cleanup, including when there were no raw packets to delete.
|
||||
IF deleted < batch_size THEN
|
||||
DELETE FROM analytics_hourly_iata_stats WHERE hour < date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days';
|
||||
DELETE FROM analytics_payload_breakdown_by_iata WHERE bucket < date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days';
|
||||
DELETE FROM analytics_top_observers_by_iata WHERE bucket < date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days';
|
||||
DELETE FROM analytics_top_talkers_by_iata WHERE bucket < date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days';
|
||||
DELETE FROM analytics_top_advertisers_by_iata WHERE bucket < date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days';
|
||||
DELETE FROM analytics_observer_activity_hourly WHERE bucket < date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days';
|
||||
DELETE FROM analytics_signal_stats_hourly WHERE hour < date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days';
|
||||
DELETE FROM analytics_path_stats_hourly WHERE hour < date_trunc('hour', NOW(), 'UTC') - INTERVAL '30 days';
|
||||
END IF;
|
||||
RETURN deleted;
|
||||
END $$;
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package db
|
||||
|
||||
import (
|
||||
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestObserverMetricsPostgres(t *testing.T) {
|
||||
ctx, tx := retentionTx(t)
|
||||
analyticsTables(t, ctx, tx)
|
||||
// Use the real observer DDL without depending on a preinstalled public schema.
|
||||
initial, err := migrationFiles.ReadFile("migrations/001_initial_schema.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ddl := string(initial)
|
||||
start := strings.Index(ddl, "CREATE TABLE observers (")
|
||||
end := strings.Index(ddl[start:], "\n);") + start + 3
|
||||
if _, err := tx.Exec(ctx, "DROP TABLE observers; "+ddl[start:end]+" ALTER TABLE observers ADD COLUMN region_scope TEXT;"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
CREATE TABLE observer_brokers (observer_id uuid,broker_name text,last_seen timestamptz,last_packet_at timestamptz,PRIMARY KEY(observer_id,broker_name));
|
||||
INSERT INTO observers (id,public_key) VALUES ('00000000-0000-0000-0000-000000000001','\x01');
|
||||
INSERT INTO packets(packet_hash,payload_type,last_heard_at,first_heard_at) SELECT int4send(i),4,NOW()-interval '5 days',NOW()-interval '5 days' FROM generate_series(1,3) i;
|
||||
INSERT INTO packet_observations(packet_hash,observer_id,iata,heard_at,path_length_byte,hash_size,hop_count,payload_type,snr,rssi)
|
||||
SELECT packet_hash,'00000000-0000-0000-0000-000000000001','YOW',date_trunc('hour',NOW())-interval '5 days',0,1,0,CASE WHEN packet_hash=int4send(1) THEN 4 END,0,-100 FROM packets;
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applyStatsMigration(t, ctx, tx, "039_analytics_retention.sql")
|
||||
store := &Store{q: sqlc.New(tx)}
|
||||
id := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
if err := store.DeleteOldPackets(ctx, time.Now().Add(-72*time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Migration repairs the two unknown counts that 039 did not archive.
|
||||
applyStatsMigration(t, ctx, tx, "040_observer_unknown_activity.sql")
|
||||
applyStatsMigration(t, ctx, tx, "040_observer_unknown_activity.sql")
|
||||
until := time.Now().UTC().Truncate(time.Hour)
|
||||
activity, err := store.GetObserverActivity(ctx, id, 7*24*time.Hour, time.Hour, until)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if activity.Summary.RecordedPackets != 3 || len(activity.PayloadTypes) != 2 || activity.Summary.LatestRecordedAt != nil || activity.Summary.LastCompleteHour != 0 {
|
||||
t.Fatalf("archive summary: %+v types=%+v", activity.Summary, activity.PayloadTypes)
|
||||
}
|
||||
if activity.WindowEnd != until.UnixMilli() || activity.WindowStart != until.Add(-7*24*time.Hour).UnixMilli() || activity.Source != "hourly" {
|
||||
t.Fatalf("window: %+v", activity)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO packets(packet_hash,last_heard_at) VALUES ('\xaa',NOW());
|
||||
INSERT INTO packet_observations(packet_hash,observer_id,iata,heard_at,path_length_byte,hash_size,hop_count,snr,rssi) VALUES ('\xaa','00000000-0000-0000-0000-000000000001','YOW',date_trunc('hour',NOW())-interval '30 minutes',0,1,0,0,-100);
|
||||
INSERT INTO packet_observations(packet_hash,observer_id,iata,heard_at) VALUES ('\xaa','00000000-0000-0000-0000-000000000001','YOW',NOW()) ON CONFLICT DO NOTHING;`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := store.GetObserverActivity(ctx, id, time.Hour, 15*time.Minute, until)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw.Summary.RecordedPackets != 1 || raw.Summary.LastCompleteHour != 1 || raw.Summary.LatestRecordedAt == nil || raw.PayloadTypes[0].PayloadType != -1 {
|
||||
t.Fatalf("raw/duplicate: %+v", raw)
|
||||
}
|
||||
if err := store.UpsertObserverBroker(ctx, id, "one", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var packet pgtype.Timestamptz
|
||||
tx.QueryRow(ctx, "SELECT last_packet_at FROM observer_brokers").Scan(&packet)
|
||||
if packet.Valid {
|
||||
t.Fatal("status created a packet timestamp")
|
||||
}
|
||||
if err := store.UpsertObserverBroker(ctx, id, "one", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.TouchObserverBrokers(ctx, []uuid.UUID{id}, []string{"one"}, []time.Time{until.Add(time.Hour)}, []time.Time{{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx.QueryRow(ctx, "SELECT last_packet_at FROM observer_brokers").Scan(&packet)
|
||||
if !packet.Valid {
|
||||
t.Fatal("status erased packet timestamp")
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ SELECT id,1 FROM observers WHERE display_name='expired';`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := coalescer.UpsertObserverBroker(ctx, oldID, "fixture"); err != nil {
|
||||
if err := coalescer.UpsertObserverBroker(ctx, oldID, "fixture", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A future cutoff simulates this cached observer having aged past retention.
|
||||
@@ -112,7 +112,7 @@ SELECT id,1 FROM observers WHERE display_name='expired';`)
|
||||
if err != nil || newID == oldID {
|
||||
t.Fatalf("returning observer reused a deleted ID: %v", err)
|
||||
}
|
||||
if err := coalescer.UpsertObserverBroker(ctx, newID, "fixture"); err != nil {
|
||||
if err := coalescer.UpsertObserverBroker(ctx, newID, "fixture", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var brokers int
|
||||
|
||||
+45
-17
@@ -230,12 +230,37 @@ func (s *Store) GetObserverTelemetryBucketed(ctx context.Context, observerID uui
|
||||
// GetObserverActivity returns bucketed heard-activity for an observer over the trailing window.
|
||||
// Buckets of an hour or coarser come from the hourly rollup; anything finer reads observations directly.
|
||||
// Range and Interval are left empty for the handler to fill.
|
||||
func (s *Store) GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
|
||||
func (s *Store) GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration, until time.Time) (*api.ObserverActivity, error) {
|
||||
obs, err := s.q.GetObserverByID(ctx, observerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
activity := &api.ObserverActivity{}
|
||||
now := time.Now().UTC()
|
||||
if until.IsZero() {
|
||||
until = now
|
||||
}
|
||||
until = until.UTC().Truncate(interval)
|
||||
since := until.Add(-window)
|
||||
if !since.Equal(since.Truncate(interval)) {
|
||||
since = since.Truncate(interval).Add(interval)
|
||||
}
|
||||
activity := &api.ObserverActivity{WindowStart: since.UnixMilli(), WindowEnd: until.UnixMilli(), GeneratedAt: now.UnixMilli(), Source: "raw", Summary: &api.ObserverActivitySummary{}}
|
||||
if interval >= time.Hour {
|
||||
activity.Source = "hourly"
|
||||
}
|
||||
live, err := s.q.GetObserverActivityLiveSummary(ctx, sqlc.GetObserverActivityLiveSummaryParams{
|
||||
ObserverID: observerID, GeneratedAt: pgtype.Timestamptz{Time: now, Valid: true},
|
||||
HourStart: pgtype.Timestamptz{Time: now.Truncate(time.Hour).Add(-time.Hour), Valid: true}, HourEnd: pgtype.Timestamptz{Time: now.Truncate(time.Hour), Valid: true}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
activity.Summary.LastCompleteHour = live.LastCompleteHour
|
||||
if live.LatestRecordedAt.Valid {
|
||||
v := live.LatestRecordedAt.Time.UnixMilli()
|
||||
activity.Summary.LatestRecordedAt = &v
|
||||
}
|
||||
activity.Summary.LastCompleteHourStart = now.Truncate(time.Hour).Add(-time.Hour).UnixMilli()
|
||||
activity.Summary.LastCompleteHourEnd = now.Truncate(time.Hour).UnixMilli()
|
||||
// radio is non-nil only when airtime is actually costable, so radio != null implies costed buckets
|
||||
if obs.RadioSf != nil && obs.RadioBwKhz != nil && obs.RadioCr != nil &&
|
||||
*obs.RadioSf >= 7 && *obs.RadioSf <= 12 && *obs.RadioBwKhz > 0 && *obs.RadioCr > 0 {
|
||||
@@ -247,12 +272,6 @@ func (s *Store) GetObserverActivity(ctx context.Context, observerID uuid.UUID, w
|
||||
PreambleSymbols: lora.PreambleSymbols(int(*obs.RadioSf)),
|
||||
}
|
||||
}
|
||||
// round the window start up to a bucket boundary so the first bucket is never a partial one
|
||||
start := time.Now().Add(-window).UTC()
|
||||
since := start.Truncate(interval)
|
||||
if since.Before(start) {
|
||||
since = since.Add(interval)
|
||||
}
|
||||
sinceTS := pgtype.Timestamptz{Time: since, Valid: true}
|
||||
binWidth := pgtype.Interval{Microseconds: interval.Microseconds(), Valid: true}
|
||||
|
||||
@@ -261,6 +280,7 @@ func (s *Store) GetObserverActivity(ctx context.Context, observerID uuid.UUID, w
|
||||
ObserverID: observerID,
|
||||
Column2: sinceTS,
|
||||
Column3: binWidth,
|
||||
Until: pgtype.Timestamptz{Time: until, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -268,6 +288,7 @@ func (s *Store) GetObserverActivity(ctx context.Context, observerID uuid.UUID, w
|
||||
activity.Points = make([]api.ObserverActivityPoint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
p := api.ObserverActivityPoint{T: r.Bucket.Time.UnixMilli(), Observations: r.Observations}
|
||||
activity.Summary.RecordedPackets += r.Observations
|
||||
if r.AirtimeN > 0 {
|
||||
airtime := r.AirtimeMs
|
||||
p.AirtimeMs = &airtime
|
||||
@@ -286,18 +307,20 @@ func (s *Store) GetObserverActivity(ctx context.Context, observerID uuid.UUID, w
|
||||
typeRows, err := s.q.GetObserverActivityHourlyPayloadTypes(ctx, sqlc.GetObserverActivityHourlyPayloadTypesParams{
|
||||
ObserverID: observerID,
|
||||
Column2: sinceTS,
|
||||
Until: pgtype.Timestamptz{Time: until, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
activity.PayloadTypes = make([]api.PayloadBreakdownItem, 0, len(typeRows))
|
||||
for _, v := range typeRows {
|
||||
if v.PayloadType == nil {
|
||||
continue
|
||||
payloadType := int16(-1)
|
||||
if v.PayloadType != nil {
|
||||
payloadType = *v.PayloadType
|
||||
}
|
||||
activity.PayloadTypes = append(activity.PayloadTypes, api.PayloadBreakdownItem{
|
||||
PayloadType: *v.PayloadType,
|
||||
PayloadTypeName: api.PayloadTypeName(*v.PayloadType),
|
||||
PayloadType: payloadType,
|
||||
PayloadTypeName: api.PayloadTypeName(payloadType),
|
||||
Count: v.Count,
|
||||
})
|
||||
}
|
||||
@@ -308,6 +331,7 @@ func (s *Store) GetObserverActivity(ctx context.Context, observerID uuid.UUID, w
|
||||
ObserverID: observerID,
|
||||
Column2: sinceTS,
|
||||
Column3: binWidth,
|
||||
Until: pgtype.Timestamptz{Time: until, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -315,6 +339,7 @@ func (s *Store) GetObserverActivity(ctx context.Context, observerID uuid.UUID, w
|
||||
activity.Points = make([]api.ObserverActivityPoint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
p := api.ObserverActivityPoint{T: r.Bucket.Time.UnixMilli(), Observations: r.Observations}
|
||||
activity.Summary.RecordedPackets += r.Observations
|
||||
if r.AirtimeN > 0 {
|
||||
airtime := r.AirtimeMs
|
||||
p.AirtimeMs = &airtime
|
||||
@@ -332,18 +357,20 @@ func (s *Store) GetObserverActivity(ctx context.Context, observerID uuid.UUID, w
|
||||
typeRows, err := s.q.GetObserverActivityRawPayloadTypes(ctx, sqlc.GetObserverActivityRawPayloadTypesParams{
|
||||
ObserverID: observerID,
|
||||
Column2: sinceTS,
|
||||
Until: pgtype.Timestamptz{Time: until, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
activity.PayloadTypes = make([]api.PayloadBreakdownItem, 0, len(typeRows))
|
||||
for _, v := range typeRows {
|
||||
if v.PayloadType == nil {
|
||||
continue
|
||||
payloadType := int16(-1)
|
||||
if v.PayloadType != nil {
|
||||
payloadType = *v.PayloadType
|
||||
}
|
||||
activity.PayloadTypes = append(activity.PayloadTypes, api.PayloadBreakdownItem{
|
||||
PayloadType: *v.PayloadType,
|
||||
PayloadTypeName: api.PayloadTypeName(*v.PayloadType),
|
||||
PayloadType: payloadType,
|
||||
PayloadTypeName: api.PayloadTypeName(payloadType),
|
||||
Count: v.Count,
|
||||
})
|
||||
}
|
||||
@@ -424,10 +451,11 @@ func (s *Store) GetObserverRadio(ctx context.Context, observerID uuid.UUID) (ing
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertObserverBroker(ctx context.Context, observerID uuid.UUID, brokerName string) error {
|
||||
func (s *Store) UpsertObserverBroker(ctx context.Context, observerID uuid.UUID, brokerName string, isPacket bool) error {
|
||||
params := sqlc.UpsertObserverBrokerParams{
|
||||
ObserverID: observerID,
|
||||
BrokerName: brokerName,
|
||||
IsPacket: isPacket,
|
||||
}
|
||||
return s.q.UpsertObserverBroker(ctx, params)
|
||||
}
|
||||
|
||||
+22
-13
@@ -569,6 +569,7 @@ func f32(v float32) *float32 { return &v }
|
||||
func TestGetObserverActivity_HourlyFoldNoSignal(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
mock.EXPECT().GetObserverActivityLiveSummary(gomock.Any(), gomock.Any()).Return(sqlc.GetObserverActivityLiveSummaryRow{}, nil).AnyTimes()
|
||||
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
mock.EXPECT().GetObserverByID(gomock.Any(), observerID).
|
||||
@@ -592,7 +593,7 @@ func TestGetObserverActivity_HourlyFoldNoSignal(t *testing.T) {
|
||||
}, nil)
|
||||
|
||||
store := &Store{q: mock}
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 24*time.Hour, time.Hour)
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 24*time.Hour, time.Hour, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -615,8 +616,8 @@ func TestGetObserverActivity_HourlyFoldNoSignal(t *testing.T) {
|
||||
if p.RSSIAvg != nil {
|
||||
t.Errorf("expected nil RSSIAvg, got %v", *p.RSSIAvg)
|
||||
}
|
||||
if len(got.PayloadTypes) != 1 || got.PayloadTypes[0].PayloadType != 4 || got.PayloadTypes[0].Count != 9 {
|
||||
t.Fatalf("expected one payload type 4 with count 9, got %+v", got.PayloadTypes)
|
||||
if len(got.PayloadTypes) != 2 || got.PayloadTypes[0].PayloadType != 4 || got.PayloadTypes[0].Count != 9 || got.PayloadTypes[1].PayloadType != -1 || got.PayloadTypes[1].Count != 3 {
|
||||
t.Fatalf("expected known and unknown payloads to reconcile, got %+v", got.PayloadTypes)
|
||||
}
|
||||
if got.PayloadTypes[0].PayloadTypeName != api.PayloadTypeName(4) {
|
||||
t.Errorf("expected payload type name %q, got %q", api.PayloadTypeName(4), got.PayloadTypes[0].PayloadTypeName)
|
||||
@@ -638,6 +639,7 @@ func TestGetObserverActivity_HourlyFoldNoSignal(t *testing.T) {
|
||||
func TestGetObserverActivity_HourlyFoldWeightedAverages(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
mock.EXPECT().GetObserverActivityLiveSummary(gomock.Any(), gomock.Any()).Return(sqlc.GetObserverActivityLiveSummaryRow{}, nil).AnyTimes()
|
||||
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
mock.EXPECT().GetObserverByID(gomock.Any(), observerID).
|
||||
@@ -658,7 +660,7 @@ func TestGetObserverActivity_HourlyFoldWeightedAverages(t *testing.T) {
|
||||
Return([]sqlc.GetObserverActivityHourlyPayloadTypesRow{}, nil)
|
||||
|
||||
store := &Store{q: mock}
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 7*24*time.Hour, 6*time.Hour)
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 7*24*time.Hour, 6*time.Hour, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -689,6 +691,7 @@ func TestGetObserverActivity_HourlyFoldWeightedAverages(t *testing.T) {
|
||||
func TestGetObserverActivity_RawPath(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
mock.EXPECT().GetObserverActivityLiveSummary(gomock.Any(), gomock.Any()).Return(sqlc.GetObserverActivityLiveSummaryRow{}, nil).AnyTimes()
|
||||
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
mock.EXPECT().GetObserverByID(gomock.Any(), observerID).
|
||||
@@ -709,7 +712,7 @@ func TestGetObserverActivity_RawPath(t *testing.T) {
|
||||
Return([]sqlc.GetObserverActivityRawPayloadTypesRow{{PayloadType: i16(1), Count: 5}}, nil)
|
||||
|
||||
store := &Store{q: mock}
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 6*time.Hour, 15*time.Minute)
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 6*time.Hour, 15*time.Minute, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -737,6 +740,7 @@ func TestGetObserverActivity_RawPath(t *testing.T) {
|
||||
func TestGetObserverActivity_RawFoldNoSignal(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
mock.EXPECT().GetObserverActivityLiveSummary(gomock.Any(), gomock.Any()).Return(sqlc.GetObserverActivityLiveSummaryRow{}, nil).AnyTimes()
|
||||
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
mock.EXPECT().GetObserverByID(gomock.Any(), observerID).
|
||||
@@ -757,7 +761,7 @@ func TestGetObserverActivity_RawFoldNoSignal(t *testing.T) {
|
||||
Return([]sqlc.GetObserverActivityRawPayloadTypesRow{}, nil)
|
||||
|
||||
store := &Store{q: mock}
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 6*time.Hour, 15*time.Minute)
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 6*time.Hour, 15*time.Minute, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -779,6 +783,7 @@ func TestGetObserverActivity_RawFoldNoSignal(t *testing.T) {
|
||||
func TestGetObserverActivity_RawPathEmpty(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
mock.EXPECT().GetObserverActivityLiveSummary(gomock.Any(), gomock.Any()).Return(sqlc.GetObserverActivityLiveSummaryRow{}, nil).AnyTimes()
|
||||
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
mock.EXPECT().GetObserverByID(gomock.Any(), observerID).
|
||||
@@ -789,7 +794,7 @@ func TestGetObserverActivity_RawPathEmpty(t *testing.T) {
|
||||
Return([]sqlc.GetObserverActivityRawPayloadTypesRow{}, nil)
|
||||
|
||||
store := &Store{q: mock}
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 6*time.Hour, 15*time.Minute)
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 6*time.Hour, 15*time.Minute, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -804,6 +809,7 @@ func TestGetObserverActivity_RawPathEmpty(t *testing.T) {
|
||||
func TestGetObserverActivity_HourlyPathAtOneHour(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
mock.EXPECT().GetObserverActivityLiveSummary(gomock.Any(), gomock.Any()).Return(sqlc.GetObserverActivityLiveSummaryRow{}, nil).AnyTimes()
|
||||
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
mock.EXPECT().GetObserverByID(gomock.Any(), observerID).
|
||||
@@ -814,7 +820,7 @@ func TestGetObserverActivity_HourlyPathAtOneHour(t *testing.T) {
|
||||
Return([]sqlc.GetObserverActivityHourlyPayloadTypesRow{}, nil)
|
||||
|
||||
store := &Store{q: mock}
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 24*time.Hour, time.Hour)
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 24*time.Hour, time.Hour, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -826,13 +832,14 @@ func TestGetObserverActivity_HourlyPathAtOneHour(t *testing.T) {
|
||||
func TestGetObserverActivity_UnknownObserver(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
mock.EXPECT().GetObserverActivityLiveSummary(gomock.Any(), gomock.Any()).Return(sqlc.GetObserverActivityLiveSummaryRow{}, nil).AnyTimes()
|
||||
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
||||
|
||||
mock.EXPECT().GetObserverByID(gomock.Any(), observerID).
|
||||
Return(sqlc.Observer{}, pgx.ErrNoRows)
|
||||
|
||||
store := &Store{q: mock}
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, time.Hour, time.Hour)
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, time.Hour, time.Hour, time.Time{})
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
t.Fatalf("expected pgx.ErrNoRows, got %v", err)
|
||||
}
|
||||
@@ -858,6 +865,7 @@ func TestGetObserverActivity_RadioNilWhenIncomplete(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
mock.EXPECT().GetObserverActivityLiveSummary(gomock.Any(), gomock.Any()).Return(sqlc.GetObserverActivityLiveSummaryRow{}, nil).AnyTimes()
|
||||
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
mock.EXPECT().GetObserverByID(gomock.Any(), observerID).
|
||||
@@ -868,7 +876,7 @@ func TestGetObserverActivity_RadioNilWhenIncomplete(t *testing.T) {
|
||||
Return([]sqlc.GetObserverActivityHourlyPayloadTypesRow{}, nil)
|
||||
|
||||
store := &Store{q: mock}
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 24*time.Hour, time.Hour)
|
||||
got, err := store.GetObserverActivity(context.Background(), observerID, 24*time.Hour, time.Hour, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -882,6 +890,7 @@ func TestGetObserverActivity_RadioNilWhenIncomplete(t *testing.T) {
|
||||
func TestGetObserverActivity_SinceAlignedToInterval(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
mock := mockdb.NewMockQuerier(ctrl)
|
||||
mock.EXPECT().GetObserverActivityLiveSummary(gomock.Any(), gomock.Any()).Return(sqlc.GetObserverActivityLiveSummaryRow{}, nil).AnyTimes()
|
||||
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
window, interval := 24*time.Hour, 6*time.Hour
|
||||
|
||||
@@ -897,12 +906,12 @@ func TestGetObserverActivity_SinceAlignedToInterval(t *testing.T) {
|
||||
mock.EXPECT().GetObserverActivityHourlyPayloadTypes(gomock.Any(), gomock.Any()).
|
||||
Return([]sqlc.GetObserverActivityHourlyPayloadTypesRow{}, nil)
|
||||
|
||||
before := time.Now().Add(-window)
|
||||
before := time.Now().UTC().Truncate(interval).Add(-window)
|
||||
store := &Store{q: mock}
|
||||
if _, err := store.GetObserverActivity(context.Background(), observerID, window, interval); err != nil {
|
||||
if _, err := store.GetObserverActivity(context.Background(), observerID, window, interval, time.Time{}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
after := time.Now().Add(-window)
|
||||
after := time.Now().UTC().Truncate(interval).Add(-window)
|
||||
|
||||
if gotSince.UnixNano()%int64(interval) != 0 {
|
||||
t.Errorf("since %s is not aligned to %s", gotSince, interval)
|
||||
|
||||
+10
-1
@@ -20,11 +20,12 @@ func (s *Store) TouchObservers(ctx context.Context, ids []uuid.UUID, seen []time
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) TouchObserverBrokers(ctx context.Context, ids []uuid.UUID, brokers []string, seen []time.Time) error {
|
||||
func (s *Store) TouchObserverBrokers(ctx context.Context, ids []uuid.UUID, brokers []string, seen, packets []time.Time) error {
|
||||
return s.q.TouchObserverBrokers(ctx, sqlc.TouchObserverBrokersParams{
|
||||
Column1: ids,
|
||||
Column2: brokers,
|
||||
Column3: toTimestamptzs(seen),
|
||||
Column4: nullableTimes(packets),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,3 +43,11 @@ func toTimestamptzs(ts []time.Time) []pgtype.Timestamptz {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nullableTimes(ts []time.Time) []pgtype.Timestamptz {
|
||||
out := make([]pgtype.Timestamptz, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = pgtype.Timestamptz{Time: t, Valid: !t.IsZero()}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- name: GetObserverActivityLiveSummary :one
|
||||
-- Two indexed ranges, bounded to one observer; no legacy presence counters.
|
||||
WITH latest AS (
|
||||
SELECT heard_at FROM packet_observations
|
||||
WHERE observer_id = @observer_id::uuid AND heard_at <= @generated_at::timestamptz
|
||||
ORDER BY heard_at DESC LIMIT 1
|
||||
), hourly AS (
|
||||
SELECT COUNT(*)::bigint AS n FROM packet_observations
|
||||
WHERE observer_id = @observer_id::uuid
|
||||
AND heard_at >= @hour_start::timestamptz AND heard_at < @hour_end::timestamptz
|
||||
)
|
||||
SELECT (SELECT heard_at FROM latest)::timestamptz AS latest_recorded_at,
|
||||
hourly.n AS last_complete_hour FROM hourly;
|
||||
+10
-9
@@ -275,14 +275,14 @@ SELECT
|
||||
COALESCE(AVG(rssi) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0)), 0)::real AS rssi_avg,
|
||||
COUNT(rssi) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0))::bigint AS rssi_n
|
||||
FROM packet_observations
|
||||
WHERE observer_id = $1 AND heard_at >= $2::timestamptz
|
||||
WHERE observer_id = $1 AND heard_at >= $2::timestamptz AND heard_at < @until::timestamptz
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket;
|
||||
|
||||
-- name: GetObserverActivityRawPayloadTypes :many
|
||||
SELECT payload_type, COUNT(*)::bigint AS count
|
||||
FROM packet_observations
|
||||
WHERE observer_id = $1 AND heard_at >= $2::timestamptz AND payload_type IS NOT NULL
|
||||
WHERE observer_id = $1 AND heard_at >= $2::timestamptz AND heard_at < @until::timestamptz
|
||||
GROUP BY payload_type
|
||||
ORDER BY count DESC;
|
||||
|
||||
@@ -299,14 +299,14 @@ SELECT
|
||||
COALESCE(SUM(rssi_sum), 0)::bigint AS rssi_sum,
|
||||
SUM(rssi_n)::bigint AS rssi_n
|
||||
FROM mv_observer_activity_hourly
|
||||
WHERE observer_id = $1 AND bucket >= $2::timestamptz
|
||||
WHERE observer_id = $1 AND bucket >= $2::timestamptz AND bucket < @until::timestamptz
|
||||
GROUP BY 1
|
||||
ORDER BY 1;
|
||||
|
||||
-- name: GetObserverActivityHourlyPayloadTypes :many
|
||||
SELECT payload_type, SUM(observations)::bigint AS count
|
||||
FROM mv_observer_activity_hourly
|
||||
WHERE observer_id = $1 AND bucket >= $2::timestamptz
|
||||
WHERE observer_id = $1 AND bucket >= $2::timestamptz AND bucket < @until::timestamptz
|
||||
GROUP BY payload_type
|
||||
ORDER BY count DESC;
|
||||
|
||||
@@ -363,19 +363,20 @@ RETURNING o.id;
|
||||
|
||||
-- name: UpsertObserverBroker :exec
|
||||
INSERT INTO observer_brokers (observer_id, broker_name, last_seen, last_packet_at)
|
||||
VALUES ($1, $2, NOW(), NOW())
|
||||
VALUES ($1, $2, NOW(), CASE WHEN @is_packet::boolean THEN NOW() END)
|
||||
ON CONFLICT (observer_id, broker_name) DO UPDATE SET
|
||||
last_seen = NOW(),
|
||||
last_packet_at = NOW();
|
||||
last_seen = NOW(),
|
||||
last_packet_at = COALESCE(EXCLUDED.last_packet_at, observer_brokers.last_packet_at);
|
||||
|
||||
-- name: TouchObserverBrokers :exec
|
||||
UPDATE observer_brokers ob SET
|
||||
last_seen = GREATEST(ob.last_seen, v.seen),
|
||||
last_packet_at = GREATEST(ob.last_packet_at, v.seen)
|
||||
last_packet_at = GREATEST(ob.last_packet_at, v.packet)
|
||||
FROM (
|
||||
SELECT unnest($1::uuid[]) AS observer_id,
|
||||
unnest($2::text[]) AS broker_name,
|
||||
unnest($3::timestamptz[]) AS seen
|
||||
unnest($3::timestamptz[]) AS seen,
|
||||
unnest($4::timestamptz[]) AS packet
|
||||
) v
|
||||
WHERE ob.observer_id = v.observer_id AND ob.broker_name = v.broker_name;
|
||||
|
||||
|
||||
+16
-1
@@ -3,7 +3,7 @@
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -source=db/sqlc/querier.go -destination=db/sqlc/mock/querier.go -package=mockdb
|
||||
// mockgen -source db/sqlc/querier.go -destination db/sqlc/mock/querier.go -package mockdb
|
||||
//
|
||||
|
||||
// Package mockdb is a generated GoMock package.
|
||||
@@ -384,6 +384,21 @@ func (mr *MockQuerierMockRecorder) GetObserverActivityHourlyPayloadTypes(ctx, ar
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetObserverActivityHourlyPayloadTypes", reflect.TypeOf((*MockQuerier)(nil).GetObserverActivityHourlyPayloadTypes), ctx, arg)
|
||||
}
|
||||
|
||||
// GetObserverActivityLiveSummary mocks base method.
|
||||
func (m *MockQuerier) GetObserverActivityLiveSummary(ctx context.Context, arg db.GetObserverActivityLiveSummaryParams) (db.GetObserverActivityLiveSummaryRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetObserverActivityLiveSummary", ctx, arg)
|
||||
ret0, _ := ret[0].(db.GetObserverActivityLiveSummaryRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetObserverActivityLiveSummary indicates an expected call of GetObserverActivityLiveSummary.
|
||||
func (mr *MockQuerierMockRecorder) GetObserverActivityLiveSummary(ctx, arg any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetObserverActivityLiveSummary", reflect.TypeOf((*MockQuerier)(nil).GetObserverActivityLiveSummary), ctx, arg)
|
||||
}
|
||||
|
||||
// GetObserverActivityRaw mocks base method.
|
||||
func (m *MockQuerier) GetObserverActivityRaw(ctx context.Context, arg db.GetObserverActivityRawParams) ([]db.GetObserverActivityRawRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: observer_activity_summary.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getObserverActivityLiveSummary = `-- name: GetObserverActivityLiveSummary :one
|
||||
WITH latest AS (
|
||||
SELECT heard_at FROM packet_observations
|
||||
WHERE observer_id = $1::uuid AND heard_at <= $2::timestamptz
|
||||
ORDER BY heard_at DESC LIMIT 1
|
||||
), hourly AS (
|
||||
SELECT COUNT(*)::bigint AS n FROM packet_observations
|
||||
WHERE observer_id = $1::uuid
|
||||
AND heard_at >= $3::timestamptz AND heard_at < $4::timestamptz
|
||||
)
|
||||
SELECT (SELECT heard_at FROM latest)::timestamptz AS latest_recorded_at,
|
||||
hourly.n AS last_complete_hour FROM hourly
|
||||
`
|
||||
|
||||
type GetObserverActivityLiveSummaryParams struct {
|
||||
ObserverID uuid.UUID `json:"observer_id"`
|
||||
GeneratedAt pgtype.Timestamptz `json:"generated_at"`
|
||||
HourStart pgtype.Timestamptz `json:"hour_start"`
|
||||
HourEnd pgtype.Timestamptz `json:"hour_end"`
|
||||
}
|
||||
|
||||
type GetObserverActivityLiveSummaryRow struct {
|
||||
LatestRecordedAt pgtype.Timestamptz `json:"latest_recorded_at"`
|
||||
LastCompleteHour int64 `json:"last_complete_hour"`
|
||||
}
|
||||
|
||||
// Two indexed ranges, bounded to one observer; no legacy presence counters.
|
||||
func (q *Queries) GetObserverActivityLiveSummary(ctx context.Context, arg GetObserverActivityLiveSummaryParams) (GetObserverActivityLiveSummaryRow, error) {
|
||||
row := q.db.QueryRow(ctx, getObserverActivityLiveSummary,
|
||||
arg.ObserverID,
|
||||
arg.GeneratedAt,
|
||||
arg.HourStart,
|
||||
arg.HourEnd,
|
||||
)
|
||||
var i GetObserverActivityLiveSummaryRow
|
||||
err := row.Scan(&i.LatestRecordedAt, &i.LastCompleteHour)
|
||||
return i, err
|
||||
}
|
||||
@@ -58,6 +58,8 @@ type Querier interface {
|
||||
// Hour-or-coarser buckets summed from the hourly rollup; same COALESCE-plus-count shape as the raw query.
|
||||
GetObserverActivityHourly(ctx context.Context, arg GetObserverActivityHourlyParams) ([]GetObserverActivityHourlyRow, error)
|
||||
GetObserverActivityHourlyPayloadTypes(ctx context.Context, arg GetObserverActivityHourlyPayloadTypesParams) ([]GetObserverActivityHourlyPayloadTypesRow, error)
|
||||
// Two indexed ranges, bounded to one observer; no legacy presence counters.
|
||||
GetObserverActivityLiveSummary(ctx context.Context, arg GetObserverActivityLiveSummaryParams) (GetObserverActivityLiveSummaryRow, error)
|
||||
// Sub-hour activity buckets straight off idx_observations_observer; no join to packets.
|
||||
// Aggregates are COALESCEd and paired with a count column: sqlc types a cast expression as
|
||||
// NOT NULL, so the counts are what tell the store a bucket had no costed or no signal rows.
|
||||
|
||||
+37
-15
@@ -650,7 +650,7 @@ SELECT
|
||||
COALESCE(SUM(rssi_sum), 0)::bigint AS rssi_sum,
|
||||
SUM(rssi_n)::bigint AS rssi_n
|
||||
FROM mv_observer_activity_hourly
|
||||
WHERE observer_id = $1 AND bucket >= $2::timestamptz
|
||||
WHERE observer_id = $1 AND bucket >= $2::timestamptz AND bucket < $4::timestamptz
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`
|
||||
@@ -659,6 +659,7 @@ type GetObserverActivityHourlyParams struct {
|
||||
ObserverID uuid.UUID `json:"observer_id"`
|
||||
Column2 pgtype.Timestamptz `json:"column_2"`
|
||||
Column3 pgtype.Interval `json:"column_3"`
|
||||
Until pgtype.Timestamptz `json:"until"`
|
||||
}
|
||||
|
||||
type GetObserverActivityHourlyRow struct {
|
||||
@@ -675,7 +676,12 @@ type GetObserverActivityHourlyRow struct {
|
||||
|
||||
// Hour-or-coarser buckets summed from the hourly rollup; same COALESCE-plus-count shape as the raw query.
|
||||
func (q *Queries) GetObserverActivityHourly(ctx context.Context, arg GetObserverActivityHourlyParams) ([]GetObserverActivityHourlyRow, error) {
|
||||
rows, err := q.db.Query(ctx, getObserverActivityHourly, arg.ObserverID, arg.Column2, arg.Column3)
|
||||
rows, err := q.db.Query(ctx, getObserverActivityHourly,
|
||||
arg.ObserverID,
|
||||
arg.Column2,
|
||||
arg.Column3,
|
||||
arg.Until,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -707,7 +713,7 @@ func (q *Queries) GetObserverActivityHourly(ctx context.Context, arg GetObserver
|
||||
const getObserverActivityHourlyPayloadTypes = `-- name: GetObserverActivityHourlyPayloadTypes :many
|
||||
SELECT payload_type, SUM(observations)::bigint AS count
|
||||
FROM mv_observer_activity_hourly
|
||||
WHERE observer_id = $1 AND bucket >= $2::timestamptz
|
||||
WHERE observer_id = $1 AND bucket >= $2::timestamptz AND bucket < $3::timestamptz
|
||||
GROUP BY payload_type
|
||||
ORDER BY count DESC
|
||||
`
|
||||
@@ -715,6 +721,7 @@ ORDER BY count DESC
|
||||
type GetObserverActivityHourlyPayloadTypesParams struct {
|
||||
ObserverID uuid.UUID `json:"observer_id"`
|
||||
Column2 pgtype.Timestamptz `json:"column_2"`
|
||||
Until pgtype.Timestamptz `json:"until"`
|
||||
}
|
||||
|
||||
type GetObserverActivityHourlyPayloadTypesRow struct {
|
||||
@@ -723,7 +730,7 @@ type GetObserverActivityHourlyPayloadTypesRow struct {
|
||||
}
|
||||
|
||||
func (q *Queries) GetObserverActivityHourlyPayloadTypes(ctx context.Context, arg GetObserverActivityHourlyPayloadTypesParams) ([]GetObserverActivityHourlyPayloadTypesRow, error) {
|
||||
rows, err := q.db.Query(ctx, getObserverActivityHourlyPayloadTypes, arg.ObserverID, arg.Column2)
|
||||
rows, err := q.db.Query(ctx, getObserverActivityHourlyPayloadTypes, arg.ObserverID, arg.Column2, arg.Until)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -754,7 +761,7 @@ SELECT
|
||||
COALESCE(AVG(rssi) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0)), 0)::real AS rssi_avg,
|
||||
COUNT(rssi) FILTER (WHERE NOT (COALESCE(rssi, 0) = 0 AND COALESCE(snr, 0) = 0))::bigint AS rssi_n
|
||||
FROM packet_observations
|
||||
WHERE observer_id = $1 AND heard_at >= $2::timestamptz
|
||||
WHERE observer_id = $1 AND heard_at >= $2::timestamptz AND heard_at < $4::timestamptz
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket
|
||||
`
|
||||
@@ -763,6 +770,7 @@ type GetObserverActivityRawParams struct {
|
||||
ObserverID uuid.UUID `json:"observer_id"`
|
||||
Column2 pgtype.Timestamptz `json:"column_2"`
|
||||
Column3 pgtype.Interval `json:"column_3"`
|
||||
Until pgtype.Timestamptz `json:"until"`
|
||||
}
|
||||
|
||||
type GetObserverActivityRawRow struct {
|
||||
@@ -781,7 +789,12 @@ type GetObserverActivityRawRow struct {
|
||||
// Aggregates are COALESCEd and paired with a count column: sqlc types a cast expression as
|
||||
// NOT NULL, so the counts are what tell the store a bucket had no costed or no signal rows.
|
||||
func (q *Queries) GetObserverActivityRaw(ctx context.Context, arg GetObserverActivityRawParams) ([]GetObserverActivityRawRow, error) {
|
||||
rows, err := q.db.Query(ctx, getObserverActivityRaw, arg.ObserverID, arg.Column2, arg.Column3)
|
||||
rows, err := q.db.Query(ctx, getObserverActivityRaw,
|
||||
arg.ObserverID,
|
||||
arg.Column2,
|
||||
arg.Column3,
|
||||
arg.Until,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -813,7 +826,7 @@ func (q *Queries) GetObserverActivityRaw(ctx context.Context, arg GetObserverAct
|
||||
const getObserverActivityRawPayloadTypes = `-- name: GetObserverActivityRawPayloadTypes :many
|
||||
SELECT payload_type, COUNT(*)::bigint AS count
|
||||
FROM packet_observations
|
||||
WHERE observer_id = $1 AND heard_at >= $2::timestamptz AND payload_type IS NOT NULL
|
||||
WHERE observer_id = $1 AND heard_at >= $2::timestamptz AND heard_at < $3::timestamptz
|
||||
GROUP BY payload_type
|
||||
ORDER BY count DESC
|
||||
`
|
||||
@@ -821,6 +834,7 @@ ORDER BY count DESC
|
||||
type GetObserverActivityRawPayloadTypesParams struct {
|
||||
ObserverID uuid.UUID `json:"observer_id"`
|
||||
Column2 pgtype.Timestamptz `json:"column_2"`
|
||||
Until pgtype.Timestamptz `json:"until"`
|
||||
}
|
||||
|
||||
type GetObserverActivityRawPayloadTypesRow struct {
|
||||
@@ -829,7 +843,7 @@ type GetObserverActivityRawPayloadTypesRow struct {
|
||||
}
|
||||
|
||||
func (q *Queries) GetObserverActivityRawPayloadTypes(ctx context.Context, arg GetObserverActivityRawPayloadTypesParams) ([]GetObserverActivityRawPayloadTypesRow, error) {
|
||||
rows, err := q.db.Query(ctx, getObserverActivityRawPayloadTypes, arg.ObserverID, arg.Column2)
|
||||
rows, err := q.db.Query(ctx, getObserverActivityRawPayloadTypes, arg.ObserverID, arg.Column2, arg.Until)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -4422,11 +4436,12 @@ func (q *Queries) SetPacketDecrypted(ctx context.Context, packetHash []byte) err
|
||||
const touchObserverBrokers = `-- name: TouchObserverBrokers :exec
|
||||
UPDATE observer_brokers ob SET
|
||||
last_seen = GREATEST(ob.last_seen, v.seen),
|
||||
last_packet_at = GREATEST(ob.last_packet_at, v.seen)
|
||||
last_packet_at = GREATEST(ob.last_packet_at, v.packet)
|
||||
FROM (
|
||||
SELECT unnest($1::uuid[]) AS observer_id,
|
||||
unnest($2::text[]) AS broker_name,
|
||||
unnest($3::timestamptz[]) AS seen
|
||||
unnest($3::timestamptz[]) AS seen,
|
||||
unnest($4::timestamptz[]) AS packet
|
||||
) v
|
||||
WHERE ob.observer_id = v.observer_id AND ob.broker_name = v.broker_name
|
||||
`
|
||||
@@ -4435,10 +4450,16 @@ type TouchObserverBrokersParams struct {
|
||||
Column1 []uuid.UUID `json:"column_1"`
|
||||
Column2 []string `json:"column_2"`
|
||||
Column3 []pgtype.Timestamptz `json:"column_3"`
|
||||
Column4 []pgtype.Timestamptz `json:"column_4"`
|
||||
}
|
||||
|
||||
func (q *Queries) TouchObserverBrokers(ctx context.Context, arg TouchObserverBrokersParams) error {
|
||||
_, err := q.db.Exec(ctx, touchObserverBrokers, arg.Column1, arg.Column2, arg.Column3)
|
||||
_, err := q.db.Exec(ctx, touchObserverBrokers,
|
||||
arg.Column1,
|
||||
arg.Column2,
|
||||
arg.Column3,
|
||||
arg.Column4,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4943,22 +4964,23 @@ func (q *Queries) UpsertObserver(ctx context.Context, publicKey []byte) (Observe
|
||||
const upsertObserverBroker = `-- name: UpsertObserverBroker :exec
|
||||
|
||||
INSERT INTO observer_brokers (observer_id, broker_name, last_seen, last_packet_at)
|
||||
VALUES ($1, $2, NOW(), NOW())
|
||||
VALUES ($1, $2, NOW(), CASE WHEN $3::boolean THEN NOW() END)
|
||||
ON CONFLICT (observer_id, broker_name) DO UPDATE SET
|
||||
last_seen = NOW(),
|
||||
last_packet_at = NOW()
|
||||
last_seen = NOW(),
|
||||
last_packet_at = COALESCE(EXCLUDED.last_packet_at, observer_brokers.last_packet_at)
|
||||
`
|
||||
|
||||
type UpsertObserverBrokerParams struct {
|
||||
ObserverID uuid.UUID `json:"observer_id"`
|
||||
BrokerName string `json:"broker_name"`
|
||||
IsPacket bool `json:"is_packet"`
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OBSERVER BROKERS
|
||||
// ============================================================
|
||||
func (q *Queries) UpsertObserverBroker(ctx context.Context, arg UpsertObserverBrokerParams) error {
|
||||
_, err := q.db.Exec(ctx, upsertObserverBroker, arg.ObserverID, arg.BrokerName)
|
||||
_, err := q.db.Exec(ctx, upsertObserverBroker, arg.ObserverID, arg.BrokerName, arg.IsPacket)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -1468,6 +1468,12 @@ const docTemplate = `{
|
||||
"description": "Bucket size: 5m, 15m, 1h, 6h or 24h (default 15m)",
|
||||
"name": "interval",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Optional exclusive end in epoch milliseconds; aligned down to a complete bucket, at most 30 days old",
|
||||
"name": "until",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -3924,6 +3930,7 @@ const docTemplate = `{
|
||||
"type": "integer"
|
||||
},
|
||||
"observationCount": {
|
||||
"description": "legacy cumulative presence counter; includes non-packet events",
|
||||
"type": "integer"
|
||||
},
|
||||
"observerType": {
|
||||
@@ -3979,6 +3986,10 @@ const docTemplate = `{
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverActivity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"generatedAt": {
|
||||
"description": "response computation time, not proof of continuous coverage",
|
||||
"type": "integer"
|
||||
},
|
||||
"interval": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3999,6 +4010,21 @@ const docTemplate = `{
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"description": "raw or hourly; missing records do not prove an outage",
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverActivitySummary"
|
||||
},
|
||||
"windowEnd": {
|
||||
"description": "exclusive complete-bucket end, epoch ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"windowStart": {
|
||||
"description": "inclusive complete-bucket start, epoch ms",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4046,6 +4072,26 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverActivitySummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lastCompleteHour": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lastCompleteHourEnd": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lastCompleteHourStart": {
|
||||
"type": "integer"
|
||||
},
|
||||
"latestRecordedAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"recordedPackets": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverBroker": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1466,6 +1466,12 @@
|
||||
"description": "Bucket size: 5m, 15m, 1h, 6h or 24h (default 15m)",
|
||||
"name": "interval",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Optional exclusive end in epoch milliseconds; aligned down to a complete bucket, at most 30 days old",
|
||||
"name": "until",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -3922,6 +3928,7 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"observationCount": {
|
||||
"description": "legacy cumulative presence counter; includes non-packet events",
|
||||
"type": "integer"
|
||||
},
|
||||
"observerType": {
|
||||
@@ -3977,6 +3984,10 @@
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverActivity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"generatedAt": {
|
||||
"description": "response computation time, not proof of continuous coverage",
|
||||
"type": "integer"
|
||||
},
|
||||
"interval": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3997,6 +4008,21 @@
|
||||
},
|
||||
"range": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"description": "raw or hourly; missing records do not prove an outage",
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverActivitySummary"
|
||||
},
|
||||
"windowEnd": {
|
||||
"description": "exclusive complete-bucket end, epoch ms",
|
||||
"type": "integer"
|
||||
},
|
||||
"windowStart": {
|
||||
"description": "inclusive complete-bucket start, epoch ms",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4044,6 +4070,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverActivitySummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lastCompleteHour": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lastCompleteHourEnd": {
|
||||
"type": "integer"
|
||||
},
|
||||
"lastCompleteHourStart": {
|
||||
"type": "integer"
|
||||
},
|
||||
"latestRecordedAt": {
|
||||
"type": "integer"
|
||||
},
|
||||
"recordedPackets": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverBroker": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -521,6 +521,7 @@ definitions:
|
||||
description: epoch ms
|
||||
type: integer
|
||||
observationCount:
|
||||
description: legacy cumulative presence counter; includes non-packet events
|
||||
type: integer
|
||||
observerType:
|
||||
description: e.g. "meshcoretomqtt", "meshcoreha"
|
||||
@@ -560,6 +561,9 @@ definitions:
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverActivity:
|
||||
properties:
|
||||
generatedAt:
|
||||
description: response computation time, not proof of continuous coverage
|
||||
type: integer
|
||||
interval:
|
||||
type: string
|
||||
payloadTypes:
|
||||
@@ -574,6 +578,17 @@ definitions:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverActivityRadio'
|
||||
range:
|
||||
type: string
|
||||
source:
|
||||
description: raw or hourly; missing records do not prove an outage
|
||||
type: string
|
||||
summary:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverActivitySummary'
|
||||
windowEnd:
|
||||
description: exclusive complete-bucket end, epoch ms
|
||||
type: integer
|
||||
windowStart:
|
||||
description: inclusive complete-bucket start, epoch ms
|
||||
type: integer
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverActivityPoint:
|
||||
properties:
|
||||
@@ -604,6 +619,19 @@ definitions:
|
||||
sf:
|
||||
type: integer
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverActivitySummary:
|
||||
properties:
|
||||
lastCompleteHour:
|
||||
type: integer
|
||||
lastCompleteHourEnd:
|
||||
type: integer
|
||||
lastCompleteHourStart:
|
||||
type: integer
|
||||
latestRecordedAt:
|
||||
type: integer
|
||||
recordedPackets:
|
||||
type: integer
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverBroker:
|
||||
properties:
|
||||
lastPacketAt:
|
||||
@@ -2401,6 +2429,11 @@ paths:
|
||||
in: query
|
||||
name: interval
|
||||
type: string
|
||||
- description: Optional exclusive end in epoch milliseconds; aligned down to
|
||||
a complete bucket, at most 30 days old
|
||||
in: query
|
||||
name: until
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
|
||||
@@ -256,6 +256,7 @@ var activityIntervals = map[string]time.Duration{
|
||||
// @Param observerId path string true "Observer UUID"
|
||||
// @Param range query string false "Trailing window as a Go duration, max 720h (default 24h); max 48h when interval is under 1h"
|
||||
// @Param interval query string false "Bucket size: 5m, 15m, 1h, 6h or 24h (default 15m)"
|
||||
// @Param until query int false "Optional exclusive end in epoch milliseconds; aligned down to a complete bucket, at most 30 days old"
|
||||
// @Success 200 {object} api.ObserverActivity
|
||||
// @Failure 400 {object} handlers.APIError
|
||||
// @Failure 404 {object} handlers.APIError
|
||||
@@ -295,7 +296,17 @@ func getObserverActivity(reader api.Reader) http.HandlerFunc {
|
||||
respondError(w, http.StatusBadRequest, "range/interval exceeds 1000 buckets")
|
||||
return
|
||||
}
|
||||
activity, err := reader.GetObserverActivity(r.Context(), observerID, window, interval)
|
||||
until := time.Time{}
|
||||
if values, ok := r.URL.Query()["until"]; ok {
|
||||
n, err := strconv.ParseInt(values[0], 10, 64)
|
||||
now := time.Now()
|
||||
if len(values) != 1 || err != nil || n < 0 || n > now.UnixMilli() || n < now.Add(-720*time.Hour).UnixMilli() {
|
||||
respondError(w, http.StatusBadRequest, "until must be one past epoch-millisecond timestamp within 30 days")
|
||||
return
|
||||
}
|
||||
until = time.UnixMilli(n)
|
||||
}
|
||||
activity, err := reader.GetObserverActivity(r.Context(), observerID, window, interval, until)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "observer not found")
|
||||
|
||||
@@ -200,7 +200,7 @@ func TestGetObserverActivity_Defaults(t *testing.T) {
|
||||
var gotWindow, gotInterval time.Duration
|
||||
r := chi.NewRouter()
|
||||
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration, _ time.Time) (*api.ObserverActivity, error) {
|
||||
gotWindow, gotInterval = window, interval
|
||||
return &api.ObserverActivity{Points: []api.ObserverActivityPoint{}}, nil
|
||||
},
|
||||
@@ -232,7 +232,7 @@ func TestGetObserverActivity_CustomRangeAndInterval(t *testing.T) {
|
||||
var gotWindow, gotInterval time.Duration
|
||||
r := chi.NewRouter()
|
||||
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration, _ time.Time) (*api.ObserverActivity, error) {
|
||||
gotWindow, gotInterval = window, interval
|
||||
return &api.ObserverActivity{Points: []api.ObserverActivityPoint{}}, nil
|
||||
},
|
||||
@@ -264,7 +264,7 @@ func TestGetObserverActivity_SubHourRangeLimit(t *testing.T) {
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration, _ time.Time) (*api.ObserverActivity, error) {
|
||||
t.Fatal("reader should not be called")
|
||||
return nil, nil
|
||||
},
|
||||
@@ -286,7 +286,7 @@ func TestGetObserverActivity_SubHourRangeLimit(t *testing.T) {
|
||||
var gotWindow, gotInterval time.Duration
|
||||
ok := chi.NewRouter()
|
||||
ok.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, window, interval time.Duration, _ time.Time) (*api.ObserverActivity, error) {
|
||||
gotWindow, gotInterval = window, interval
|
||||
return &api.ObserverActivity{Points: []api.ObserverActivityPoint{}}, nil
|
||||
},
|
||||
@@ -309,6 +309,10 @@ func TestGetObserverActivity_BadRequests(t *testing.T) {
|
||||
query string
|
||||
}{
|
||||
{"invalid uuid", "not-a-uuid", ""},
|
||||
{"bad end", "00000000-0000-0000-0000-000000000001", "?until=banana"},
|
||||
{"negative end", "00000000-0000-0000-0000-000000000001", "?until=-1"},
|
||||
{"duplicate end", "00000000-0000-0000-0000-000000000001", "?until=1&until=2"},
|
||||
{"future end", "00000000-0000-0000-0000-000000000001", "?until=253402300799999"},
|
||||
{"unparseable range", "00000000-0000-0000-0000-000000000001", "?range=banana"},
|
||||
{"range too long", "00000000-0000-0000-0000-000000000001", "?range=721h"},
|
||||
{"zero range", "00000000-0000-0000-0000-000000000001", "?range=0"},
|
||||
@@ -319,7 +323,7 @@ func TestGetObserverActivity_BadRequests(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration, _ time.Time) (*api.ObserverActivity, error) {
|
||||
t.Fatal("reader should not be called")
|
||||
return nil, nil
|
||||
},
|
||||
@@ -338,7 +342,7 @@ func TestGetObserverActivity_NotFound(t *testing.T) {
|
||||
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
r := chi.NewRouter()
|
||||
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration, _ time.Time) (*api.ObserverActivity, error) {
|
||||
return nil, pgx.ErrNoRows
|
||||
},
|
||||
}))
|
||||
@@ -364,7 +368,7 @@ func TestGetObserverActivity_ReaderError(t *testing.T) {
|
||||
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
r := chi.NewRouter()
|
||||
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration, _ time.Time) (*api.ObserverActivity, error) {
|
||||
return nil, errors.New("boom")
|
||||
},
|
||||
}))
|
||||
@@ -381,7 +385,7 @@ func TestGetObserverActivity_NullsSerialise(t *testing.T) {
|
||||
observerID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
||||
r := chi.NewRouter()
|
||||
r.Get("/observers/{observerId}/activity", getObserverActivity(stubReader{
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
|
||||
getObserverActivity: func(_ context.Context, _ uuid.UUID, _, _ time.Duration, _ time.Time) (*api.ObserverActivity, error) {
|
||||
return &api.ObserverActivity{
|
||||
PayloadTypes: []api.PayloadBreakdownItem{},
|
||||
Points: []api.ObserverActivityPoint{{T: 1757376000000, Observations: 3}},
|
||||
|
||||
@@ -32,7 +32,7 @@ type stubReader struct {
|
||||
getObserver func(ctx context.Context, observerID uuid.UUID) (*api.Observer, error)
|
||||
getObserverTelemetry func(ctx context.Context, observerID uuid.UUID, since, until time.Time, afterID int64) (*api.ObserverTelemetry, error)
|
||||
getObserverTelemetryBucketed func(ctx context.Context, observerID uuid.UUID, since, until time.Time, bucketHours int32) ([]api.ObserverTelemetryPoint, error)
|
||||
getObserverActivity func(ctx context.Context, observerID uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error)
|
||||
getObserverActivity func(ctx context.Context, observerID uuid.UUID, window, interval time.Duration, until time.Time) (*api.ObserverActivity, error)
|
||||
getObserverScopes func(ctx context.Context, observerID uuid.UUID) ([]string, error)
|
||||
listObserverAdverts func(ctx context.Context, observerID uuid.UUID, cursor int64, limit int32) (api.Page[api.AdvertObservation], error)
|
||||
listNodes func(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, pubkeyPrefix, name, scope string, cursor int64, limit int32, includeNeighbors bool) (api.Page[api.NodeSummary], error)
|
||||
@@ -186,9 +186,9 @@ func (s stubReader) GetObserverTelemetryBucketed(ctx context.Context, observerID
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s stubReader) GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
|
||||
func (s stubReader) GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration, until time.Time) (*api.ObserverActivity, error) {
|
||||
if s.getObserverActivity != nil {
|
||||
return s.getObserverActivity(ctx, observerID, window, interval)
|
||||
return s.getObserverActivity(ctx, observerID, window, interval, until)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ type Observer struct {
|
||||
LastStatusAt *int64 `json:"lastStatusAt,omitempty"` // epoch ms
|
||||
FirstSeen int64 `json:"firstSeen"` // epoch ms
|
||||
LastSeen int64 `json:"lastSeen"` // epoch ms
|
||||
ObservationCount int64 `json:"observationCount"`
|
||||
Brokers []ObserverBroker `json:"brokers"` // broker names this observer has been seen on
|
||||
ObservationCount int64 `json:"observationCount"` // legacy cumulative presence counter; includes non-packet events
|
||||
Brokers []ObserverBroker `json:"brokers"` // broker names this observer has been seen on
|
||||
}
|
||||
|
||||
// ObserverTelemetryPoint is a single telemetry snapshot for an observer.
|
||||
@@ -89,7 +89,22 @@ type ObserverActivityPoint struct {
|
||||
}
|
||||
|
||||
// ObserverActivity is the per-observer heard-activity response.
|
||||
// ObserverActivitySummary describes stored packet records, never MQTT presence events.
|
||||
type ObserverActivitySummary struct {
|
||||
RecordedPackets int64 `json:"recordedPackets"`
|
||||
LastCompleteHour int64 `json:"lastCompleteHour"`
|
||||
LastCompleteHourStart int64 `json:"lastCompleteHourStart"`
|
||||
LastCompleteHourEnd int64 `json:"lastCompleteHourEnd"`
|
||||
LatestRecordedAt *int64 `json:"latestRecordedAt"`
|
||||
}
|
||||
|
||||
type ObserverActivity struct {
|
||||
WindowStart int64 `json:"windowStart"` // inclusive complete-bucket start, epoch ms
|
||||
WindowEnd int64 `json:"windowEnd"` // exclusive complete-bucket end, epoch ms
|
||||
GeneratedAt int64 `json:"generatedAt"` // response computation time, not proof of continuous coverage
|
||||
Source string `json:"source"` // raw or hourly; missing records do not prove an outage
|
||||
Summary *ObserverActivitySummary `json:"summary,omitempty"`
|
||||
|
||||
Range string `json:"range"`
|
||||
Interval string `json:"interval"`
|
||||
Radio *ObserverActivityRadio `json:"radio"`
|
||||
|
||||
@@ -100,7 +100,7 @@ type Reader interface {
|
||||
// GetObserverActivity returns bucketed heard-activity for an observer over the trailing window.
|
||||
// interval >= 1h is served from the hourly rollup. Returns pgx.ErrNoRows for an unknown observer.
|
||||
// Range and Interval on the result are left empty for the handler to fill.
|
||||
GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration) (*ObserverActivity, error)
|
||||
GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration, until time.Time) (*ObserverActivity, error)
|
||||
|
||||
// GetObserverScopes returns the names of all transport scopes an observer has
|
||||
// been seen forwarding packets for, ordered alphabetically.
|
||||
|
||||
Vendored
+1
-1
@@ -148,7 +148,7 @@ func (s *stubReader) GetObserverTelemetryBucketed(_ context.Context, _ uuid.UUID
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubReader) GetObserverActivity(_ context.Context, _ uuid.UUID, _, _ time.Duration) (*api.ObserverActivity, error) {
|
||||
func (s *stubReader) GetObserverActivity(_ context.Context, _ uuid.UUID, _, _ time.Duration, _ time.Time) (*api.ObserverActivity, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
Vendored
+3
-3
@@ -372,10 +372,10 @@ func (cr *CachedReader) GetObserverTelemetryBucketed(ctx context.Context, observ
|
||||
}
|
||||
|
||||
// GetObserverActivity implements [api.Reader].
|
||||
func (cr *CachedReader) GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration) (*api.ObserverActivity, error) {
|
||||
key := keyObserverActivityPrefix + observerID.String() + ":" + window.String() + ":" + interval.String()
|
||||
func (cr *CachedReader) GetObserverActivity(ctx context.Context, observerID uuid.UUID, window, interval time.Duration, until time.Time) (*api.ObserverActivity, error) {
|
||||
key := keyObserverActivityPrefix + observerID.String() + ":" + window.String() + ":" + interval.String() + ":" + until.UTC().Format(time.RFC3339Nano)
|
||||
return getOrSet(ctx, cr.c, key, observerActivityTTL, func() (*api.ObserverActivity, error) {
|
||||
return cr.inner.GetObserverActivity(ctx, observerID, window, interval)
|
||||
return cr.inner.GetObserverActivity(ctx, observerID, window, interval, until)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ type DB interface {
|
||||
UpsertObserver(ctx context.Context, pubkey []byte) (uuid.UUID, string, error)
|
||||
|
||||
// UpsertObserverBroker records that this observer was seen on brokerName.
|
||||
UpsertObserverBroker(ctx context.Context, observerID uuid.UUID, brokerName string) error
|
||||
UpsertObserverBroker(ctx context.Context, observerID uuid.UUID, brokerName string, isPacket bool) error
|
||||
|
||||
// UpsertIATA auto-creates an iata_codes row if it doesn't exist yet.
|
||||
UpsertIATA(ctx context.Context, iata string) error
|
||||
|
||||
@@ -201,8 +201,10 @@ func (s *stubDB) SetNodeCapability(_ context.Context, nodeID uuid.UUID, paths, t
|
||||
func (s *stubDB) UpsertObserver(_ context.Context, _ []byte) (uuid.UUID, string, error) {
|
||||
return uuid.Nil, "", nil
|
||||
}
|
||||
func (s *stubDB) UpsertObserverBroker(_ context.Context, _ uuid.UUID, _ string) error { return nil }
|
||||
func (s *stubDB) UpsertIATA(_ context.Context, _ string) error { return nil }
|
||||
func (s *stubDB) UpsertObserverBroker(_ context.Context, _ uuid.UUID, _ string, _ bool) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubDB) UpsertIATA(_ context.Context, _ string) error { return nil }
|
||||
func (s *stubDB) UpsertPacket(_ context.Context, _ UpsertPacketParams) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (w *Worker) handleNeighbors(ctx context.Context, iata, pubkeyHex string, ra
|
||||
w.log.Error(fmt.Sprintf("db: upsert observer failed in neighbors from %s", pubkeyHex), "error", err)
|
||||
return
|
||||
}
|
||||
if err := w.db.UpsertObserverBroker(ctx, observerID, w.cfg.BrokerName); err != nil {
|
||||
if err := w.db.UpsertObserverBroker(ctx, observerID, w.cfg.BrokerName, false); err != nil {
|
||||
w.log.Error(fmt.Sprintf("db: upsert observer broker failed in neighbors from %s", pubkeyHex), "error", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -324,7 +324,7 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [
|
||||
w.log.Error(fmt.Sprintf("db: upsert observer failed with packet from %s/%s", iata, pubkeyHex), "error", err)
|
||||
return
|
||||
}
|
||||
err = w.db.UpsertObserverBroker(ctx, id, w.cfg.BrokerName)
|
||||
err = w.db.UpsertObserverBroker(ctx, id, w.cfg.BrokerName, true)
|
||||
if err != nil {
|
||||
w.log.Error(fmt.Sprintf("db: update observer broker failed with packet from %s/%s", iata, pubkeyHex), "error", err)
|
||||
return
|
||||
|
||||
@@ -87,7 +87,7 @@ func (w *Worker) handleStatus(ctx context.Context, pubkeyHex string, raw []byte)
|
||||
if w.onObserverUpsert != nil {
|
||||
w.onObserverUpsert(ctx, id)
|
||||
}
|
||||
if err := w.db.UpsertObserverBroker(ctx, id, w.cfg.BrokerName); err != nil {
|
||||
if err := w.db.UpsertObserverBroker(ctx, id, w.cfg.BrokerName, false); err != nil {
|
||||
w.log.Error(fmt.Sprintf("db: upsert observer broker failed in status from %s", pubkeyHex), "error", err)
|
||||
}
|
||||
params := UpdateObserverStatusParams{
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
package presence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type brokerTestStore struct {
|
||||
Store
|
||||
initial []bool
|
||||
seen, packets []time.Time
|
||||
}
|
||||
|
||||
func (s *brokerTestStore) UpsertObserverBroker(_ context.Context, _ uuid.UUID, _ string, packet bool) error {
|
||||
s.initial = append(s.initial, packet)
|
||||
return nil
|
||||
}
|
||||
func (s *brokerTestStore) TouchObserverBrokers(_ context.Context, _ []uuid.UUID, _ []string, seen, packets []time.Time) error {
|
||||
s.seen = seen
|
||||
s.packets = packets
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBrokerPresenceDoesNotInventPacketFreshness(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := &brokerTestStore{}
|
||||
c := New(store, time.Second, time.Minute)
|
||||
now := time.Now()
|
||||
c.now = func() time.Time { return now }
|
||||
id := uuid.New()
|
||||
c.UpsertObserverBroker(ctx, id, "one", false)
|
||||
c.UpsertObserverBroker(ctx, id, "one", false)
|
||||
c.Flush(ctx)
|
||||
if len(store.initial) != 1 || store.initial[0] || !store.packets[0].IsZero() {
|
||||
t.Fatal("status-only activity invented a packet")
|
||||
}
|
||||
now = now.Add(time.Minute)
|
||||
packetTime := now
|
||||
c.UpsertObserverBroker(ctx, id, "one", true)
|
||||
now = now.Add(time.Minute)
|
||||
c.UpsertObserverBroker(ctx, id, "one", false)
|
||||
c.Flush(ctx)
|
||||
if !store.seen[0].Equal(now) || !store.packets[0].Equal(packetTime) {
|
||||
t.Fatal("later status changed packet freshness")
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ type Store interface {
|
||||
|
||||
// TouchObserverBrokers applies coalesced last_seen/last_packet_at bumps
|
||||
// for the given (observer, broker) pairs in one statement.
|
||||
TouchObserverBrokers(ctx context.Context, ids []uuid.UUID, brokers []string, seen []time.Time) error
|
||||
TouchObserverBrokers(ctx context.Context, ids []uuid.UUID, brokers []string, seen, packets []time.Time) error
|
||||
|
||||
// TouchPackets applies coalesced last_heard_at bumps for the given packet
|
||||
// hashes in one statement.
|
||||
@@ -51,6 +51,8 @@ type observerBump struct {
|
||||
count int32
|
||||
}
|
||||
|
||||
type brokerBump struct{ seen, packet time.Time }
|
||||
|
||||
type brokerKey struct {
|
||||
id uuid.UUID
|
||||
broker string
|
||||
@@ -68,7 +70,7 @@ type Coalescer struct {
|
||||
identities map[string]identity // pubkey -> observer row
|
||||
dirtyObservers map[uuid.UUID]observerBump
|
||||
knownBrokers map[brokerKey]struct{}
|
||||
dirtyBrokers map[brokerKey]time.Time
|
||||
dirtyBrokers map[brokerKey]brokerBump
|
||||
knownIATAs map[string]struct{}
|
||||
packetsSeen map[string]time.Time // hash -> last observation
|
||||
dirtyPackets map[string]time.Time
|
||||
@@ -86,7 +88,7 @@ func New(store Store, flushInterval, packetTTL time.Duration) *Coalescer {
|
||||
identities: make(map[string]identity),
|
||||
dirtyObservers: make(map[uuid.UUID]observerBump),
|
||||
knownBrokers: make(map[brokerKey]struct{}),
|
||||
dirtyBrokers: make(map[brokerKey]time.Time),
|
||||
dirtyBrokers: make(map[brokerKey]brokerBump),
|
||||
knownIATAs: make(map[string]struct{}),
|
||||
packetsSeen: make(map[string]time.Time),
|
||||
dirtyPackets: make(map[string]time.Time),
|
||||
@@ -122,17 +124,22 @@ func (c *Coalescer) UpsertObserver(ctx context.Context, pubkey []byte) (uuid.UUI
|
||||
|
||||
// UpsertObserverBroker writes through the first time a pair is seen and
|
||||
// records a bump afterwards.
|
||||
func (c *Coalescer) UpsertObserverBroker(ctx context.Context, observerID uuid.UUID, brokerName string) error {
|
||||
func (c *Coalescer) UpsertObserverBroker(ctx context.Context, observerID uuid.UUID, brokerName string, isPacket bool) error {
|
||||
key := brokerKey{id: observerID, broker: brokerName}
|
||||
c.mu.Lock()
|
||||
if _, ok := c.knownBrokers[key]; ok {
|
||||
c.dirtyBrokers[key] = c.now()
|
||||
bump := c.dirtyBrokers[key]
|
||||
bump.seen = c.now()
|
||||
if isPacket {
|
||||
bump.packet = bump.seen
|
||||
}
|
||||
c.dirtyBrokers[key] = bump
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.Store.UpsertObserverBroker(ctx, observerID, brokerName); err != nil {
|
||||
if err := c.Store.UpsertObserverBroker(ctx, observerID, brokerName, isPacket); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
@@ -248,7 +255,7 @@ func (c *Coalescer) Flush(ctx context.Context) {
|
||||
brokers := c.dirtyBrokers
|
||||
packets := c.dirtyPackets
|
||||
c.dirtyObservers = make(map[uuid.UUID]observerBump)
|
||||
c.dirtyBrokers = make(map[brokerKey]time.Time)
|
||||
c.dirtyBrokers = make(map[brokerKey]brokerBump)
|
||||
c.dirtyPackets = make(map[string]time.Time)
|
||||
cutoff := c.now().Add(-c.packetTTL)
|
||||
for key, seen := range c.packetsSeen {
|
||||
@@ -266,12 +273,14 @@ func (c *Coalescer) Flush(ctx context.Context) {
|
||||
ids := make([]uuid.UUID, 0, len(brokers))
|
||||
names := make([]string, 0, len(brokers))
|
||||
seen := make([]time.Time, 0, len(brokers))
|
||||
packetTimes := make([]time.Time, 0, len(brokers))
|
||||
for key, ts := range brokers {
|
||||
ids = append(ids, key.id)
|
||||
names = append(names, key.broker)
|
||||
seen = append(seen, ts)
|
||||
seen = append(seen, ts.seen)
|
||||
packetTimes = append(packetTimes, ts.packet)
|
||||
}
|
||||
if err := c.Store.TouchObserverBrokers(ctx, ids, names, seen); err != nil {
|
||||
if err := c.Store.TouchObserverBrokers(ctx, ids, names, seen, packetTimes); err != nil {
|
||||
slog.Error(fmt.Sprintf("presence: flush observer brokers failed (%d rows dropped)", len(ids)), "component", "presence", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user