mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-22 22:21:27 +00:00
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:
1 parent
94b4b40f9b
commit
41baee9197
404 files changed
+13030
-1017
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user