Packets: one statement per filter shape, exact pageCursor on packets, nodes and observers (2.1.0) (#241)

* feat(db): packet type domains, row-cursor indexes and the inverted-heard index

* feat(api): exact page cursors for packets, nodes and observers

* feat(db): normalise packet list filters before choosing a statement

* feat(db): one packets statement per filter shape with an exact row cursor

* fix(db): let the window statements decide since after until

* feat(db): exact keyset cursors for nodes and observers; observers page by last_seen

* feat(api): pageCursor on packets, nodes and observers; it wins over cursor

* test(planguard): packets statements and node/observer keysets leave pending; diff and storediff cases

* chore: bump version to 2.1.0

* test(db): turn off JIT in the keyset cursor tests
This commit is contained in:
MrAlders0n authored and GitHub committed 2026-10-19 15:59:55 -04:00
1 parent 94b4b40f9b
commit 41baee9197
404 files changed
+13030 -1017

No files matched your search

+1 -1
View File
@@ -69,7 +69,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|PacketSummaries|PacketEndpointsResolveLive|ObserverMetrics|RouteEvidence|RouteEvidenceIndex|AnalyticsRetention|AnalyticsRetentionConcurrent|DeleteOldPacketsBatches|MeshMapperCatalogue|ChannelMessageScopes|NodeLocationReset|RunMigrations[A-Za-z]*|KnownRoutesIndexSet|Reconfirm[A-Za-z]*|BigintCursors|RouteSearchHopOrder|StatsNodeTypesSplit|ListKnownRoutes|ListKnownRoutesMatchesLegacy|KnownRoutesCursorTies|KnownRoutesUnanalyzedRegion|KnownRoutesHopStatementsStayCustom|KnownRoutePaginationTies|RouteRetentionCutoffsExact|DeleteExpiredRoutes|DeleteGraceRoutesSlice|GraceWatermarkHeldByLockedRow|OpenPoolsSettings|PlanRouterClassCUnnamed|APIStoreHopStatementsUnnamed|PgStatStatementsMigrationGuarded|DBStatements|DBStatementsNoExtension|PgStatStatementsMigrationNoCreatableSchema|IndexHealthBaselineOnReindex|UnusedIndexes|PgActivity|RollHourSetsWorkMem|SmallTableIndexSet|NodeReadersAfterIndexDrops|ObserverChannelReadersAfterIndexDrops|UpsertChannelWriteThrough|UpsertChannelHashOnlyWriteThrough|ReadOnlyPoolsRejectWrites)Postgres$' -count=1 -v
run: go test ./db -run '^Test(Signal|Paths|PacketSummaries|PacketEndpointsResolveLive|ObserverMetrics|RouteEvidence|RouteEvidenceIndex|AnalyticsRetention|AnalyticsRetentionConcurrent|DeleteOldPacketsBatches|MeshMapperCatalogue|ChannelMessageScopes|NodeLocationReset|RunMigrations[A-Za-z]*|KnownRoutesIndexSet|PacketsDomainChecks|Reconfirm[A-Za-z]*|BigintCursors|RouteSearchHopOrder|StatsNodeTypesSplit|ListKnownRoutes|ListKnownRoutesMatchesLegacy|KnownRoutesCursorTies|KnownRoutesUnanalyzedRegion|KnownRoutesHopStatementsStayCustom|KnownRoutePaginationTies|RouteRetentionCutoffsExact|DeleteExpiredRoutes|DeleteGraceRoutesSlice|GraceWatermarkHeldByLockedRow|OpenPoolsSettings|PlanRouterClassCUnnamed|APIStoreHopStatementsUnnamed|PgStatStatementsMigrationGuarded|DBStatements|DBStatementsNoExtension|PgStatStatementsMigrationNoCreatableSchema|IndexHealthBaselineOnReindex|UnusedIndexes|PgActivity|RollHourSetsWorkMem|SmallTableIndexSet|NodeReadersAfterIndexDrops|ObserverChannelReadersAfterIndexDrops|UpsertChannelWriteThrough|UpsertChannelHashOnlyWriteThrough|ReadOnlyPoolsRejectWrites|ListPacketsGlobalMatchesLegacy|PacketsGlobalCursorTies|ListPacketsWindowInvertedRows|PacketsLatestObserverTie|NodesLegacyCursorMatchesToday|NodesPageCursorTies|ObserversLegacyCursorMatchesToday|ObserversNextCursorFromLastSeen)Postgres$' -count=1 -v
- name: Verify route retention task against PostgreSQL 16
env:
+1 -1
View File
@@ -50,7 +50,7 @@ import (
var version = "dev"
// @title MeshCore Beacon API
// @version 2.0.8
// @version 2.1.0
// @description MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket.
// @description REST requests share a configurable per-client rate limit (default 600/minute and a 600-request one-second burst cap). Exceeded limits return HTTP 429 with error.code=rate_limited and a Retry-After header in seconds. CORS preflights and WebSocket upgrades do not consume this API budget.
// @description WebSocket upgrade attempts at /ws have a configurable per-client limit (default 10/minute, including failed handshakes). Rate exhaustion returns HTTP 429 with Retry-After before upgrade. An accepted socket exceeding the concurrent cap closes with code 1013 before hello; established connections remain open.
+2 -2
View File
@@ -58,8 +58,8 @@ ratelimit:
# Keep max_connections well above that.
#database:
# # custom_all (default): every API statement gets a custom plan.
# # split: statements admitted to the generic-plan class run on cached generic plans. No statement
# # is admitted yet, so split has no effect; keep custom_all.
# # split: statements admitted to the generic-plan class (db/planpolicy/cached_gen.go) run on cached
# # generic plans on api_cached; every other API statement keeps custom plans.
# # single: rollback. One API pool on today's settings; every guard family observes.
# api_plan_layout: custom_all
+38
View File
@@ -198,3 +198,41 @@ FROM pg_index WHERE indrelid = 'known_routes'::regclass`).Scan(&got, &bad); err
t.Fatalf("known_routes indexes = %v, want %v; not valid and ready: %v", got, want, bad)
}
}
// The operator pre-builds the four packet indexes; the migrations record them, not fail or rebuild them.
func TestRunMigrationsPacketsIndexesPrebuiltPostgres(t *testing.T) {
ctx, pool := schemaPool(t)
if err := RunMigrations(ctx, pool); err != nil {
t.Fatal(err)
}
oids := map[string]uint32{}
for _, idx := range packetsEndStateIndexes {
var oid uint32
if err := pool.QueryRow(ctx, "SELECT $1::regclass::oid", idx).Scan(&oid); err != nil {
t.Fatalf("%s: %v", idx, err)
}
oids[idx] = oid
}
files := []string{
"029_packets_last_heard_hash.sql",
"030_packets_type_last_heard.sql",
"031_packets_scope_last_heard.sql",
"032_packets_heard_inverted.sql",
}
if _, err := pool.Exec(ctx, "DELETE FROM schema_migrations WHERE filename = ANY($1)", files); err != nil {
t.Fatal(err)
}
if err := RunMigrations(ctx, pool); err != nil {
t.Fatalf("029-032 over pre-built indexes: %v", err)
}
checkRecorded(t, ctx, pool, files...)
for idx, before := range oids {
var after uint32
if err := pool.QueryRow(ctx, "SELECT $1::regclass::oid", idx).Scan(&after); err != nil {
t.Fatalf("%s: %v", idx, err)
}
if after != before {
t.Errorf("%s rebuilt: oid %d -> %d", idx, before, after)
}
}
}
@@ -0,0 +1,5 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Decoded headers carry 4 payload-type bits; an absent type filter is passed as the full domain.
ALTER TABLE packets ADD CONSTRAINT packets_payload_type_domain CHECK (payload_type BETWEEN 0 AND 15) NOT VALID;
@@ -0,0 +1,5 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Scans packets under SHARE UPDATE EXCLUSIVE; writes continue.
ALTER TABLE packets VALIDATE CONSTRAINT packets_payload_type_domain;
@@ -0,0 +1,5 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Decoded headers carry 2 route-type bits; an absent type filter is passed as the full domain.
ALTER TABLE packets ADD CONSTRAINT packets_route_type_domain CHECK (route_type BETWEEN 0 AND 3) NOT VALID;
@@ -0,0 +1,5 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Scans packets under SHARE UPDATE EXCLUSIVE; writes continue.
ALTER TABLE packets VALIDATE CONSTRAINT packets_route_type_domain;
@@ -0,0 +1,5 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Packet pages walk (last_heard_at, packet_hash); the hash breaks millisecond ties.
CREATE INDEX CONCURRENTLY idx_packets_last_heard_hash ON packets USING btree (last_heard_at DESC, packet_hash DESC);
@@ -0,0 +1,5 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Typed pages read one range per (payload type, route type) pair.
CREATE INDEX CONCURRENTLY idx_packets_type_last_heard ON packets USING btree (payload_type, route_type, last_heard_at DESC, packet_hash DESC);
@@ -0,0 +1,5 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Scoped pages read one range per scope; the included columns keep typed and windowed scope pages index-only.
CREATE INDEX CONCURRENTLY idx_packets_scope_last_heard ON packets USING btree (scope_id, last_heard_at DESC, packet_hash DESC) INCLUDE (payload_type, route_type, first_heard_at) WHERE (scope_id IS NOT NULL);
@@ -0,0 +1,5 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Concurrent upserts can leave last_heard_at below first_heard_at; window pages read those rows here.
CREATE INDEX CONCURRENTLY idx_packets_heard_inverted ON packets USING btree (first_heard_at) INCLUDE (last_heard_at, packet_hash, payload_type, route_type, scope_id) WHERE ((first_heard_at > last_heard_at) IS TRUE);
@@ -0,0 +1,6 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Without these statistics the planner assumes a third of packets are inverted and misprices the window's second branch.
CREATE STATISTICS packets_heard_inverted_stat ON ((first_heard_at > last_heard_at)) FROM packets;
ANALYZE packets;
+40 -16
View File
@@ -90,12 +90,32 @@ func (s *Store) SetNodeDefaultScope(ctx context.Context, nodeID uuid.UUID, scope
})
}
func (s *Store) ListNodes(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) {
var cursorTS pgtype.Timestamptz
if cursor > 0 {
cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true}
// uuidKeysetCursor maps the cursors of a (last_seen DESC, id DESC) list to the
// statement's row-cursor pair. A pageCursor (at, id) wins. A numeric cursor
// pairs its millisecond with the nil uuid, which sorts below every id, so the
// row comparison keeps today's "last_seen < ms". Page 1 starts at infinity.
func uuidKeysetCursor(cursor int64, at *time.Time, id uuid.UUID) (pgtype.Timestamptz, uuid.UUID) {
switch {
case at != nil:
return pgtype.Timestamptz{Time: *at, Valid: true}, id
case cursor > 0:
return pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true}, uuid.Nil
default:
return pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true}, uuid.Nil
}
rows, err := s.q.ListNodes(ctx, sqlc.ListNodesParams{
}
// listNodesParams builds the statement's arguments for one ListNodes request.
func listNodesParams(nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, pubkeyPrefix, name, scope string, cursor int64, pageCursor *api.NodeCursor, limit int32, includeNeighbors bool) sqlc.ListNodesParams {
var (
at *time.Time
id uuid.UUID
)
if pageCursor != nil {
at, id = &pageCursor.LastSeen, pageCursor.ID
}
cursorTS, cursorID := uuidKeysetCursor(cursor, at, id)
return sqlc.ListNodesParams{
Column1: nodeType,
Column2: iatas,
Column3: tristate(supportsMultibytePaths),
@@ -107,9 +127,14 @@ func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, s
Column9: scope,
Column10: includeNeighbors,
Column11: pubkeyPrefix,
})
Column12: cursorID,
}
}
func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, supportsMultibytePaths, supportsMultibyteTraces *bool, pubkey []byte, pubkeyPrefix, name, scope string, cursor int64, pageCursor *api.NodeCursor, limit int32, includeNeighbors bool) (api.NodePage, error) {
rows, err := s.q.ListNodes(ctx, listNodesParams(nodeType, iatas, supportsMultibytePaths, supportsMultibyteTraces, pubkey, pubkeyPrefix, name, scope, cursor, pageCursor, limit, includeNeighbors))
if err != nil {
return api.Page[api.NodeSummary]{}, err
return api.NodePage{}, err
}
hasMore := len(rows) > int(limit)
if hasMore {
@@ -143,16 +168,15 @@ func (s *Store) ListNodes(ctx context.Context, nodeType int16, iatas []string, s
}
items = append(items, node)
}
var nextCursor *int64
if hasMore && len(items) > 0 {
ms := rows[len(rows)-1].LastSeen.Time.UnixMilli()
nextCursor = &ms
page := api.NodePage{Page: api.Page[api.NodeSummary]{Items: items, HasMore: hasMore}}
if hasMore && len(rows) > 0 {
last := rows[len(rows)-1]
ms := last.LastSeen.Time.UnixMilli()
next := api.NodeCursor{LastSeen: last.LastSeen.Time, ID: last.ID}.String()
page.NextCursor = &ms
page.NextPageCursor = &next
}
return api.Page[api.NodeSummary]{
Items: items,
NextCursor: nextCursor,
HasMore: hasMore,
}, nil
return page, nil
}
func (s *Store) GetNode(ctx context.Context, nodeID uuid.UUID) (*api.Node, error) {
+4 -2
View File
@@ -15,6 +15,7 @@ import (
"time"
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
@@ -70,7 +71,8 @@ ANALYZE nodes; ANALYZE node_iatas; ANALYZE node_neighbors; ANALYZE observers; AN
t.Fatal(err)
}
query := sqlc.New(tx)
base := sqlc.ListNodesParams{Column1: int16(0), Column3: "any", Column4: "any", Column6: "", Limit: 51}
page1 := pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true}
base := sqlc.ListNodesParams{Column1: int16(0), Column3: "any", Column4: "any", Column6: "", Column7: page1, Limit: 51, Column12: uuid.Nil}
for _, tc := range []struct {
name string
change func(*sqlc.ListNodesParams)
@@ -154,7 +156,7 @@ ANALYZE nodes; ANALYZE node_iatas; ANALYZE node_neighbors; ANALYZE observers; AN
sql := strings.Split(strings.Split(queries, "-- name: ListNodes :many\n")[1], "\n-- name:")[0]
var planJSON []byte
err = tx.QueryRow(ctx, "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) "+sql,
int16(0), []string(nil), "any", "any", []byte(nil), "", nil, int32(51), "", false, "").Scan(&planJSON)
int16(0), []string(nil), "any", "any", []byte(nil), "", page1, int32(51), "", false, "", uuid.Nil).Scan(&planJSON)
if err != nil {
t.Fatal(err)
}
@@ -0,0 +1,517 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"context"
"fmt"
"slices"
"testing"
"time"
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// keysetFixture: 300 nodes and 120 observers. Row i sits in block (i-1)/size at position r = (i-1)%size; rows
// r = 0..2 of every block share one millisecond (12 tied groups per table), r = 0 and r = 1 also share one
// microsecond in blocks 0..2 (3 exact pairs). Observers are within minutes of now() so status splits online and
// offline; 20 (i % 6 = 0) have NULL last_status_at, the rest have last_status_at 60-120 s before last_seen.
const keysetFixture = `
INSERT INTO transport_scopes (id, name, transport_key, key_fingerprint) VALUES
(1, '#test', '\x01', '\x02'), (2, '#other', '\x03', '\x04');
INSERT INTO nodes (id, public_key, node_type, name, last_seen, default_scope_id)
SELECT md5('node' || i)::uuid,
CASE WHEN i % 10 = 0 THEN '\x0a'::bytea || sha256(int4send(i)) ELSE sha256(int4send(i)) END,
1 + i % 4, 'node-' || i,
'2026-03-01'::timestamptz - (b * 25 + CASE WHEN r < 3 THEN 0 ELSE r END) * interval '1 second'
+ CASE WHEN r < 3 AND NOT (b < 3 AND r = 1) THEN r ELSE 0 END * interval '1 microsecond',
CASE WHEN i % 3 = 0 THEN 1 END
FROM generate_series(1, 300) i, LATERAL (SELECT (i - 1) / 25 AS b, (i - 1) % 25 AS r) k;
INSERT INTO node_iatas (node_id, iata) SELECT md5('node' || i)::uuid, 'YVR' FROM generate_series(1, 300) i WHERE i % 2 = 0;
INSERT INTO node_iatas (node_id, iata) SELECT md5('node' || i)::uuid, 'YYJ' FROM generate_series(1, 300) i WHERE i % 5 = 0;
INSERT INTO observers (id, public_key, display_name, observer_type, last_iata, last_seen, last_status_at)
SELECT md5('observer' || i)::uuid, sha256(int4send(100000 + i)), 'obs-' || i,
CASE WHEN i % 3 = 0 THEN 'repeater' ELSE 'companion' END,
CASE WHEN i % 2 = 0 THEN 'YVR' ELSE 'YYZ' END,
ls, CASE WHEN i % 6 = 0 THEN NULL ELSE ls - interval '1 minute' - (i % 4) * interval '20 seconds' END
FROM generate_series(1, 120) i,
LATERAL (SELECT (i - 1) / 10 AS b, (i - 1) % 10 AS r) k,
LATERAL (SELECT date_trunc('second', now()) - interval '1 second'
- (b * 10 + CASE WHEN r < 3 THEN 0 ELSE r END) * interval '5 seconds'
+ CASE WHEN r < 3 AND NOT (b < 3 AND r = 1) THEN r ELSE 0 END * interval '1 microsecond') t(ls);
INSERT INTO observer_brokers (observer_id, broker_name) SELECT md5('observer' || i)::uuid, 'b1' FROM generate_series(1, 120) i;
INSERT INTO observer_brokers (observer_id, broker_name) SELECT md5('observer' || i)::uuid, 'b2' FROM generate_series(1, 120) i WHERE i % 4 = 0;
INSERT INTO observer_scopes (observer_id, scope_id) SELECT md5('observer' || i)::uuid, 1 FROM generate_series(1, 120) i WHERE i % 5 = 0;
INSERT INTO observer_scopes (observer_id, scope_id) SELECT md5('observer' || i)::uuid, 2 FROM generate_series(1, 120) i WHERE i % 7 = 0;
ANALYZE nodes, node_iatas, observers, observer_brokers, observer_scopes, transport_scopes;`
// keysetTx is a rolled-back transaction holding the schema after the small-table index drops and keysetFixture.
func keysetTx(t *testing.T) (context.Context, pgx.Tx, *Store) {
t.Helper()
ctx, tx := retentionTx(t)
applyBaseline(t, ctx, tx)
applyMigrationsTx(t, ctx, tx, smallTableMigrations...)
mustExec(t, ctx, tx, keysetFixture)
// enable_seqscan = off pushes every plan past jit_above_cost; JIT-compiling thousands of queries times out on
// servers built with LLVM.
mustExec(t, ctx, tx, "SET LOCAL jit = off")
return ctx, tx, &Store{q: sqlc.New(tx)}
}
// keysetRow is one row of a table in (last_seen DESC, id DESC) order.
type keysetRow struct {
ID uuid.UUID
LastSeen time.Time
LastStatusAt pgtype.Timestamptz
}
func keysetOrder(t *testing.T, ctx context.Context, tx pgx.Tx, sql string) []keysetRow {
t.Helper()
return legacyRows[keysetRow](t, ctx, tx, sql)
}
// tiedMillis are the epoch milliseconds that hold more than one row, newest first.
func tiedMillis(rows []keysetRow) []int64 {
var out []int64
for i := 1; i < len(rows); i++ {
ms := rows[i].LastSeen.UnixMilli()
if ms == rows[i-1].LastSeen.UnixMilli() && !slices.Contains(out, ms) {
out = append(out, ms)
}
}
return out
}
func keysetIDs(rows []keysetRow) []uuid.UUID {
out := make([]uuid.UUID, len(rows))
for i, r := range rows {
out[i] = r.ID
}
return out
}
func legacyCursorTS(cursor int64) pgtype.Timestamptz {
if cursor > 0 {
return pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true}
}
return pgtype.Timestamptz{}
}
type nodeShape struct {
name string
nodeType int16
iatas []string
nameFilter string
scope string
prefix string
withNeighbours bool
}
func nodeShapes() []nodeShape {
return []nodeShape{
{name: "unfiltered"},
{name: "unfiltered neighbours", withNeighbours: true},
{name: "type 2", nodeType: 2},
{name: "iatas YVR", iatas: []string{"YVR"}},
{name: "name node-1", nameFilter: "node-1"},
{name: "scope #test", scope: "#test"},
{name: "pubkeyPrefix 0a", prefix: "0a"},
}
}
func legacyNodeRows(t *testing.T, ctx context.Context, tx pgx.Tx, sql string, sh nodeShape, cursor int64, pageLimit int32) []sqlc.ListNodesRow {
t.Helper()
return legacyRows[sqlc.ListNodesRow](t, ctx, tx, sql, sh.nodeType, sh.iatas, "any", "any", []byte(nil), sh.nameFilter,
legacyCursorTS(cursor), pageLimit, sh.scope, sh.withNeighbours, sh.prefix)
}
func newNodeRows(t *testing.T, ctx context.Context, s *Store, sh nodeShape, cursor int64, pc *api.NodeCursor, limit int32) []sqlc.ListNodesRow {
t.Helper()
rows, err := s.q.ListNodes(ctx, listNodesParams(sh.nodeType, sh.iatas, nil, nil, nil, sh.prefix, sh.nameFilter, sh.scope, cursor, pc, limit, sh.withNeighbours))
if err != nil {
t.Fatalf("%s: %v", sh.name, err)
}
return rows
}
func nodeKeys(rows []sqlc.ListNodesRow) []string {
out := make([]string, len(rows))
for i, r := range rows {
out[i] = fmt.Sprintf("%s/%s", r.LastSeen.Time.Format("15:04:05.000000"), r.ID.String()[:4])
}
return out
}
func nodeLastSeen(rows []sqlc.ListNodesRow) []time.Time {
out := make([]time.Time, len(rows))
for i, r := range rows {
out[i] = r.LastSeen.Time
}
return out
}
// Legacy cursors return today's rows: the nil uuid sorts below every id, so (ms, nil) is today's "< ms".
func TestNodesLegacyCursorMatchesTodayPostgres(t *testing.T) {
ctx, tx, s := keysetTx(t)
all := keysetOrder(t, ctx, tx, "SELECT id, last_seen, NULL::timestamptz FROM nodes ORDER BY last_seen DESC, id DESC")
tied := tiedMillis(all)
if len(tied) != 12 {
t.Fatalf("fixture: %d tied milliseconds, want 12", len(tied))
}
cursors := append([]int64{0, -5, all[3].LastSeen.Add(-500 * time.Millisecond).UnixMilli()}, tied...)
nonEmpty := 0
for _, setting := range planSettings {
mustExec(t, ctx, tx, setting)
for _, sh := range nodeShapes() {
for _, c := range cursors {
for _, limit := range []int32{1, 2, 51} {
want := legacyNodeRows(t, ctx, tx, legacyListNodesDet, sh, c, limit+1)
got := newNodeRows(t, ctx, s, sh, c, nil, limit)
if !sameRows(got, want) {
t.Fatalf("%q %s cursor=%d limit=%d:\n got %v\n want %v", setting, sh.name, c, limit, nodeKeys(got), nodeKeys(want))
}
raw := legacyNodeRows(t, ctx, tx, legacyListNodes, sh, c, limit+1)
if !slices.EqualFunc(nodeLastSeen(got), nodeLastSeen(raw), time.Time.Equal) {
t.Fatalf("%q %s cursor=%d limit=%d vs raw:\n got %v\n raw %v", setting, sh.name, c, limit, nodeKeys(got), nodeKeys(raw))
}
if len(got) > 0 {
nonEmpty++
}
}
}
}
}
mustExec(t, ctx, tx, "RESET plan_cache_mode; RESET enable_seqscan")
if nonEmpty < 500 {
t.Fatalf("only %d non-empty comparisons; the fixture does not exercise the statement", nonEmpty)
}
}
// A page boundary inside a tied millisecond (and inside an exact-microsecond pair): a pageCursor page starts
// with the tied rows the legacy cursor skips; a limit-1 pageCursor walk returns every node once, in order.
func TestNodesPageCursorTiesPostgres(t *testing.T) {
ctx, tx, s := keysetTx(t)
all := keysetOrder(t, ctx, tx, "SELECT id, last_seen, NULL::timestamptz FROM nodes ORDER BY last_seen DESC, id DESC")
if len(all) != 300 {
t.Fatalf("fixture: %d nodes", len(all))
}
// The smallest page that ends inside a tied millisecond, and the smallest that ends between two rows with the
// same microsecond, where only the id separates them.
var limits []int
for i := 1; i < len(all) && len(limits) < 2; i++ {
sameMS := all[i].LastSeen.UnixMilli() == all[i-1].LastSeen.UnixMilli()
if (len(limits) == 0 && sameMS) || (len(limits) == 1 && all[i].LastSeen.Equal(all[i-1].LastSeen)) {
limits = append(limits, i)
}
}
if len(limits) != 2 || all[limits[0]].LastSeen.Equal(all[limits[0]-1].LastSeen) {
t.Fatalf("fixture: boundaries %v", limits)
}
none := nodeShape{name: "unfiltered"}
for _, limit := range limits {
page1, err := s.ListNodes(ctx, 0, nil, nil, nil, nil, "", "", "", 0, nil, int32(limit), false)
if err != nil {
t.Fatal(err)
}
last := all[limit-1]
lastMS := last.LastSeen.UnixMilli()
if !page1.HasMore || page1.NextCursor == nil || *page1.NextCursor != lastMS || page1.NextPageCursor == nil ||
*page1.NextPageCursor != (api.NodeCursor{LastSeen: last.LastSeen, ID: last.ID}).String() {
t.Fatalf("limit %d page 1 cursors: %+v", limit, page1)
}
for i, n := range page1.Items {
if n.ID != all[i].ID {
t.Fatalf("limit %d page 1 row %d: %s, want %s", limit, i, n.ID, all[i].ID)
}
}
// Legacy cursor: today's page, which skips the rest of the tied millisecond.
legacyNext := legacyNodeRows(t, ctx, tx, legacyListNodesDet, none, lastMS, int32(limit)+1)
gotLegacy := newNodeRows(t, ctx, s, none, lastMS, nil, int32(limit))
if !sameRows(gotLegacy, legacyNext) {
t.Fatalf("limit %d legacy cursor:\n got %v\n want %v", limit, nodeKeys(gotLegacy), nodeKeys(legacyNext))
}
if len(gotLegacy) == 0 || gotLegacy[0].LastSeen.Time.UnixMilli() >= lastMS {
t.Fatalf("limit %d legacy cursor did not skip the tied millisecond", limit)
}
// pageCursor (sent with the legacy cursor, which it overrides): the skipped tied rows, then the legacy page.
pc, err := api.ParseNodeCursor(*page1.NextPageCursor)
if err != nil {
t.Fatal(err)
}
var want []uuid.UUID
for _, r := range all[limit:] {
if r.LastSeen.UnixMilli() != lastMS {
break
}
want = append(want, r.ID)
}
if len(want) == 0 {
t.Fatalf("limit %d: boundary is not inside a tied millisecond", limit)
}
for _, r := range legacyNext {
want = append(want, r.ID)
}
want = want[:min(len(want), limit+1)]
gotPC := newNodeRows(t, ctx, s, none, lastMS, pc, int32(limit))
gotIDs := make([]uuid.UUID, len(gotPC))
for i, r := range gotPC {
gotIDs[i] = r.ID
}
if !slices.Equal(gotIDs, want) {
t.Fatalf("limit %d pageCursor page:\n got %v\n want %v", limit, gotIDs, want)
}
}
// A limit-1 walk following nextPageCursor, with nextCursor sent alongside: every node once, in order.
var walked []uuid.UUID
var cursor int64
var pageCursor *api.NodeCursor
for range len(all) + 2 {
page, err := s.ListNodes(ctx, 0, nil, nil, nil, nil, "", "", "", cursor, pageCursor, 1, false)
if err != nil {
t.Fatal(err)
}
for _, n := range page.Items {
walked = append(walked, n.ID)
}
if !page.HasMore {
if page.NextPageCursor != nil || page.NextCursor != nil {
t.Fatal("last page carries a cursor")
}
break
}
cursor = *page.NextCursor
if pageCursor, err = api.ParseNodeCursor(*page.NextPageCursor); err != nil {
t.Fatal(err)
}
}
if !slices.Equal(walked, keysetIDs(all)) {
t.Fatalf("limit-1 walk: %d rows, want %d in (last_seen DESC, id DESC) order", len(walked), len(all))
}
}
// legacyObserverRow is today's ListObservers row.
type legacyObserverRow struct {
ID uuid.UUID
DisplayName *string
ObserverType *string
LastStatusAt pgtype.Timestamptz
RadioFreqMhz *float32
RadioSf *int16
RadioBwKhz *float32
Scopes []string
Status string
Iata string
}
type observerShape struct {
name string
iatas []string
observerType, broker, status, nameF, scope string
}
func observerShapes() []observerShape {
return []observerShape{
{name: "unfiltered"},
{name: "iatas YVR", iatas: []string{"YVR"}},
{name: "type repeater", observerType: "repeater"},
{name: "status online", status: "online"},
{name: "status offline", status: "offline"},
{name: "broker b2", broker: "b2"},
{name: "name obs-1", nameF: "obs-1"},
{name: "scope #test", scope: "#test"},
}
}
func legacyObserverRows(t *testing.T, ctx context.Context, tx pgx.Tx, sql string, sh observerShape, cts pgtype.Timestamptz, pageLimit int32) []legacyObserverRow {
t.Helper()
return legacyRows[legacyObserverRow](t, ctx, tx, sql, sh.iatas, sh.observerType, sh.broker, sh.status, sh.nameF, cts, pageLimit, sh.scope)
}
// newObserverRows runs today's columns of the new statement for one request.
func newObserverRows(t *testing.T, ctx context.Context, s *Store, sh observerShape, cursor int64, pc *api.ObserverCursor, limit int32) []legacyObserverRow {
t.Helper()
rows, err := s.q.ListObservers(ctx, listObserversParams(sh.iatas, sh.observerType, sh.broker, sh.status, sh.nameF, sh.scope, cursor, pc, limit))
if err != nil {
t.Fatalf("%s: %v", sh.name, err)
}
out := make([]legacyObserverRow, len(rows))
for i, r := range rows {
out[i] = legacyObserverRow{r.ID, r.DisplayName, r.ObserverType, r.LastStatusAt, r.RadioFreqMhz, r.RadioSf, r.RadioBwKhz, r.Scopes, r.Status, r.Iata}
}
return out
}
func observerIDs(rows []legacyObserverRow) []uuid.UUID {
out := make([]uuid.UUID, len(rows))
for i, r := range rows {
out[i] = r.ID
}
return out
}
func TestObserversLegacyCursorMatchesTodayPostgres(t *testing.T) {
ctx, tx, s := keysetTx(t)
all := keysetOrder(t, ctx, tx, "SELECT id, last_seen, last_status_at FROM observers ORDER BY last_seen DESC, id DESC")
lastSeen := make(map[uuid.UUID]time.Time, len(all))
for _, r := range all {
lastSeen[r.ID] = r.LastSeen
}
tied := tiedMillis(all)
if len(tied) != 12 {
t.Fatalf("fixture: %d tied milliseconds, want 12", len(tied))
}
cursors := append([]int64{0, -5, all[3].LastSeen.Add(-2500 * time.Millisecond).UnixMilli()}, tied...)
nonEmpty := 0
for _, setting := range planSettings {
mustExec(t, ctx, tx, setting)
for _, sh := range observerShapes() {
for _, c := range cursors {
for _, limit := range []int32{1, 2, 51} {
want := legacyObserverRows(t, ctx, tx, legacyListObserversDet, sh, legacyCursorTS(c), limit+1)
got := newObserverRows(t, ctx, s, sh, c, nil, limit)
if !sameRows(got, want) {
t.Fatalf("%q %s cursor=%d limit=%d:\n got %v\n want %v", setting, sh.name, c, limit, observerIDs(got), observerIDs(want))
}
raw := legacyObserverRows(t, ctx, tx, legacyListObservers, sh, legacyCursorTS(c), limit+1)
seen := func(id uuid.UUID) time.Time { return lastSeen[id] }
if !slices.EqualFunc(observerIDs(got), observerIDs(raw), func(a, b uuid.UUID) bool { return seen(a).Equal(seen(b)) }) {
t.Fatalf("%q %s cursor=%d limit=%d vs raw:\n got %v\n raw %v", setting, sh.name, c, limit, observerIDs(got), observerIDs(raw))
}
if len(got) > 0 {
nonEmpty++
}
}
}
}
}
mustExec(t, ctx, tx, "RESET plan_cache_mode; RESET enable_seqscan")
if nonEmpty < 500 {
t.Fatalf("only %d non-empty comparisons; the fixture does not exercise the statement", nonEmpty)
}
}
// The legacy nextCursor now follows the ORDER BY column and is present whenever hasMore.
func TestObserversNextCursorFromLastSeenPostgres(t *testing.T) {
ctx, tx, s := keysetTx(t)
all := keysetOrder(t, ctx, tx, "SELECT id, last_seen, last_status_at FROM observers ORDER BY last_seen DESC, id DESC")
if len(all) != 120 {
t.Fatalf("fixture: %d observers", len(all))
}
const limit = 7
// Store walk following nextCursor: exactly the rows a "last_seen < ms" walk reaches; the only rows missed
// share the millisecond of a page's last row.
var walked, want []uuid.UUID
var boundaries []int64
var cursor int64
for range len(all) + 2 {
page, err := s.ListObservers(ctx, nil, "", "", "", "", "", cursor, nil, limit)
if err != nil {
t.Fatal(err)
}
for _, o := range page.Items {
walked = append(walked, o.ID)
}
if !page.HasMore {
break
}
if page.NextCursor == nil {
t.Fatal("hasMore without nextCursor")
}
cursor = *page.NextCursor
boundaries = append(boundaries, cursor)
}
for next := int64(0); ; {
var page []keysetRow
for _, r := range all {
if next == 0 || r.LastSeen.Before(time.UnixMilli(next)) {
page = append(page, r)
}
}
if len(page) <= limit {
want = append(want, keysetIDs(page)...)
break
}
want = append(want, keysetIDs(page[:limit])...)
next = page[limit-1].LastSeen.UnixMilli()
}
if !slices.Equal(walked, want) {
t.Fatalf("nextCursor walk:\n got %v\n want %v", walked, want)
}
missed := 0
for _, r := range all {
if !slices.Contains(walked, r.ID) {
missed++
if !slices.Contains(boundaries, r.LastSeen.UnixMilli()) {
t.Errorf("observer %s missed outside a boundary millisecond", r.ID)
}
}
}
if missed == 0 {
t.Fatal("fixture: no page boundary falls inside a tied millisecond")
}
// The pre-change walk (nextCursor = last_status_at, absent when NULL) misses more than half of them.
var before []uuid.UUID
var cts pgtype.Timestamptz
for range len(all) + 2 {
rows := legacyObserverRows(t, ctx, tx, legacyListObservers, observerShape{}, cts, limit+1)
hasMore := len(rows) > limit
if hasMore {
rows = rows[:limit]
}
before = append(before, observerIDs(rows)...)
if !hasMore || !rows[len(rows)-1].LastStatusAt.Valid {
break
}
cts = pgtype.Timestamptz{Time: time.UnixMilli(rows[len(rows)-1].LastStatusAt.Time.UnixMilli()), Valid: true}
}
if len(all)-len(before) <= len(all)/2 {
t.Fatalf("pre-change walk reached %d of %d observers; the fixture does not show the loss", len(before), len(all))
}
// A page whose last row has NULL last_status_at: hasMore with nextCursor from last_seen (was nil).
k := slices.IndexFunc(all, func(r keysetRow) bool { return !r.LastStatusAt.Valid })
if k < 0 || k+1 >= len(all) {
t.Fatal("fixture: no observer with NULL last_status_at before the last row")
}
page, err := s.ListObservers(ctx, nil, "", "", "", "", "", 0, nil, int32(k+1))
if err != nil {
t.Fatal(err)
}
wantPC := (api.ObserverCursor{LastSeen: all[k].LastSeen, ID: all[k].ID}).String()
if !page.HasMore || page.NextCursor == nil || *page.NextCursor != all[k].LastSeen.UnixMilli() ||
page.NextPageCursor == nil || *page.NextPageCursor != wantPC {
t.Fatalf("page ending on NULL last_status_at: hasMore=%t nextCursor=%v nextPageCursor=%v", page.HasMore, page.NextCursor, page.NextPageCursor)
}
// A limit-1 pageCursor walk: all 120 once, in (last_seen DESC, id DESC) order.
var pcWalk []uuid.UUID
var pc *api.ObserverCursor
for range len(all) + 2 {
page, err := s.ListObservers(ctx, nil, "", "", "", "", "", 0, pc, 1)
if err != nil {
t.Fatal(err)
}
for _, o := range page.Items {
pcWalk = append(pcWalk, o.ID)
}
if !page.HasMore {
break
}
if pc, err = api.ParseObserverCursor(*page.NextPageCursor); err != nil {
t.Fatal(err)
}
}
if !slices.Equal(pcWalk, keysetIDs(all)) {
t.Fatalf("limit-1 pageCursor walk: %d rows, want %d in order", len(pcWalk), len(all))
}
}
+86 -10
View File
@@ -6,11 +6,13 @@ package db
import (
"context"
"errors"
"fmt"
"testing"
"time"
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
mockdb "github.com/MeshCore-Beacon/beacon-server/db/sqlc/mock"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/MeshCore-Beacon/beacon-server/internal/ingest"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
@@ -197,7 +199,7 @@ func TestListNodes_Pagination(t *testing.T) {
Return(rows, nil)
store := &Store{q: mock}
page, err := store.ListNodes(context.Background(), 0, []string{"YVR"}, nil, nil, nil, "", "", "", 0, 2, false)
page, err := store.ListNodes(context.Background(), 0, []string{"YVR"}, nil, nil, nil, "", "", "", 0, nil, 2, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -230,7 +232,7 @@ func TestListNodes_IATAsUnmarshal(t *testing.T) {
}, nil)
store := &Store{q: mock}
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, 10, false)
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, nil, 10, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -259,7 +261,7 @@ func TestListNodes_Stale(t *testing.T) {
}, nil)
store := &Store{q: mock, staleThreshold: 24 * time.Hour}
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, 10, false)
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, nil, 10, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -300,7 +302,7 @@ func TestListNodes_RadioStringFormatting(t *testing.T) {
}, nil)
store := &Store{q: mock}
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, 10, false)
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, nil, 10, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -644,8 +646,8 @@ func TestListNodes_IncludeNeighbors_PassesFlagAndMapsIDs(t *testing.T) {
mock.EXPECT().
ListNodes(gomock.Any(), gomock.Eq(sqlc.ListNodesParams{
Column1: int16(0), Column2: nil, Column3: "any", Column4: "any",
Column5: nil, Column6: "", Column7: pgtype.Timestamptz{},
Limit: 11, Column9: "", Column10: true,
Column5: nil, Column6: "", Column7: pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true},
Limit: 11, Column9: "", Column12: uuid.Nil, Column10: true,
})).
Return([]sqlc.ListNodesRow{
{
@@ -656,7 +658,7 @@ func TestListNodes_IncludeNeighbors_PassesFlagAndMapsIDs(t *testing.T) {
}, nil)
store := &Store{q: mock}
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, 10, true)
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, nil, 10, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -674,15 +676,15 @@ func TestListNodes_ExcludeNeighbors_LeavesIDsNil(t *testing.T) {
mock.EXPECT().
ListNodes(gomock.Any(), gomock.Eq(sqlc.ListNodesParams{
Column1: int16(0), Column2: nil, Column3: "any", Column4: "any",
Column5: nil, Column6: "", Column7: pgtype.Timestamptz{},
Limit: 11, Column9: "", Column10: false,
Column5: nil, Column6: "", Column7: pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true},
Limit: 11, Column9: "", Column12: uuid.Nil, Column10: false,
})).
Return([]sqlc.ListNodesRow{
{ID: nodeID, PublicKey: []byte{0x01}, NeighborIds: nil},
}, nil)
store := &Store{q: mock}
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, 10, false)
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, nil, 10, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -706,3 +708,77 @@ func TestDeleteOldNodes(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
// Page 1 starts at infinity, a numeric cursor pairs its millisecond with the nil uuid, and a pageCursor sends its
// exact pair, winning over a numeric cursor sent alongside.
func TestListNodes_CursorParams(t *testing.T) {
const legacy = int64(1792442625019)
pc := &api.NodeCursor{LastSeen: time.UnixMicro(1792442625019123).UTC(), ID: uuid.MustParse("0b0b0b0b-0000-0000-0000-000000000001")}
for _, tc := range []struct {
name string
cursor int64
pc *api.NodeCursor
ts pgtype.Timestamptz
id uuid.UUID
}{
{"page 1", 0, nil, pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true}, uuid.Nil},
{"negative cursor", -5, nil, pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true}, uuid.Nil},
{"legacy cursor", legacy, nil, pgtype.Timestamptz{Time: time.UnixMilli(legacy), Valid: true}, uuid.Nil},
{"pageCursor", 0, pc, pgtype.Timestamptz{Time: pc.LastSeen, Valid: true}, pc.ID},
{"pageCursor wins", legacy, pc, pgtype.Timestamptz{Time: pc.LastSeen, Valid: true}, pc.ID},
} {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
mock := mockdb.NewMockQuerier(ctrl)
mock.EXPECT().
ListNodes(gomock.Any(), gomock.Eq(sqlc.ListNodesParams{
Column1: int16(2), Column2: []string{"YVR"}, Column3: "true", Column4: "any",
Column5: []byte{0x01}, Column6: "n", Column7: tc.ts,
Limit: 6, Column9: "#test", Column10: true, Column11: "ab", Column12: tc.id,
})).
Return(nil, nil)
yes := true
store := &Store{q: mock}
page, err := store.ListNodes(context.Background(), 2, []string{"YVR"}, &yes, nil, []byte{0x01}, "ab", "n", "#test", tc.cursor, tc.pc, 5, true)
if err != nil {
t.Fatal(err)
}
if page.Items == nil || page.HasMore || page.NextCursor != nil || page.NextPageCursor != nil {
t.Fatalf("empty page: %+v", page)
}
})
}
}
func TestListNodes_NextPageCursor(t *testing.T) {
rows := make([]sqlc.ListNodesRow, 3)
for i := range rows {
rows[i] = sqlc.ListNodesRow{
ID: uuid.MustParse(fmt.Sprintf("00000000-0000-0000-0000-00000000000%d", i+1)),
PublicKey: []byte{byte(i)},
LastSeen: pgtype.Timestamptz{Time: time.UnixMicro(1792442625019123 - int64(i)).UTC(), Valid: true},
}
}
for _, limit := range []int32{2, 3} {
ctrl := gomock.NewController(t)
mock := mockdb.NewMockQuerier(ctrl)
mock.EXPECT().ListNodes(gomock.Any(), gomock.Any()).Return(rows, nil)
store := &Store{q: mock}
page, err := store.ListNodes(context.Background(), 0, nil, nil, nil, nil, "", "", "", 0, nil, limit, false)
if err != nil {
t.Fatal(err)
}
if limit == 3 {
if page.HasMore || page.NextCursor != nil || page.NextPageCursor != nil {
t.Fatalf("last page carries a cursor: %+v", page)
}
continue
}
last := rows[1]
want := api.NodeCursor{LastSeen: last.LastSeen.Time, ID: last.ID}.String()
if !page.HasMore || page.NextCursor == nil || *page.NextCursor != last.LastSeen.Time.UnixMilli() ||
page.NextPageCursor == nil || *page.NextPageCursor != want {
t.Fatalf("limit 2: hasMore=%t nextCursor=%v nextPageCursor=%v, want %s", page.HasMore, page.NextCursor, page.NextPageCursor, want)
}
}
}
+1 -1
View File
@@ -114,7 +114,7 @@ FROM observers o`)
t.Errorf("GetObserverLastIATA(no packets) = %q, %v; want empty", got, err)
}
page, err := store.ListObservers(ctx, []string{"YYJ", "YYZ"}, "", "", "", "", "", 0, 10)
page, err := store.ListObservers(ctx, []string{"YYJ", "YYZ"}, "", "", "", "", "", 0, nil, 10)
if err != nil {
t.Fatal(err)
}
+25 -18
View File
@@ -32,12 +32,17 @@ func (s *Store) UpsertObserver(ctx context.Context, pubkey []byte, iata string)
return row.ID, displayName, err
}
func (s *Store) ListObservers(ctx context.Context, iatas []string, observerType, broker, status, name, scope string, cursor int64, limit int32) (api.Page[api.ObserverSummary], error) {
var cursorTS pgtype.Timestamptz
if cursor > 0 {
cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true}
// listObserversParams builds the statement's arguments for one ListObservers request.
func listObserversParams(iatas []string, observerType, broker, status, name, scope string, cursor int64, pageCursor *api.ObserverCursor, limit int32) sqlc.ListObserversParams {
var (
at *time.Time
id uuid.UUID
)
if pageCursor != nil {
at, id = &pageCursor.LastSeen, pageCursor.ID
}
params := sqlc.ListObserversParams{
cursorTS, cursorID := uuidKeysetCursor(cursor, at, id)
return sqlc.ListObserversParams{
Column1: iatas,
Column2: observerType,
Column3: broker,
@@ -46,10 +51,15 @@ func (s *Store) ListObservers(ctx context.Context, iatas []string, observerType,
Column6: cursorTS,
Limit: limit + 1,
Column8: scope,
Column9: cursorID,
}
}
func (s *Store) ListObservers(ctx context.Context, iatas []string, observerType, broker, status, name, scope string, cursor int64, pageCursor *api.ObserverCursor, limit int32) (api.ObserverPage, error) {
params := listObserversParams(iatas, observerType, broker, status, name, scope, cursor, pageCursor, limit)
rows, err := s.q.ListObservers(ctx, params)
if err != nil {
return api.Page[api.ObserverSummary]{}, err
return api.ObserverPage{}, err
}
hasMore := len(rows) > int(limit)
if hasMore {
@@ -75,19 +85,16 @@ func (s *Store) ListObservers(ctx context.Context, iatas []string, observerType,
}
items = append(items, observer)
}
var nextCursor *int64
if hasMore {
// observers use UUID so encode last_seen as cursor
if rows[len(rows)-1].LastStatusAt.Valid {
ms := rows[len(rows)-1].LastStatusAt.Time.UnixMilli()
nextCursor = &ms
}
page := api.ObserverPage{Page: api.Page[api.ObserverSummary]{Items: items, HasMore: hasMore}}
if hasMore && len(rows) > 0 {
// Both cursors follow the ORDER BY column, last_seen.
last := rows[len(rows)-1]
ms := last.LastSeen.Time.UnixMilli()
next := api.ObserverCursor{LastSeen: last.LastSeen.Time, ID: last.ID}.String()
page.NextCursor = &ms
page.NextPageCursor = &next
}
return api.Page[api.ObserverSummary]{
Items: items,
NextCursor: nextCursor,
HasMore: hasMore,
}, nil
return page, nil
}
func (s *Store) GetObserver(ctx context.Context, observerID uuid.UUID) (*api.Observer, error) {
+70 -4
View File
@@ -82,6 +82,7 @@ func TestListObservers_Pagination(t *testing.T) {
rows[i] = sqlc.ListObserversRow{
ID: observerID,
LastStatusAt: lastStatusAt,
LastSeen: lastStatusAt,
}
}
@@ -90,7 +91,7 @@ func TestListObservers_Pagination(t *testing.T) {
Return(rows, nil)
store := &Store{q: mock}
page, err := store.ListObservers(context.Background(), []string{"YVR"}, "", "", "", "", "", 0, 2)
page, err := store.ListObservers(context.Background(), []string{"YVR"}, "", "", "", "", "", 0, nil, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -126,7 +127,7 @@ func TestListObservers_RadioStringFormatting(t *testing.T) {
}, nil)
store := &Store{q: mock}
page, err := store.ListObservers(context.Background(), nil, "", "", "", "", "", 0, 10)
page, err := store.ListObservers(context.Background(), nil, "", "", "", "", "", 0, nil, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -154,7 +155,7 @@ func TestListObservers_NilRadioFields(t *testing.T) {
}, nil)
store := &Store{q: mock}
page, err := store.ListObservers(context.Background(), nil, "", "", "", "", "", 0, 10)
page, err := store.ListObservers(context.Background(), nil, "", "", "", "", "", 0, nil, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -172,7 +173,7 @@ func TestListObservers_DBError(t *testing.T) {
Return(nil, errors.New("db error"))
store := &Store{q: mock}
_, err := store.ListObservers(context.Background(), nil, "", "", "", "", "", 0, 10)
_, err := store.ListObservers(context.Background(), nil, "", "", "", "", "", 0, nil, 10)
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -954,3 +955,68 @@ func TestGetObserverActivity_SinceAlignedToInterval(t *testing.T) {
t.Errorf("expected interval %d us, got %+v", interval.Microseconds(), gotInterval)
}
}
// Page 1 starts at infinity, a numeric cursor pairs its millisecond with the nil uuid, and a pageCursor sends its
// exact pair, winning over a numeric cursor sent alongside.
func TestListObservers_CursorParams(t *testing.T) {
const legacy = int64(1792442625019)
pc := &api.ObserverCursor{LastSeen: time.UnixMicro(1792442625019123).UTC(), ID: uuid.MustParse("0b0b0b0b-0000-0000-0000-000000000001")}
for _, tc := range []struct {
name string
cursor int64
pc *api.ObserverCursor
ts pgtype.Timestamptz
id uuid.UUID
}{
{"page 1", 0, nil, pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true}, uuid.Nil},
{"negative cursor", -5, nil, pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true}, uuid.Nil},
{"legacy cursor", legacy, nil, pgtype.Timestamptz{Time: time.UnixMilli(legacy), Valid: true}, uuid.Nil},
{"pageCursor", 0, pc, pgtype.Timestamptz{Time: pc.LastSeen, Valid: true}, pc.ID},
{"pageCursor wins", legacy, pc, pgtype.Timestamptz{Time: pc.LastSeen, Valid: true}, pc.ID},
} {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
mock := mockdb.NewMockQuerier(ctrl)
mock.EXPECT().
ListObservers(gomock.Any(), gomock.Eq(sqlc.ListObserversParams{
Column1: []string{"YVR"}, Column2: "repeater", Column3: "b1", Column4: "online", Column5: "n",
Column6: tc.ts, Limit: 6, Column8: "#test", Column9: tc.id,
})).
Return(nil, nil)
store := &Store{q: mock}
page, err := store.ListObservers(context.Background(), []string{"YVR"}, "repeater", "b1", "online", "n", "#test", tc.cursor, tc.pc, 5)
if err != nil {
t.Fatal(err)
}
if page.Items == nil || page.HasMore || page.NextCursor != nil || page.NextPageCursor != nil {
t.Fatalf("empty page: %+v", page)
}
})
}
}
// Both cursors come from the last row's last_seen, also when its last_status_at is NULL.
func TestListObservers_NextCursorFromLastSeen(t *testing.T) {
rows := []sqlc.ListObserversRow{
{ID: uuid.MustParse("00000000-0000-0000-0000-000000000001"), LastSeen: pgtype.Timestamptz{Time: time.UnixMicro(1792442625019123).UTC(), Valid: true},
LastStatusAt: pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}},
{ID: uuid.MustParse("00000000-0000-0000-0000-000000000002"), LastSeen: pgtype.Timestamptz{Time: time.UnixMicro(1792442625018456).UTC(), Valid: true}},
{ID: uuid.MustParse("00000000-0000-0000-0000-000000000003"), LastSeen: pgtype.Timestamptz{Time: time.UnixMicro(1792442625017000).UTC(), Valid: true}},
}
for _, limit := range []int32{1, 2} {
ctrl := gomock.NewController(t)
mock := mockdb.NewMockQuerier(ctrl)
mock.EXPECT().ListObservers(gomock.Any(), gomock.Any()).Return(rows[:limit+1], nil)
store := &Store{q: mock}
page, err := store.ListObservers(context.Background(), nil, "", "", "", "", "", 0, nil, limit)
if err != nil {
t.Fatal(err)
}
last := rows[limit-1]
want := api.ObserverCursor{LastSeen: last.LastSeen.Time, ID: last.ID}.String()
if !page.HasMore || page.NextCursor == nil || *page.NextCursor != last.LastSeen.Time.UnixMilli() ||
page.NextPageCursor == nil || *page.NextPageCursor != want {
t.Fatalf("limit %d: hasMore=%t nextCursor=%v nextPageCursor=%v, want %s", limit, page.HasMore, page.NextCursor, page.NextPageCursor, want)
}
}
}
+1 -1
View File
@@ -137,7 +137,7 @@ VALUES ($1,decode($2,'hex'),$3,$4,$5,0,1,0)`, n, fmt.Sprintf("%064x", n), observ
// One page query plus one hash batch and one advert batch.
for _, iatas := range [][]string{nil, {"YYZ", "YVR"}} {
q.calls = 0
page, err := store.ListPackets(ctx, nil, nil, iatas, nil, time.Time{}, time.Time{}, 0, 50)
page, err := store.ListPackets(ctx, nil, nil, iatas, nil, time.Time{}, time.Time{}, 0, nil, 50)
if err != nil || len(page.Items) != 4 || q.calls != 3 {
t.Fatalf("list %v: rows=%d queries=%d err=%v", iatas, len(page.Items), q.calls, err)
}
@@ -78,7 +78,7 @@ FROM packets p WHERE p.packet_hash = $1`, hash).Scan(&stored, &raw); err != nil
t.Errorf("packets.observation_count = %d, raw count = %d; want 2", stored, raw)
}
page, err := store.ListPackets(ctx, nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 10)
page, err := store.ListPackets(ctx, nil, nil, nil, nil, time.Time{}, time.Time{}, 0, nil, 10)
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -110,7 +110,7 @@ FROM (VALUES
}
counter.calls = 0
}
page, err := store.ListPackets(ctx, nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 50)
page, err := store.ListPackets(ctx, nil, nil, nil, nil, time.Time{}, time.Time{}, 0, nil, 50)
if err != nil {
t.Fatal(err)
}
@@ -125,7 +125,7 @@ SELECT id,decode(lpad(to_hex(id),2,'0'),'hex'),'00000000-0000-0000-0000-00000000
t.Fatal(err)
}
for _, iatas := range [][]string{nil, {"YVR"}} {
page, err := store.ListPackets(ctx, nil, nil, iatas, nil, time.Time{}, time.Time{}, 0, 50)
page, err := store.ListPackets(ctx, nil, nil, iatas, nil, time.Time{}, time.Time{}, 0, nil, 50)
if err != nil {
t.Fatal(err)
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/jackc/pgx/v5/pgtype"
)
// Domains enforced by the packets_payload_type_domain and
// packets_route_type_domain check constraints. A value outside them matches no
// row, so dropping it from a filter list cannot change the result.
const (
payloadTypeCount = 16 // payload_type BETWEEN 0 AND 15
routeTypeCount = 4 // route_type BETWEEN 0 AND 3
)
// packetFilter is a global /packets request after normalisation.
type packetFilter struct {
payloadTypes []int16 // deduplicated, in 0..15, order kept; all 16 when the request gave none
routeTypes []int16 // deduplicated, in 0..3, order kept; all 4 when the request gave none
typed bool // the request gave a payload or route type list
scopes []string // deduplicated, order kept, values as given (no trim)
windowed bool // since or until was given
since, until pgtype.Timestamptz // -infinity / infinity when absent
cursorTS pgtype.Timestamptz // infinity on page 1
cursorHash []byte // never nil; empty on page 1 and for a legacy cursor
pageLimit int32 // limit + 1
}
// newPacketFilter normalises a request. ok is false when no row can match: a type list that held values and lost
// all of them to the domain drop. The caller then returns an empty page without a query.
func newPacketFilter(payloadTypes, routeTypes []int16, scopes []string, since, until time.Time, cursor int64, pageCursor *api.PacketCursor, limit int32) (f packetFilter, ok bool) {
var ptOK, rtOK bool
f.payloadTypes, ptOK = normaliseTypes(payloadTypes, payloadTypeCount)
f.routeTypes, rtOK = normaliseTypes(routeTypes, routeTypeCount)
if !ptOK || !rtOK {
return packetFilter{}, false
}
f.typed = len(payloadTypes) > 0 || len(routeTypes) > 0
f.scopes = dedupeStrings(scopes)
// since after until is not short-circuited: the statement compares the encoded values, which wrap for
// out-of-range times exactly as they always have, so it alone decides which rows match.
f.since = pgtype.Timestamptz{InfinityModifier: pgtype.NegativeInfinity, Valid: true}
f.until = pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true}
if !since.IsZero() {
f.since = pgtype.Timestamptz{Time: since, Valid: true}
f.windowed = true
}
if !until.IsZero() {
f.until = pgtype.Timestamptz{Time: until, Valid: true}
f.windowed = true
}
switch {
case pageCursor != nil:
f.cursorTS = pgtype.Timestamptz{Time: pageCursor.At, Valid: true}
f.cursorHash = pageCursor.Hash
case cursor > 0:
// Same encoding as the numeric cursor has always had, including the
// wrap of out-of-range values to an early instant.
f.cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true}
default:
f.cursorTS = pgtype.Timestamptz{InfinityModifier: pgtype.Infinity, Valid: true}
}
if f.cursorHash == nil {
f.cursorHash = []byte{}
}
f.pageLimit = limit + 1
return f, true
}
// normaliseTypes returns the distinct values of in that fall in 0..n-1, in
// their first-seen order, or all of 0..n-1 when in is empty. ok is false when
// in held values and none of them were in range.
func normaliseTypes(in []int16, n int16) (out []int16, ok bool) {
if len(in) == 0 {
out = make([]int16, n)
for i := range out {
out[i] = int16(i)
}
return out, true
}
var seen [16]bool
for _, v := range in {
if v < 0 || v >= n || seen[v] {
continue
}
seen[v] = true
out = append(out, v)
}
return out, len(out) > 0
}
// dedupeStrings returns the distinct values of in, in first-seen order, or nil when
// in is empty.
func dedupeStrings(in []string) []string {
if len(in) == 0 {
return nil
}
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, v := range in {
if _, dup := seen[v]; dup {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
+166
View File
@@ -0,0 +1,166 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"bytes"
"slices"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/jackc/pgx/v5/pgtype"
)
func domain(n int16) []int16 {
out := make([]int16, n)
for i := range out {
out[i] = int16(i)
}
return out
}
func TestPacketFilterNormalisation(t *testing.T) {
full16, full4 := domain(16), domain(4)
var zero time.Time
t0 := time.UnixMilli(1792442625019).UTC()
t1 := t0.Add(time.Minute)
epoch := time.UnixMilli(0)
for _, tc := range []struct {
name string
pt, rt []int16
scopes []string
since, until time.Time
wantOK, wantTyped bool
wantPT, wantRT []int16
wantScopes []string
wantWindowed bool
}{
{"absent", nil, nil, nil, zero, zero, true, false, full16, full4, nil, false},
{"empty after parse", []int16{}, []int16{}, []string{}, zero, zero, true, false, full16, full4, nil, false},
{"dup types", []int16{4, 4}, []int16{0, 0, 1}, nil, zero, zero, true, true, []int16{4}, []int16{0, 1}, nil, false},
{"order kept", []int16{9, 2, 4, 2}, nil, nil, zero, zero, true, true, []int16{9, 2, 4}, full4, nil, false},
{"drop 99", []int16{99, 4}, nil, nil, zero, zero, true, true, []int16{4}, full4, nil, false},
{"drop negative and 16", []int16{-3, 16, 15}, nil, nil, zero, zero, true, true, []int16{15}, full4, nil, false},
{"all dropped", []int16{99}, nil, nil, zero, zero, false, false, nil, nil, nil, false},
{"unknown name", []int16{-1}, nil, nil, zero, zero, false, false, nil, nil, nil, false},
{"route 9", nil, []int16{9}, nil, zero, zero, false, false, nil, nil, nil, false},
{"route 3,7", nil, []int16{3, 7}, nil, zero, zero, true, true, full16, []int16{3}, nil, false},
{"route given payload dropped", []int16{99}, []int16{1}, nil, zero, zero, false, false, nil, nil, nil, false},
{"scope dups kept untrimmed", nil, nil, []string{"#a", " #b", "#a"}, zero, zero, true, false, full16, full4, []string{"#a", " #b"}, false},
{"scope blank singular kept", nil, nil, []string{" "}, zero, zero, true, false, full16, full4, []string{" "}, false},
{"since > until is left to the statement", nil, nil, nil, t1, t0, true, false, full16, full4, nil, true},
{"since = until", nil, nil, nil, t0, t0, true, false, full16, full4, nil, true},
{"since < until", nil, nil, nil, t0, t1, true, false, full16, full4, nil, true},
{"until only", nil, nil, nil, zero, t0, true, false, full16, full4, nil, true},
{"since only", nil, nil, nil, t0, zero, true, false, full16, full4, nil, true},
{"since epoch is a bound", nil, nil, nil, epoch, zero, true, false, full16, full4, nil, true},
{"since epoch > until", nil, nil, nil, epoch, time.UnixMilli(-1), true, false, full16, full4, nil, true},
} {
t.Run(tc.name, func(t *testing.T) {
f, ok := newPacketFilter(tc.pt, tc.rt, tc.scopes, tc.since, tc.until, 0, nil, 50)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
}
if !ok {
return
}
if f.typed != tc.wantTyped {
t.Errorf("typed = %v, want %v", f.typed, tc.wantTyped)
}
if !slices.Equal(f.payloadTypes, tc.wantPT) {
t.Errorf("payloadTypes = %v, want %v", f.payloadTypes, tc.wantPT)
}
if !slices.Equal(f.routeTypes, tc.wantRT) {
t.Errorf("routeTypes = %v, want %v", f.routeTypes, tc.wantRT)
}
if !slices.Equal(f.scopes, tc.wantScopes) {
t.Errorf("scopes = %q, want %q", f.scopes, tc.wantScopes)
}
if f.windowed != tc.wantWindowed {
t.Errorf("windowed = %v, want %v", f.windowed, tc.wantWindowed)
}
checkBound(t, "since", f.since, tc.since, pgtype.NegativeInfinity)
checkBound(t, "until", f.until, tc.until, pgtype.Infinity)
if f.pageLimit != 51 {
t.Errorf("pageLimit = %d, want 51", f.pageLimit)
}
if f.cursorHash == nil {
t.Error("cursorHash is nil")
}
})
}
// The caller's slices are not modified.
in := []int16{4, 4, 99}
newPacketFilter(in, nil, nil, zero, zero, 0, nil, 1)
if !slices.Equal(in, []int16{4, 4, 99}) {
t.Errorf("input mutated: %v", in)
}
}
func checkBound(t *testing.T, name string, got pgtype.Timestamptz, in time.Time, absent pgtype.InfinityModifier) {
t.Helper()
if !got.Valid {
t.Errorf("%s not valid", name)
return
}
if in.IsZero() {
if got.InfinityModifier != absent {
t.Errorf("%s = %+v, want infinity modifier %v", name, got, absent)
}
return
}
if got.InfinityModifier != pgtype.Finite || !got.Time.Equal(in) {
t.Errorf("%s = %+v, want %v", name, got, in)
}
}
func TestPacketFilterCursor(t *testing.T) {
var zero time.Time
at := time.UnixMicro(1792442625019123).UTC()
hash := []byte{0xde, 0xad, 0xbe, 0xef}
for _, tc := range []struct {
name string
cursor int64
pc *api.PacketCursor
wantInf bool
wantTime time.Time
wantHash []byte
}{
{"page 1", 0, nil, true, zero, []byte{}},
{"negative cursor", -5, nil, true, zero, []byte{}},
{"legacy cursor", 1792442625019, nil, false, time.UnixMilli(1792442625019), []byte{}},
{"legacy max int64 wraps as today", 9223372036854775807, nil, false, time.UnixMilli(9223372036854775807), []byte{}},
{"pageCursor", 0, &api.PacketCursor{At: at, Hash: hash}, false, at, hash},
{"pageCursor wins over cursor", 5, &api.PacketCursor{At: at, Hash: hash}, false, at, hash},
{"pageCursor with nil hash", 0, &api.PacketCursor{At: at}, false, at, []byte{}},
} {
t.Run(tc.name, func(t *testing.T) {
f, ok := newPacketFilter(nil, nil, nil, zero, zero, tc.cursor, tc.pc, 100)
if !ok {
t.Fatal("ok = false")
}
if f.cursorHash == nil {
t.Fatal("cursorHash is nil")
}
if !bytes.Equal(f.cursorHash, tc.wantHash) {
t.Errorf("cursorHash = %x, want %x", f.cursorHash, tc.wantHash)
}
if !f.cursorTS.Valid {
t.Fatal("cursorTS not valid")
}
if tc.wantInf {
if f.cursorTS.InfinityModifier != pgtype.Infinity {
t.Errorf("cursorTS = %+v, want infinity", f.cursorTS)
}
} else if f.cursorTS.InfinityModifier != pgtype.Finite || !f.cursorTS.Time.Equal(tc.wantTime) {
t.Errorf("cursorTS = %+v, want %v", f.cursorTS, tc.wantTime)
}
if f.pageLimit != 101 {
t.Errorf("pageLimit = %d, want 101", f.pageLimit)
}
})
}
}
+83 -33
View File
@@ -83,38 +83,97 @@ func buildLatestObserverPath(pathLengthByte, hashSize, hopCount *int16, pathByte
return pathLength, pathBytesHex
}
func (s *Store) ListPackets(ctx context.Context, payloadTypes, routeTypes []int16, iatas []string, scopes []string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) {
// packetRow is the shared row of the six global packets statements; the other five Row types convert to it.
type packetRow = sqlc.ListPacketsPageRow
// ListPackets serves the packet list. Region (iatas) requests keep their site-local statement and cursor and ignore
// pageCursor. Every other request is normalised and sent to the one statement for its filter shape; pageCursor wins
// over cursor there.
func (s *Store) ListPackets(ctx context.Context, payloadTypes, routeTypes []int16, iatas []string, scopes []string, since, until time.Time, cursor int64, pageCursor *api.PacketCursor, limit int32) (api.PacketPage, error) {
if len(iatas) > 0 {
return s.listPacketsByIATAs(ctx, payloadTypes, routeTypes, iatas, scopes, since, until, cursor, limit)
p, err := s.listPacketsByIATAs(ctx, payloadTypes, routeTypes, iatas, scopes, since, until, cursor, limit)
return api.PacketPage{Page: p}, err
}
var cursorTS pgtype.Timestamptz
if cursor > 0 {
cursorTS = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true}
f, ok := newPacketFilter(payloadTypes, routeTypes, scopes, since, until, cursor, pageCursor, limit)
if !ok {
return api.PacketPage{Page: api.Page[api.PacketSummary]{Items: []api.PacketSummary{}}}, nil
}
var sinceTS pgtype.Timestamptz
if !since.IsZero() {
sinceTS = pgtype.Timestamptz{Time: since, Valid: true}
}
var untilTS pgtype.Timestamptz
if !until.IsZero() {
untilTS = pgtype.Timestamptz{Time: until, Valid: true}
}
rows, err := s.q.ListPackets(ctx, sqlc.ListPacketsParams{
Column1: payloadTypes,
Column2: routeTypes,
Column3: sinceTS,
Column4: untilTS,
Column5: cursorTS,
Limit: limit + 1,
Column7: scopes,
})
rows, err := s.listPacketRows(ctx, f)
if err != nil {
return api.Page[api.PacketSummary]{}, err
return api.PacketPage{}, err
}
hasMore := len(rows) > int(limit)
if hasMore {
rows = rows[:limit]
}
page := api.PacketPage{Page: api.Page[api.PacketSummary]{
Items: s.packetSummaries(ctx, rows),
HasMore: hasMore,
}}
if hasMore && len(rows) > 0 {
last := rows[len(rows)-1]
ms := last.LastHeardAt.Time.UnixMilli()
next := api.PacketCursor{At: last.LastHeardAt.Time, Hash: last.PacketHash}.String()
page.NextCursor = &ms
page.NextPageCursor = &next
}
return page, nil
}
// listPacketRows runs the statement for f's filter shape and returns up to f.pageLimit rows in
// (last_heard_at DESC, packet_hash DESC) order.
func (s *Store) listPacketRows(ctx context.Context, f packetFilter) ([]packetRow, error) {
switch {
case f.windowed && len(f.scopes) > 0:
rows, err := s.q.ListPacketsWindowScoped(ctx, sqlc.ListPacketsWindowScopedParams{
SinceTs: f.since, CursorTs: f.cursorTS, CursorHash: f.cursorHash, UntilTs: f.until,
PayloadTypes: f.payloadTypes, RouteTypes: f.routeTypes, PageLimit: f.pageLimit, ScopeNames: f.scopes,
})
return convertPacketRows(rows, err, func(r sqlc.ListPacketsWindowScopedRow) packetRow { return packetRow(r) })
case f.windowed:
rows, err := s.q.ListPacketsWindow(ctx, sqlc.ListPacketsWindowParams{
SinceTs: f.since, CursorTs: f.cursorTS, CursorHash: f.cursorHash, UntilTs: f.until,
PayloadTypes: f.payloadTypes, RouteTypes: f.routeTypes, PageLimit: f.pageLimit,
})
return convertPacketRows(rows, err, func(r sqlc.ListPacketsWindowRow) packetRow { return packetRow(r) })
case len(f.scopes) > 0 && f.typed:
rows, err := s.q.ListPacketsByScopesTypes(ctx, sqlc.ListPacketsByScopesTypesParams{
CursorTs: f.cursorTS, CursorHash: f.cursorHash, PayloadTypes: f.payloadTypes, RouteTypes: f.routeTypes,
PageLimit: f.pageLimit, ScopeNames: f.scopes,
})
return convertPacketRows(rows, err, func(r sqlc.ListPacketsByScopesTypesRow) packetRow { return packetRow(r) })
case len(f.scopes) > 0:
rows, err := s.q.ListPacketsByScopes(ctx, sqlc.ListPacketsByScopesParams{
CursorTs: f.cursorTS, CursorHash: f.cursorHash, PageLimit: f.pageLimit, ScopeNames: f.scopes,
})
return convertPacketRows(rows, err, func(r sqlc.ListPacketsByScopesRow) packetRow { return packetRow(r) })
case f.typed:
rows, err := s.q.ListPacketsByTypes(ctx, sqlc.ListPacketsByTypesParams{
PayloadTypes: f.payloadTypes, RouteTypes: f.routeTypes, CursorTs: f.cursorTS, CursorHash: f.cursorHash,
PageLimit: f.pageLimit,
})
return convertPacketRows(rows, err, func(r sqlc.ListPacketsByTypesRow) packetRow { return packetRow(r) })
}
return s.q.ListPacketsPage(ctx, sqlc.ListPacketsPageParams{
CursorTs: f.cursorTS, CursorHash: f.cursorHash, PageLimit: f.pageLimit,
})
}
// convertPacketRows converts one statement's rows to packetRow with conv, a plain Go conversion (the Row types
// share every field name and type).
func convertPacketRows[R any](rows []R, err error, conv func(R) packetRow) ([]packetRow, error) {
if err != nil {
return nil, err
}
out := make([]packetRow, len(rows))
for i, r := range rows {
out[i] = conv(r)
}
return out, nil
}
// packetSummaries builds the API items for global packet rows and resolves their endpoints.
func (s *Store) packetSummaries(ctx context.Context, rows []packetRow) []api.PacketSummary {
items := make([]api.PacketSummary, 0, len(rows))
lookups := make([]endpointLookup, 0, len(rows))
for _, v := range rows {
@@ -148,16 +207,7 @@ func (s *Store) ListPackets(ctx context.Context, payloadTypes, routeTypes []int1
lookups = append(lookups, lookup)
}
s.fillLatestEndpoints(ctx, items, lookups)
var nextCursor *int64
if hasMore && len(items) > 0 {
last := items[len(items)-1].LastHeardAt
nextCursor = &last
}
return api.Page[api.PacketSummary]{
Items: items,
NextCursor: nextCursor,
HasMore: hasMore,
}, nil
return items
}
func (s *Store) listPacketsByIATAs(ctx context.Context, payloadTypes, routeTypes []int16, iatas []string, scopes []string, since, until time.Time, cursor int64, limit int32) (api.Page[api.PacketSummary], error) {
+403
View File
@@ -0,0 +1,403 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"bytes"
"context"
"crypto/md5"
"fmt"
"math"
"slices"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// packetFixture: scopes #a, #b, #c and #empty (no packets); 600 packets over 40 tied milliseconds with
// microsecond-tie groups inside each, mixed types, a quarter unscoped, first_heard_at up to 6 s before
// last_heard_at; 21 packets (i % 29 = 1) with first_heard_at 300 µs after last_heard_at; two observations per
// packet on distinct observers, and for the 10 packets with i % 60 = 0 both observations share the newest heard_at.
const packetFixture = `
INSERT INTO transport_scopes (id, name, transport_key, key_fingerprint) VALUES
(1, '#a', '\x01', '\x01'), (2, '#b', '\x02', '\x02'), (3, '#c', '\x03', '\x03'), (4, '#empty', '\x04', '\x04');
INSERT INTO observers (id, public_key, display_name)
SELECT ('00000000-0000-0000-0000-00000000030' || n)::uuid, int2send(n::int2), 'obs' || n FROM generate_series(1, 4) n;
INSERT INTO packets (packet_hash, payload_type, payload_version, route_type, scope_id, raw_payload, raw_header,
parsed_payload, first_heard_at, last_heard_at, observation_count)
SELECT decode(md5(i::text), 'hex'), (i * 7) % 16, 0, (i * 3) % 4, CASE WHEN i % 4 = 0 THEN NULL ELSE 1 + i % 3 END,
'\x00', '\x00',
CASE WHEN (i * 7) % 16 = 4 THEN jsonb_build_object('appData', jsonb_build_object('name', 'n' || i)) END,
CASE WHEN i % 29 = 1 THEN x.l + interval '300 microseconds' ELSE x.l - (i % 7) * interval '1 second' END,
x.l, 2
FROM generate_series(1, 600) i,
LATERAL (SELECT '2026-01-10 00:00:00+00'::timestamptz + (i % 40) * interval '1 millisecond'
+ (i % 3) * interval '1 microsecond') x(l);
INSERT INTO packet_observations (packet_hash, observer_id, iata, heard_at, path_length_byte, hash_size, hop_count, path_bytes)
SELECT packet_hash, ('00000000-0000-0000-0000-00000000030' || (1 + i % 4))::uuid, 'YVR',
last_heard_at - CASE WHEN i % 60 = 0 THEN interval '0' ELSE interval '1 millisecond' END, 65, 1, 1, '\x01'
FROM (SELECT i, decode(md5(i::text), 'hex') AS packet_hash FROM generate_series(1, 600) i) g JOIN packets USING (packet_hash);
INSERT INTO packet_observations (packet_hash, observer_id, iata, heard_at, path_length_byte, hash_size, hop_count, path_bytes)
SELECT packet_hash, ('00000000-0000-0000-0000-00000000030' || (1 + (i + 1) % 4))::uuid, 'YYZ', last_heard_at, 66, 1, 2, '\x0203'
FROM (SELECT i, decode(md5(i::text), 'hex') AS packet_hash FROM generate_series(1, 600) i) g JOIN packets USING (packet_hash);
ANALYZE packets;`
var packetBase = time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
// fixtureHash is packet i's hash in packetFixture.
func fixtureHash(i int) []byte {
h := md5.Sum(fmt.Appendf(nil, "%d", i))
return h[:]
}
// packetsGlobalTx is a rolled-back transaction holding the end-state schema and packetFixture.
func packetsGlobalTx(t *testing.T) (context.Context, pgx.Tx, *Store) {
t.Helper()
ctx, tx := retentionTx(t)
applyBaseline(t, ctx, tx)
applyPacketsEndState(t, ctx, tx)
mustExec(t, ctx, tx, packetFixture)
return ctx, tx, routeStore(tx)
}
type packetShape struct {
name string
pt, rt []int16
scopes []string
since, until time.Time
}
func optTS(t time.Time) pgtype.Timestamptz {
if t.IsZero() {
return pgtype.Timestamptz{}
}
return pgtype.Timestamptz{Time: t, Valid: true}
}
// legacyPacketRows runs one of today's statements with the arguments today's Store passed it.
func legacyPacketRows(t *testing.T, ctx context.Context, tx pgx.Tx, sql string, sh packetShape, cursor int64, pageLimit int32) []packetRow {
t.Helper()
var cts pgtype.Timestamptz
if cursor > 0 {
cts = pgtype.Timestamptz{Time: time.UnixMilli(cursor), Valid: true}
}
return legacyRows[packetRow](t, ctx, tx, sql, sh.pt, sh.rt, optTS(sh.since), optTS(sh.until), cts, pageLimit, sh.scopes)
}
// newPacketRows runs the dispatcher's core for one request; no rows when the filter cannot match.
func newPacketRows(t *testing.T, ctx context.Context, s *Store, sh packetShape, cursor int64, pc *api.PacketCursor, limit int32) []packetRow {
t.Helper()
f, ok := newPacketFilter(sh.pt, sh.rt, sh.scopes, sh.since, sh.until, cursor, pc, limit)
if !ok {
return nil
}
rows, err := s.listPacketRows(ctx, f)
if err != nil {
t.Fatalf("%s: %v", sh.name, err)
}
return rows
}
func packetKeys(rows []packetRow) []string {
out := make([]string, len(rows))
for i, r := range rows {
out[i] = fmt.Sprintf("%s/%x", r.LastHeardAt.Time.Format("15:04:05.000000"), r.PacketHash[:3])
}
return out
}
// sameKeyRuns reports whether got and raw have the same last_heard_at sequence and, for every run of equal
// last_heard_at, the same hash set. A run the LIMIT cut (the last one of a full page) is compared by length only.
func sameKeyRuns(got, raw []packetRow, full bool) bool {
if len(got) != len(raw) {
return false
}
for i := range got {
if !got[i].LastHeardAt.Time.Equal(raw[i].LastHeardAt.Time) {
return false
}
}
for i := 0; i < len(got); {
j := i
for j < len(got) && got[j].LastHeardAt.Time.Equal(got[i].LastHeardAt.Time) {
j++
}
if j < len(got) || !full {
a, b := hashSet(got[i:j]), hashSet(raw[i:j])
if !slices.Equal(a, b) {
return false
}
}
i = j
}
return true
}
func hashSet(rows []packetRow) []string {
out := make([]string, len(rows))
for i, r := range rows {
out[i] = string(r.PacketHash)
}
slices.Sort(out)
return out
}
func globalShapes() []packetShape {
bogus := make([]string, 5000)
for i := range bogus {
bogus[i] = fmt.Sprintf("#bogus%d", i)
}
all16x4 := packetShape{name: "all 16x4 explicit"}
for i := int16(0); i < 16; i++ {
all16x4.pt = append(all16x4.pt, i)
}
all16x4.rt = []int16{0, 1, 2, 3}
oneFirst := packetBase.Add(7*time.Millisecond + time.Microsecond) // packet 7: first_heard_at = last_heard_at
return []packetShape{
{name: "unfiltered"},
{name: "pt 4", pt: []int16{4}},
{name: "pt 4,4 rt 0,0,1", pt: []int16{4, 4}, rt: []int16{0, 0, 1}},
{name: "pt 99,4", pt: []int16{99, 4}},
{name: "pt 99", pt: []int16{99}},
{name: "pt empty", pt: []int16{}},
{name: "rt 3", rt: []int16{3}},
{name: "rt 3,7", rt: []int16{3, 7}},
{name: "web 13 types", pt: []int16{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 15}},
all16x4,
{name: "scopes #a", scopes: []string{"#a"}},
{name: "scopes #a,#b,#a,#nope", scopes: []string{"#a", "#b", "#a", "#nope"}},
{name: "scopes #empty", scopes: []string{"#empty"}},
{name: "scopes #nope", scopes: []string{"#nope"}},
{name: "scopes untrimmed", scopes: []string{" #a"}},
{name: "scopes #a,#c pt fib", scopes: []string{"#a", "#c"}, pt: []int16{1, 2, 3, 5, 8, 13}},
{name: "window since until", since: packetBase.Add(-3 * time.Second), until: packetBase.Add(20 * time.Millisecond)},
{name: "window since only", since: packetBase.Add(14*time.Millisecond + 200*time.Microsecond)},
{name: "window since early", since: packetBase.Add(-4 * time.Second)},
{name: "window until only", until: packetBase.Add(-2 * time.Second)},
{name: "window typed", since: packetBase.Add(-5 * time.Second), pt: []int16{4, 9}, rt: []int16{1, 2}},
{name: "window scoped", since: packetBase.Add(-5 * time.Second), scopes: []string{"#a", "#b"}},
{name: "window since = until", since: oneFirst, until: oneFirst},
{name: "window since > until", since: oneFirst, until: oneFirst.Add(-time.Microsecond)},
{name: "window since > until by ms", since: packetBase.Add(20 * time.Millisecond), until: packetBase.Add(10 * time.Millisecond)},
{name: "window since max int64", since: time.UnixMilli(math.MaxInt64), until: packetBase.Add(20 * time.Millisecond)},
{name: "window until max int64", since: packetBase.Add(-5 * time.Second), until: time.UnixMilli(math.MaxInt64)},
{name: "5000 bogus scopes", scopes: bogus},
}
}
// Shapes × cursors × limits × both plan modes: rows equal today's statement with the tiebreak, in order, on all
// columns; against today's raw statement the key sequence and the set per key run are equal (tie order).
func TestListPacketsGlobalMatchesLegacyPostgres(t *testing.T) {
ctx, tx, s := packetsGlobalTx(t)
cursors := []int64{
0, -5,
packetBase.Add(17 * time.Millisecond).UnixMilli(), // a tied millisecond
packetBase.Add(30 * time.Millisecond).UnixMilli(), // just above a millisecond of microsecond-tie groups
packetBase.Add(-time.Millisecond).UnixMilli(),
packetBase.Add(40 * time.Millisecond).UnixMilli(),
math.MaxInt64,
}
nonEmpty := 0
for _, mode := range []string{"force_custom_plan", "force_generic_plan"} {
mustExec(t, ctx, tx, "SET LOCAL plan_cache_mode = "+mode)
for _, sh := range globalShapes() {
for _, c := range cursors {
for _, limit := range []int32{1, 2, 3, 7, 51} {
want := legacyPacketRows(t, ctx, tx, legacyListPacketsDet, sh, c, limit+1)
got := newPacketRows(t, ctx, s, sh, c, nil, limit)
if !sameRows(got, want) {
t.Fatalf("%s %s cursor=%d limit=%d:\n got %v\n want %v", mode, sh.name, c, limit, packetKeys(got), packetKeys(want))
}
raw := legacyPacketRows(t, ctx, tx, legacyListPackets, sh, c, limit+1)
if !sameKeyRuns(got, raw, len(raw) == int(limit)+1) {
t.Fatalf("%s %s cursor=%d limit=%d vs raw:\n got %v\n raw %v", mode, sh.name, c, limit, packetKeys(got), packetKeys(raw))
}
if len(got) > 0 {
nonEmpty++
}
}
}
}
}
// since = MaxInt64 ms wraps when encoded and today returns rows; a time-based since > until check would not.
wrapped := packetShape{since: time.UnixMilli(math.MaxInt64), until: packetBase.Add(20 * time.Millisecond)}
if len(legacyPacketRows(t, ctx, tx, legacyListPackets, wrapped, 0, 100000)) == 0 {
t.Fatal("fixture: the wrapped since returns no rows today, so the shape pins nothing")
}
if nonEmpty < 500 {
t.Fatalf("only %d non-empty comparisons; the fixture does not exercise the statements", nonEmpty)
}
}
// Three or more packets in one millisecond at a page boundary.
func TestPacketsGlobalCursorTiesPostgres(t *testing.T) {
ctx, tx, s := packetsGlobalTx(t)
for _, sh := range []packetShape{
{name: "unfiltered"},
{name: "pt 4", pt: []int16{4}},
{name: "scopes #a", scopes: []string{"#a"}},
{name: "window", since: packetBase.Add(-4 * time.Second)},
} {
all := legacyPacketRows(t, ctx, tx, legacyListPacketsDet, sh, 0, 100000)
// Smallest page that ends inside a millisecond with at least one more row after the boundary.
limit := 0
for i := 1; i < len(all); i++ {
if all[i].LastHeardAt.Time.UnixMilli() == all[i-1].LastHeardAt.Time.UnixMilli() && i >= 2 {
limit = i
break
}
}
if limit == 0 {
t.Fatalf("%s: no tied millisecond in %d rows", sh.name, len(all))
}
page1 := newPacketRows(t, ctx, s, sh, 0, nil, int32(limit))
if !sameRows(page1, all[:limit+1]) {
t.Fatalf("%s page 1: %v", sh.name, packetKeys(page1))
}
last := page1[limit-1]
lastMS := last.LastHeardAt.Time.UnixMilli()
// Legacy cursor: today's page, which skips the rest of the tied millisecond.
legacyNext := legacyPacketRows(t, ctx, tx, legacyListPacketsDet, sh, lastMS, int32(limit)+1)
gotLegacy := newPacketRows(t, ctx, s, sh, lastMS, nil, int32(limit))
if !sameRows(gotLegacy, legacyNext) {
t.Fatalf("%s legacy cursor:\n got %v\n want %v", sh.name, packetKeys(gotLegacy), packetKeys(legacyNext))
}
if len(gotLegacy) > 0 && gotLegacy[0].LastHeardAt.Time.UnixMilli() >= lastMS {
t.Fatalf("%s legacy cursor did not skip the tied millisecond", sh.name)
}
// pageCursor: exactly the skipped tied rows, then the legacy page's rows.
var skipped []packetRow
for _, r := range all[limit:] {
if r.LastHeardAt.Time.UnixMilli() != lastMS {
break
}
skipped = append(skipped, r)
}
if len(skipped) == 0 {
t.Fatalf("%s: boundary is not inside a tied millisecond", sh.name)
}
want := append(slices.Clone(skipped), legacyNext...)
if len(want) > limit+1 {
want = want[:limit+1]
}
gotPC := newPacketRows(t, ctx, s, sh, 0, &api.PacketCursor{At: last.LastHeardAt.Time, Hash: last.PacketHash}, int32(limit))
if !sameRows(gotPC, want) {
t.Fatalf("%s pageCursor:\n got %v\n want %v", sh.name, packetKeys(gotPC), packetKeys(want))
}
// A limit-1 pageCursor walk returns every row once, in order. A legacy cursor passed alongside is ignored.
var walked []packetRow
var pc *api.PacketCursor
for range len(all) + 2 {
var page []packetRow
if pc == nil {
page = newPacketRows(t, ctx, s, sh, 0, nil, 1)
} else {
page = newPacketRows(t, ctx, s, sh, lastMS, pc, 1)
}
if len(page) == 0 {
break
}
walked = append(walked, page[0])
pc = &api.PacketCursor{At: page[0].LastHeardAt.Time, Hash: page[0].PacketHash}
}
if !sameRows(walked, all) {
t.Fatalf("%s limit-1 walk: %d rows, want %d", sh.name, len(walked), len(all))
}
}
}
// An inverted row inside [since, until] appears once, at its last_heard_at position, also when only the inverted
// branch can reach it.
func TestListPacketsWindowInvertedRowsPostgres(t *testing.T) {
ctx, tx, s := packetsGlobalTx(t)
since := packetBase.Add(14*time.Millisecond + 200*time.Microsecond)
inverted := fixtureHash(494) // last_heard_at base+14ms+2µs < since <= first_heard_at = last_heard_at + 300µs
var lha, fha time.Time
if err := tx.QueryRow(ctx, `SELECT last_heard_at, first_heard_at FROM packets WHERE packet_hash = $1`, inverted).Scan(&lha, &fha); err != nil {
t.Fatal(err)
}
if !lha.Before(since) || fha.Before(since) {
t.Fatalf("fixture: packet 494 last %v first %v, since %v", lha, fha, since)
}
for _, sh := range []packetShape{
{name: "since", since: since},
{name: "since scoped", since: since, scopes: []string{"#a", "#b", "#c"}},
} {
want := legacyPacketRows(t, ctx, tx, legacyListPacketsDet, sh, 0, 100000)
for _, limit := range []int32{1, 3, 100000 - 1} {
got := newPacketRows(t, ctx, s, sh, 0, nil, limit)
if !sameRows(got, want[:min(len(want), int(limit)+1)]) {
t.Fatalf("%s limit %d:\n got %v\n want %v", sh.name, limit, packetKeys(got), packetKeys(want))
}
}
n := 0
for _, r := range want {
if bytes.Equal(r.PacketHash, inverted) {
n++
}
}
if n != 1 {
t.Fatalf("%s: inverted packet appears %d times", sh.name, n)
}
pc := &api.PacketCursor{At: lha.Add(time.Microsecond), Hash: inverted}
got := newPacketRows(t, ctx, s, sh, 0, pc, 3)
if len(got) == 0 || !bytes.Equal(got[0].PacketHash, inverted) {
t.Fatalf("%s pageCursor just above the inverted row: %v", sh.name, packetKeys(got))
}
}
for _, sh := range []packetShape{
{name: "until only", until: packetBase.Add(-2 * time.Second)},
{name: "since only", since: since},
{name: "since = until", since: fha, until: fha},
} {
want := legacyPacketRows(t, ctx, tx, legacyListPacketsDet, sh, 0, 100000)
got := newPacketRows(t, ctx, s, sh, 0, nil, 100000-1)
if len(want) == 0 || !sameRows(got, want) {
t.Fatalf("%s:\n got %v\n want %v", sh.name, packetKeys(got), packetKeys(want))
}
}
}
// Ties on the newest heard_at resolve to the highest observation id.
func TestPacketsLatestObserverTiePostgres(t *testing.T) {
ctx, tx, s := packetsGlobalTx(t)
rows := newPacketRows(t, ctx, s, packetShape{name: "all"}, 0, nil, 1000)
byHash := map[string]packetRow{}
for _, r := range rows {
byHash[string(r.PacketHash)] = r
}
checked := 0
for i := 60; i <= 600; i += 60 {
h := fixtureHash(i)
var want uuid.UUID
var tied int
if err := tx.QueryRow(ctx, `
SELECT (SELECT observer_id FROM packet_observations WHERE packet_hash = $1 ORDER BY heard_at DESC, id DESC LIMIT 1),
(SELECT count(*) FROM packet_observations WHERE packet_hash = $1
AND heard_at = (SELECT max(heard_at) FROM packet_observations WHERE packet_hash = $1))::int`, h).Scan(&want, &tied); err != nil {
t.Fatal(err)
}
if tied != 2 {
t.Fatalf("packet %d: %d observations on the newest heard_at, want 2", i, tied)
}
r, ok := byHash[string(h)]
if !ok {
t.Fatalf("packet %d missing", i)
}
if r.LatestObserverID != want {
t.Errorf("packet %d: latest observer %v, want %v (highest id)", i, r.LatestObserverID, want)
}
checked++
}
if checked != 10 {
t.Fatalf("checked %d packets, want 10", checked)
}
}
+2 -2
View File
@@ -43,7 +43,7 @@ VALUES ('\x01', 4, 0, 1, '\x00', '\x00', NOW(), NOW())`)
t.Fatal(err)
}
store := &Store{q: sqlc.New(tx)}
page, err := store.ListPackets(ctx, nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 50)
page, err := store.ListPackets(ctx, nil, nil, nil, nil, time.Time{}, time.Time{}, 0, nil, 50)
if err != nil {
t.Fatalf("packet without observation must remain readable: %v", err)
}
@@ -62,7 +62,7 @@ UPDATE packets SET observation_count = 1 WHERE packet_hash = '\x01';`)
t.Fatal(err)
}
for _, iatas := range [][]string{nil, {"YVR"}} {
page, err := store.ListPackets(ctx, nil, nil, iatas, nil, time.Time{}, time.Time{}, 0, 50)
page, err := store.ListPackets(ctx, nil, nil, iatas, nil, time.Time{}, time.Time{}, 0, nil, 50)
if err != nil {
t.Fatal(err)
}
+176
View File
@@ -0,0 +1,176 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import "strings"
// legacyListPackets is the pre-change global packets statement, verbatim. Args: $1 payload_types, $2 route_types,
// $3 since, $4 until, $5 cursor (all timestamptz, NULL when absent), $6 limit, $7 scopes.
const legacyListPackets = `-- name: ListPackets :many
SELECT
p.packet_hash,
p.payload_type,
COALESCE(CASE
WHEN p.payload_type = 4 AND jsonb_typeof(p.parsed_payload #> '{appData,name}') = 'string'
THEN p.parsed_payload #>> '{appData,name}'
WHEN p.payload_type = 3 AND p.parsed_payload ->> 'type' = 'ACK'
AND jsonb_typeof(p.parsed_payload -> 'checksum') = 'string'
AND p.parsed_payload ->> 'checksum' ~ '^[0-9a-fA-F]{8}$'
THEN 'ACK ' || lower(p.parsed_payload ->> 'checksum')
WHEN p.payload_type = 9 AND p.parsed_payload ->> 'type' IN ('TRACE', 'PING')
AND jsonb_typeof(p.parsed_payload -> 'traceTag') = 'string'
AND p.parsed_payload ->> 'traceTag' ~ '^[0-9a-fA-F]{8}$'
THEN (p.parsed_payload ->> 'type') || ' ' || lower(p.parsed_payload ->> 'traceTag')
END, '')::text AS summary,
p.route_type,
p.first_heard_at,
p.last_heard_at,
p.scope_id,
ts.name AS scope_name,
p.observation_count AS observation_count,
-- sqlc loses LATERAL nullability; these scalar defaults are ignored when observer_id is NULL.
po.observer_id AS latest_observer_id,
o.display_name AS latest_observer_name,
COALESCE(po.iata, ''::bpchar) AS latest_observer_iata,
COALESCE(po.path_length_byte, 0::smallint) AS latest_observer_path_length_byte,
COALESCE(po.hash_size, 0::smallint) AS latest_observer_hash_size,
COALESCE(po.hop_count, 0::smallint) AS latest_observer_hop_count,
po.path_bytes AS latest_observer_path_bytes,
p.raw_payload,
p.origin_pubkey
FROM packets p
LEFT JOIN LATERAL (
SELECT observer_id, iata, path_length_byte, hash_size, hop_count, path_bytes
FROM packet_observations
WHERE packet_hash = p.packet_hash
ORDER BY heard_at DESC
LIMIT 1
) po ON true
LEFT JOIN observers o ON o.id = po.observer_id
LEFT JOIN transport_scopes ts ON ts.id = p.scope_id
WHERE
(COALESCE(cardinality($1::smallint[]), 0) = 0 OR p.payload_type = ANY($1::smallint[]))
AND (COALESCE(cardinality($2::smallint[]), 0) = 0 OR p.route_type = ANY($2::smallint[]))
AND ($3::timestamptz IS NULL OR p.first_heard_at >= $3)
AND ($4::timestamptz IS NULL OR p.first_heard_at <= $4)
AND ($5::timestamptz IS NULL OR p.last_heard_at < $5)
AND (COALESCE(cardinality($7::text[]), 0) = 0 OR ts.name = ANY($7::text[]))
ORDER BY p.last_heard_at DESC
LIMIT $6
`
// legacyListPacketsDet is today's statement plus the two tiebreaks only.
var legacyListPacketsDet = strings.NewReplacer(
"ORDER BY p.last_heard_at DESC\nLIMIT $6", "ORDER BY p.last_heard_at DESC, p.packet_hash DESC\nLIMIT $6",
"ORDER BY heard_at DESC\n LIMIT 1", "ORDER BY heard_at DESC, id DESC\n LIMIT 1",
).Replace(legacyListPackets)
func init() {
if !strings.Contains(legacyListPacketsDet, "p.last_heard_at DESC, p.packet_hash DESC\nLIMIT $6") ||
!strings.Contains(legacyListPacketsDet, "heard_at DESC, id DESC\n LIMIT 1") {
panic("legacyListPacketsDet: a tiebreak replacement did not apply")
}
if strings.Count(legacyListNodesDet, "n.last_seen DESC, n.id DESC\n") != 2 {
panic("legacyListNodesDet: a tiebreak replacement did not apply")
}
if !strings.Contains(legacyListObserversDet, "o.last_seen DESC, o.id DESC\nLIMIT $7") {
panic("legacyListObserversDet: the tiebreak replacement did not apply")
}
}
// legacyListNodes is the pre-change ListNodes statement, verbatim. Args: $1 node_type, $2 iatas, $3 paths tristate,
// $4 traces tristate, $5 pubkey, $6 name, $7 cursor (NULL when absent), $8 limit, $9 scope, $10 neighbors,
// $11 pubkey prefix.
const legacyListNodes = `-- name: ListNodes :many
WITH page AS (
SELECT n.id, n.public_key, n.node_type, n.name, n.latitude, n.longitude, n.last_seen,
n.radio_freq_mhz, n.radio_sf, n.radio_bw_khz, ts.name AS default_scope_name
FROM nodes n
LEFT JOIN transport_scopes ts ON ts.id = n.default_scope_id
WHERE
($1 = 0 OR n.node_type = $1)
AND (COALESCE(cardinality($2::bpchar[]), 0) = 0 OR n.id IN (SELECT node_id FROM node_iatas WHERE iata = ANY($2::bpchar[])))
AND (
$3::text = 'any'
OR ($3::text = 'true' AND n.supports_multibyte_paths = TRUE)
OR ($3::text = 'false' AND n.supports_multibyte_paths = FALSE)
)
AND (
$4::text = 'any'
OR ($4::text = 'true' AND n.supports_multibyte_traces = TRUE)
OR ($4::text = 'false' AND n.supports_multibyte_traces = FALSE)
)
AND ($5::bytea IS NULL OR n.public_key = $5)
AND ($6 = '' OR n.name ILIKE '%' || $6 || '%')
AND ($7::timestamptz IS NULL OR n.last_seen < $7)
AND ($9::text = '' OR ts.name = $9::text)
AND ($11::text = '' OR encode(n.public_key, 'hex') ILIKE $11 || '%')
ORDER BY n.last_seen DESC
LIMIT $8
)
SELECT n.id, n.public_key, n.node_type, n.name, n.latitude, n.longitude, n.last_seen,
n.radio_freq_mhz, n.radio_sf, n.radio_bw_khz, n.default_scope_name,
(SELECT json_agg(json_build_object('iata', ni.iata, 'lastHeard', (extract(epoch from ni.last_heard) * 1000)::bigint) ORDER BY ni.last_heard DESC)
FROM node_iatas ni WHERE ni.node_id = n.id) AS iatas,
EXISTS (SELECT 1 FROM observers o WHERE o.public_key = n.public_key) AS is_observer,
(SELECT o.id FROM observers o WHERE o.public_key = n.public_key LIMIT 1) AS observer_id,
(SELECT COUNT(DISTINCT nn.neighbor_id) FROM node_neighbors nn WHERE nn.node_id = n.id)::bigint AS known_neighbor_count,
(CASE WHEN $10::bool THEN
(SELECT COALESCE(array_agg(DISTINCT nn.neighbor_id), '{}'::uuid[]) FROM node_neighbors nn WHERE nn.node_id = n.id)
ELSE NULL END)::uuid[] AS neighbor_ids
FROM page n
ORDER BY n.last_seen DESC
`
// legacyListNodesDet is today's ListNodes plus the id tiebreak in both ORDER BYs.
var legacyListNodesDet = strings.NewReplacer(
"ORDER BY n.last_seen DESC\nLIMIT $8", "ORDER BY n.last_seen DESC, n.id DESC\nLIMIT $8",
"FROM page n\nORDER BY n.last_seen DESC\n", "FROM page n\nORDER BY n.last_seen DESC, n.id DESC\n",
).Replace(legacyListNodes)
// legacyListObservers is the pre-change ListObservers statement, verbatim. Args: $1 iatas, $2 type, $3 broker,
// $4 status, $5 name, $6 cursor (NULL when absent), $7 limit, $8 scope.
const legacyListObservers = `-- name: ListObservers :many
SELECT
o.id,
o.display_name,
o.observer_type,
o.last_status_at,
o.radio_freq_mhz,
o.radio_sf,
o.radio_bw_khz,
array_remove(array_agg(DISTINCT ts.name ORDER BY ts.name), NULL)::text[] AS scopes,
COALESCE(CASE
WHEN GREATEST(COALESCE(o.last_status_at, o.last_seen), o.last_seen) > NOW() - INTERVAL '5 minutes' THEN 'online'
ELSE 'offline'
END, 'offline')::text AS status,
COALESCE(o.last_iata, '')::text AS iata
FROM observers o
LEFT JOIN observer_brokers ob ON ob.observer_id = o.id
LEFT JOIN observer_scopes os ON os.observer_id = o.id
LEFT JOIN transport_scopes ts ON ts.id = os.scope_id
WHERE
(COALESCE(cardinality($1::bpchar[]), 0) = 0 OR o.last_iata = ANY($1::bpchar[]))
AND ($2 = '' OR o.observer_type = $2)
AND ($3 = '' OR ob.broker_name = $3)
AND ($4 = '' OR CASE
WHEN GREATEST(COALESCE(o.last_status_at, o.last_seen), o.last_seen) > NOW() - INTERVAL '5 minutes' THEN 'online'
ELSE 'offline'
END = $4)
AND ($5 = '' OR o.display_name ILIKE '%' || $5 || '%')
AND ($6::timestamptz IS NULL OR o.last_seen < $6)
AND ($8::text = '' OR EXISTS (
SELECT 1 FROM observer_scopes os2
JOIN transport_scopes ts2 ON ts2.id = os2.scope_id
WHERE os2.observer_id = o.id AND ts2.name = $8::text
))
GROUP BY o.id
ORDER BY o.last_seen DESC
LIMIT $7
`
// legacyListObserversDet is today's ListObservers plus the id tiebreak.
var legacyListObserversDet = strings.NewReplacer(
"ORDER BY o.last_seen DESC\nLIMIT $7", "ORDER BY o.last_seen DESC, o.id DESC\nLIMIT $7",
).Replace(legacyListObservers)
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"context"
"errors"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// packetsEndStateMigrations are the packet type domains, row-cursor indexes and the inverted-heard statistics.
var packetsEndStateMigrations = []string{
"025_packets_payload_type_domain.sql",
"026_validate_packets_payload_type_domain.sql",
"027_packets_route_type_domain.sql",
"028_validate_packets_route_type_domain.sql",
"029_packets_last_heard_hash.sql",
"030_packets_type_last_heard.sql",
"031_packets_scope_last_heard.sql",
"032_packets_heard_inverted.sql",
"033_packets_heard_inverted_stat.sql",
}
// packetsEndStateIndexes are the indexes packetsEndStateMigrations build.
var packetsEndStateIndexes = []string{
"idx_packets_last_heard_hash",
"idx_packets_type_last_heard",
"idx_packets_scope_last_heard",
"idx_packets_heard_inverted",
}
// applyPacketsEndState applies packetsEndStateMigrations inside tx.
func applyPacketsEndState(t *testing.T, ctx context.Context, tx pgx.Tx) {
t.Helper()
applyMigrationsTx(t, ctx, tx, packetsEndStateMigrations...)
}
// The domain checks are validated and the four packet indexes and the statistics object exist and are usable.
func TestPacketsDomainChecksPostgres(t *testing.T) {
ctx, pool := schemaPool(t)
if err := RunMigrations(ctx, pool); err != nil {
t.Fatal(err)
}
want := map[string]string{
"packets_payload_type_domain": "CHECK (((payload_type >= 0) AND (payload_type <= 15)))",
"packets_route_type_domain": "CHECK (((route_type >= 0) AND (route_type <= 3)))",
}
rows, err := pool.Query(ctx, `SELECT conname::text, convalidated, pg_get_constraintdef(oid)
FROM pg_constraint WHERE conrelid = 'packets'::regclass AND contype = 'c'`)
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for rows.Next() {
var name, def string
var validated bool
if err := rows.Scan(&name, &validated, &def); err != nil {
t.Fatal(err)
}
if !validated {
t.Errorf("%s not validated", name)
}
got[name] = def
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if len(got) != len(want) {
t.Fatalf("packets checks = %v, want %v", got, want)
}
for name, def := range want {
if got[name] != def {
t.Errorf("%s = %q, want %q", name, got[name], def)
}
}
for _, idx := range packetsEndStateIndexes {
var usable bool
if err := pool.QueryRow(ctx, `SELECT i.indisvalid AND i.indisready FROM pg_index i
WHERE i.indexrelid = to_regclass($1)`, idx).Scan(&usable); err != nil {
t.Fatalf("%s: %v", idx, err)
}
if !usable {
t.Errorf("%s not valid and ready", idx)
}
}
var def string
if err := pool.QueryRow(ctx, "SELECT pg_get_indexdef('idx_packets_heard_inverted'::regclass)").Scan(&def); err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(def, "WHERE ((first_heard_at > last_heard_at) IS TRUE)") {
t.Errorf("idx_packets_heard_inverted = %q", def)
}
var stats int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM pg_statistic_ext WHERE stxname = 'packets_heard_inverted_stat' AND stxrelid = 'packets'::regclass").Scan(&stats); err != nil {
t.Fatal(err)
}
if stats != 1 {
t.Errorf("packets_heard_inverted_stat rows = %d, want 1", stats)
}
for _, tc := range []struct {
name string
payloadType, routeType int
}{
{"payload_type 16", 16, 0},
{"route_type 4", 0, 4},
} {
_, err := pool.Exec(ctx, `INSERT INTO packets (packet_hash, payload_type, payload_version, route_type, raw_payload, raw_header, first_heard_at, last_heard_at)
VALUES ('\x01', $1, 0, $2, '\x', '\x', now(), now())`, tc.payloadType, tc.routeType)
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != "23514" {
t.Errorf("%s: err = %v, want SQLSTATE 23514", tc.name, err)
}
}
}
+253 -20
View File
@@ -4,12 +4,15 @@
package db
import (
"bytes"
"context"
"slices"
"testing"
"time"
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
mockdb "github.com/MeshCore-Beacon/beacon-server/db/sqlc/mock"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/MeshCore-Beacon/beacon-server/internal/ingest"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
@@ -65,9 +68,9 @@ func TestListPackets_Pagination(t *testing.T) {
mock := mockdb.NewMockQuerier(ctrl)
heardAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}
rows := make([]sqlc.ListPacketsRow, 3)
rows := make([]sqlc.ListPacketsPageRow, 3)
for i := range rows {
rows[i] = sqlc.ListPacketsRow{
rows[i] = sqlc.ListPacketsPageRow{
PacketHash: []byte{0xde, 0xad},
FirstHeardAt: heardAt,
LastHeardAt: heardAt,
@@ -75,11 +78,11 @@ func TestListPackets_Pagination(t *testing.T) {
}
mock.EXPECT().
ListPackets(gomock.Any(), gomock.Any()).
ListPacketsPage(gomock.Any(), gomock.Any()).
Return(rows, nil)
store := &Store{q: mock}
page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 2)
page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, nil, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -101,8 +104,8 @@ func TestListPackets_LatestObserverNil(t *testing.T) {
heardAt := pgtype.Timestamptz{Time: time.UnixMilli(1700000000000), Valid: true}
mock.EXPECT().
ListPackets(gomock.Any(), gomock.Any()).
Return([]sqlc.ListPacketsRow{
ListPacketsPage(gomock.Any(), gomock.Any()).
Return([]sqlc.ListPacketsPageRow{
{
PacketHash: []byte{0xde, 0xad},
FirstHeardAt: heardAt,
@@ -112,7 +115,7 @@ func TestListPackets_LatestObserverNil(t *testing.T) {
}, nil)
store := &Store{q: mock}
page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 10)
page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, nil, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -131,8 +134,8 @@ func TestListPackets_LatestObserverSet(t *testing.T) {
observerIATA := "YVR"
mock.EXPECT().
ListPackets(gomock.Any(), gomock.Any()).
Return([]sqlc.ListPacketsRow{
ListPacketsPage(gomock.Any(), gomock.Any()).
Return([]sqlc.ListPacketsPageRow{
{
PacketHash: []byte{0xde, 0xad},
FirstHeardAt: heardAt,
@@ -144,7 +147,7 @@ func TestListPackets_LatestObserverSet(t *testing.T) {
}, nil)
store := &Store{q: mock}
page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 10)
page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, nil, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -168,8 +171,8 @@ func TestListPackets_LatestObserverPathFields(t *testing.T) {
pathBytes := []byte{0xa1, 0xb2}
mock.EXPECT().
ListPackets(gomock.Any(), gomock.Any()).
Return([]sqlc.ListPacketsRow{
ListPacketsPage(gomock.Any(), gomock.Any()).
Return([]sqlc.ListPacketsPageRow{
{
PacketHash: []byte{0xde, 0xad},
FirstHeardAt: heardAt,
@@ -183,7 +186,7 @@ func TestListPackets_LatestObserverPathFields(t *testing.T) {
}, nil)
store := &Store{q: mock}
page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 10)
page, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, nil, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -513,7 +516,7 @@ func TestListPackets_IATAFilterRoutesToObservationIndex(t *testing.T) {
})
store := &Store{q: mock}
page, err := store.ListPackets(context.Background(), nil, nil, []string{"ALF"}, nil, time.Time{}, time.Time{}, 0, 1)
page, err := store.ListPackets(context.Background(), nil, nil, []string{"ALF"}, nil, time.Time{}, time.Time{}, 0, nil, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -555,7 +558,7 @@ func TestListPackets_SaturatedShortPageKeepsPaging(t *testing.T) {
}, nil)
store := &Store{q: mock}
page, err := store.ListPackets(context.Background(), nil, nil, []string{"YOW"}, nil, time.Time{}, time.Time{}, 0, 5)
page, err := store.ListPackets(context.Background(), nil, nil, []string{"YOW"}, nil, time.Time{}, time.Time{}, 0, nil, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -590,7 +593,7 @@ func TestListPackets_CursorClampsToScanFloor(t *testing.T) {
}}, nil)
store := &Store{q: mock}
page, err := store.ListPackets(context.Background(), nil, nil, []string{"YOW", "YYZ"}, nil, time.Time{}, time.Time{}, 0, 5)
page, err := store.ListPackets(context.Background(), nil, nil, []string{"YOW", "YYZ"}, nil, time.Time{}, time.Time{}, 0, nil, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -615,7 +618,7 @@ func TestListPackets_UnsaturatedShortPageEndsPaging(t *testing.T) {
}}, nil)
store := &Store{q: mock}
page, err := store.ListPackets(context.Background(), nil, nil, []string{"YOW"}, nil, time.Time{}, time.Time{}, 0, 5)
page, err := store.ListPackets(context.Background(), nil, nil, []string{"YOW"}, nil, time.Time{}, time.Time{}, 0, nil, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -633,11 +636,11 @@ func TestListPackets_UnfilteredKeepsGlobalQuery(t *testing.T) {
// gomock is strict: an unexpected ListPacketsByIATAs call fails the test.
mock.EXPECT().
ListPackets(gomock.Any(), gomock.Any()).
Return([]sqlc.ListPacketsRow{}, nil)
ListPacketsPage(gomock.Any(), gomock.Any()).
Return([]sqlc.ListPacketsPageRow{}, nil)
store := &Store{q: mock}
if _, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, 50); err != nil {
if _, err := store.ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, nil, 50); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
@@ -661,3 +664,233 @@ func TestDeleteOldPackets_LoopsUntilShortBatch(t *testing.T) {
t.Fatal(err)
}
}
// Each request shape runs exactly one statement (gomock fails on any other call), and a filter no row can match
// runs none and answers an empty page.
func TestListPacketsDispatch(t *testing.T) {
since := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
allPayload := []int16{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
cases := []struct {
name string
pt, rt []int16
iatas []string
scopes []string
since, until time.Time
expect func(m *mockdb.MockQuerier)
}{
{name: "unfiltered", expect: func(m *mockdb.MockQuerier) {
m.EXPECT().ListPacketsPage(gomock.Any(), gomock.Any()).Return(nil, nil)
}},
{name: "payload types", pt: []int16{4}, expect: func(m *mockdb.MockQuerier) {
m.EXPECT().ListPacketsByTypes(gomock.Any(), gomock.Cond(func(p sqlc.ListPacketsByTypesParams) bool {
return slices.Equal(p.PayloadTypes, []int16{4}) && slices.Equal(p.RouteTypes, []int16{0, 1, 2, 3})
})).Return(nil, nil)
}},
{name: "route types only", rt: []int16{3}, expect: func(m *mockdb.MockQuerier) {
m.EXPECT().ListPacketsByTypes(gomock.Any(), gomock.Cond(func(p sqlc.ListPacketsByTypesParams) bool {
return slices.Equal(p.PayloadTypes, allPayload) && slices.Equal(p.RouteTypes, []int16{3})
})).Return(nil, nil)
}},
{name: "scopes", scopes: []string{"#a"}, expect: func(m *mockdb.MockQuerier) {
m.EXPECT().ListPacketsByScopes(gomock.Any(), gomock.Cond(func(p sqlc.ListPacketsByScopesParams) bool {
return slices.Equal(p.ScopeNames, []string{"#a"})
})).Return(nil, nil)
}},
{name: "scopes and types", pt: []int16{4}, scopes: []string{"#a"}, expect: func(m *mockdb.MockQuerier) {
m.EXPECT().ListPacketsByScopesTypes(gomock.Any(), gomock.Any()).Return(nil, nil)
}},
{name: "since", since: since, expect: func(m *mockdb.MockQuerier) {
m.EXPECT().ListPacketsWindow(gomock.Any(), gomock.Cond(func(p sqlc.ListPacketsWindowParams) bool {
return p.SinceTs.Time.Equal(since) && p.UntilTs.InfinityModifier == pgtype.Infinity
})).Return(nil, nil)
}},
{name: "since and scopes", since: since, scopes: []string{"#a"}, expect: func(m *mockdb.MockQuerier) {
m.EXPECT().ListPacketsWindowScoped(gomock.Any(), gomock.Any()).Return(nil, nil)
}},
{name: "iatas", pt: []int16{4}, iatas: []string{"YVR"}, scopes: []string{"#a"}, since: since, expect: func(m *mockdb.MockQuerier) {
m.EXPECT().ListPacketsByIATAs(gomock.Any(), sqlc.ListPacketsByIATAsParams{
Iatas: []string{"YVR"},
ScanDepth: (7 + 1) * 8,
PayloadTypes: []int16{4},
SinceTs: pgtype.Timestamptz{Time: since, Valid: true},
ScopeNames: []string{"#a"},
PageLimit: 7 + 1,
}).Return(nil, nil)
}},
{name: "unknown payload type", pt: []int16{99}, expect: func(*mockdb.MockQuerier) {}},
{name: "since after until", since: since, until: since.Add(-time.Millisecond), expect: func(m *mockdb.MockQuerier) {
m.EXPECT().ListPacketsWindow(gomock.Any(), gomock.Any()).Return(nil, nil)
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
mock := mockdb.NewMockQuerier(ctrl)
tc.expect(mock)
store := &Store{q: mock}
page, err := store.ListPackets(context.Background(), tc.pt, tc.rt, tc.iatas, tc.scopes, tc.since, tc.until, 0, nil, 7)
if err != nil {
t.Fatal(err)
}
if page.Items == nil {
t.Error("Items is nil, want an empty slice")
}
if len(page.Items) != 0 || page.HasMore || page.NextCursor != nil || page.NextPageCursor != nil {
t.Errorf("page = %+v, want empty", page)
}
})
}
}
// pgx sends a nil []byte as NULL, and (k, h) < (ts, NULL) matches no row: every statement must get a non-nil hash.
func TestListPackets_CursorHashNeverNil(t *testing.T) {
since := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
shapes := []struct {
name string
pt []int16
scopes []string
since time.Time
}{
{name: "page"},
{name: "types", pt: []int16{4}},
{name: "scopes", scopes: []string{"#a"}},
{name: "scopes types", pt: []int16{4}, scopes: []string{"#a"}},
{name: "window", since: since},
{name: "window scoped", since: since, scopes: []string{"#a"}},
}
cursors := []struct {
name string
cursor int64
pageCursor *api.PacketCursor
}{
{name: "first page"},
{name: "legacy cursor", cursor: 1792442625019},
{name: "pageCursor", pageCursor: &api.PacketCursor{At: since, Hash: []byte{0xab}}},
}
for _, sh := range shapes {
for _, cur := range cursors {
t.Run(sh.name+"/"+cur.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
mock := mockdb.NewMockQuerier(ctrl)
var hash []byte
mock.EXPECT().ListPacketsPage(gomock.Any(), gomock.Any()).AnyTimes().
DoAndReturn(func(_ context.Context, p sqlc.ListPacketsPageParams) ([]sqlc.ListPacketsPageRow, error) {
hash = p.CursorHash
return nil, nil
})
mock.EXPECT().ListPacketsByTypes(gomock.Any(), gomock.Any()).AnyTimes().
DoAndReturn(func(_ context.Context, p sqlc.ListPacketsByTypesParams) ([]sqlc.ListPacketsByTypesRow, error) {
hash = p.CursorHash
return nil, nil
})
mock.EXPECT().ListPacketsByScopes(gomock.Any(), gomock.Any()).AnyTimes().
DoAndReturn(func(_ context.Context, p sqlc.ListPacketsByScopesParams) ([]sqlc.ListPacketsByScopesRow, error) {
hash = p.CursorHash
return nil, nil
})
mock.EXPECT().ListPacketsByScopesTypes(gomock.Any(), gomock.Any()).AnyTimes().
DoAndReturn(func(_ context.Context, p sqlc.ListPacketsByScopesTypesParams) ([]sqlc.ListPacketsByScopesTypesRow, error) {
hash = p.CursorHash
return nil, nil
})
mock.EXPECT().ListPacketsWindow(gomock.Any(), gomock.Any()).AnyTimes().
DoAndReturn(func(_ context.Context, p sqlc.ListPacketsWindowParams) ([]sqlc.ListPacketsWindowRow, error) {
hash = p.CursorHash
return nil, nil
})
mock.EXPECT().ListPacketsWindowScoped(gomock.Any(), gomock.Any()).AnyTimes().
DoAndReturn(func(_ context.Context, p sqlc.ListPacketsWindowScopedParams) ([]sqlc.ListPacketsWindowScopedRow, error) {
hash = p.CursorHash
return nil, nil
})
store := &Store{q: mock}
if _, err := store.ListPackets(context.Background(), sh.pt, nil, nil, sh.scopes, sh.since, time.Time{}, cur.cursor, cur.pageCursor, 50); err != nil {
t.Fatal(err)
}
if hash == nil {
t.Fatal("CursorHash is nil")
}
if cur.pageCursor != nil && !bytes.Equal(hash, cur.pageCursor.Hash) {
t.Errorf("CursorHash = %x, want %x", hash, cur.pageCursor.Hash)
}
})
}
}
}
// A full page carries both cursors from its last row; the final page carries neither.
func TestListPackets_NextPageCursor(t *testing.T) {
base := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
rows := []sqlc.ListPacketsPageRow{
{PacketHash: []byte{0x03}, LastHeardAt: pgtype.Timestamptz{Time: base.Add(3*time.Millisecond + 7*time.Microsecond), Valid: true}},
{PacketHash: []byte{0x02, 0xff}, LastHeardAt: pgtype.Timestamptz{Time: base.Add(2*time.Millisecond + 5*time.Microsecond), Valid: true}},
{PacketHash: []byte{0x01}, LastHeardAt: pgtype.Timestamptz{Time: base.Add(time.Millisecond), Valid: true}},
}
run := func(t *testing.T, rows []sqlc.ListPacketsPageRow) api.PacketPage {
t.Helper()
ctrl := gomock.NewController(t)
mock := mockdb.NewMockQuerier(ctrl)
mock.EXPECT().ListPacketsPage(gomock.Any(), gomock.Any()).Return(rows, nil)
page, err := (&Store{q: mock}).ListPackets(context.Background(), nil, nil, nil, nil, time.Time{}, time.Time{}, 0, nil, 2)
if err != nil {
t.Fatal(err)
}
return page
}
t.Run("full page", func(t *testing.T) {
page := run(t, rows)
last := rows[1]
wantPC := api.PacketCursor{At: last.LastHeardAt.Time, Hash: last.PacketHash}.String()
if len(page.Items) != 2 || !page.HasMore {
t.Fatalf("items %d, hasMore %v; want 2, true", len(page.Items), page.HasMore)
}
if page.NextCursor == nil || *page.NextCursor != last.LastHeardAt.Time.UnixMilli() {
t.Errorf("NextCursor = %v, want %d", page.NextCursor, last.LastHeardAt.Time.UnixMilli())
}
if page.NextPageCursor == nil || *page.NextPageCursor != wantPC {
t.Errorf("NextPageCursor = %v, want %s", page.NextPageCursor, wantPC)
}
})
t.Run("last page", func(t *testing.T) {
page := run(t, rows[:2])
if len(page.Items) != 2 || page.HasMore || page.NextCursor != nil || page.NextPageCursor != nil {
t.Errorf("page = %+v, want 2 items and no cursors", page)
}
})
}
// The region path keeps today's numeric cursor, saturation padding and scan-floor clamp, and emits no pageCursor.
func TestListPackets_RegionPathIgnoresPageCursor(t *testing.T) {
ctrl := gomock.NewController(t)
mock := mockdb.NewMockQuerier(ctrl)
const cursor = int64(1792442625019)
oldest := time.Date(2026, 8, 7, 12, 57, 25, 0, time.UTC)
floor := oldest.Add(30 * time.Second)
mock.EXPECT().
ListPacketsByIATAs(gomock.Any(), gomock.Cond(func(p sqlc.ListPacketsByIATAsParams) bool {
return p.CursorTs.Valid && p.CursorTs.Time.Equal(time.UnixMilli(cursor)) && slices.Equal(p.Iatas, []string{"YVR"})
})).
Return([]sqlc.ListPacketsByIATAsRow{{
PacketHash: []byte{0x01},
SiteHeardAt: pgtype.Timestamptz{Time: oldest, Valid: true},
ScanSaturated: true,
ScanFloor: pgtype.Timestamptz{Time: floor, Valid: true},
}}, nil)
pc := &api.PacketCursor{At: time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC), Hash: []byte{0xab}}
page, err := (&Store{q: mock}).ListPackets(context.Background(), nil, nil, []string{"YVR"}, nil, time.Time{}, time.Time{}, cursor, pc, 5)
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || !page.HasMore {
t.Fatalf("items %d, hasMore %v; want 1, true", len(page.Items), page.HasMore)
}
if page.NextCursor == nil || *page.NextCursor != floor.UnixMilli() {
t.Errorf("NextCursor = %v, want the scan floor %d", page.NextCursor, floor.UnixMilli())
}
if page.NextPageCursor != nil {
t.Errorf("NextPageCursor = %q, want nil", *page.NextPageCursor)
}
}
@@ -0,0 +1,16 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,16 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,18 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,18 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,18 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,18 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,18 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,18 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,18 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,18 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,18 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -1,5 +1,4 @@
[
"Limit",
" Incremental Sort",
" Index Scan on packets vp using idx_packets_last_heard scan=Backward"
" Index Only Scan on packets vp using idx_packets_last_heard_hash scan=Backward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Seq Scan on transport_scopes s",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Seq Scan on transport_scopes s",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Seq Scan on transport_scopes s",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Seq Scan on transport_scopes s",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Seq Scan on transport_scopes s",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Seq Scan on transport_scopes s",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Seq Scan on transport_scopes s",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Seq Scan on transport_scopes s",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Seq Scan on transport_scopes s",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Seq Scan on transport_scopes s",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Seq Scan on transport_scopes s",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Seq Scan on transport_scopes s",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,17 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,18 @@
[
"Sort",
" Limit [InitPlan]",
" Index Only Scan on packets p_1 using idx_packets_last_heard_hash scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,20 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,20 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,21 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Index Scan on transport_scopes s using transport_scopes_name_key scan=Forward",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_scope_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,27 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_type_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,27 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_type_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,28 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_type_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,28 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Unique",
" Sort",
" ProjectSet",
" Result",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_type_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,28 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Unique",
" Sort",
" ProjectSet",
" Result",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_type_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,28 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_type_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,27 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_type_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,27 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_type_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,28 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_type_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,27 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_type_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,27 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_type_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
@@ -0,0 +1,28 @@
[
"Sort",
" Limit [InitPlan]",
" Sort",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Nested Loop",
" Aggregate partial=Simple {Hashed}",
" ProjectSet",
" Result",
" Limit",
" Index Only Scan on packets p_1 using idx_packets_type_last_heard scan=Forward",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop (Left)",
" Nested Loop",
" CTE Scan",
" Index Scan on packets p using packets_pkey scan=Forward",
" Limit",
" Sort",
" Index Scan on packet_observations using idx_observations_packet scan=Forward",
" Memoize",
" Index Scan on observers o using observers_pkey scan=Forward",
" Memoize",
" Index Scan on transport_scopes ts using transport_scopes_pkey scan=Forward"
]
Loaded 100 of 404 files, more files were not shown because too many files have changed in this diff. Show more