Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions gtfsdb/block_trip_entry_index_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package gtfsdb

import (
"database/sql"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// GetBlocksForBlockTripIndexIDs joins block_trip_entry to itself on block_id. Without
// an index there, SQLite scans every block entry for every route trip, which made
// trips-for-route take seconds on NJ Transit's 33k-trip feed.
func TestBlockTripEntryBlockIDJoinUsesIndex(t *testing.T) {
client := newTestClientWithRABA(t)
ctx := t.Context()

var routeID, serviceID string
var indexID int64
require.NoError(t, client.DB.QueryRowContext(ctx, `
SELECT trips.route_id, bte.service_id, bte.block_trip_index_id
FROM block_trip_entry bte JOIN trips ON trips.id = bte.trip_id
WHERE bte.block_id IS NOT NULL LIMIT 1`).Scan(&routeID, &serviceID, &indexID))

recorder := &queryRecorder{DBTX: client.DB}
_, err := New(recorder).GetBlocksForBlockTripIndexIDs(ctx, GetBlocksForBlockTripIndexIDsParams{
FromTime: sql.NullInt64{Int64: 0, Valid: true},
ToTime: sql.NullInt64{Int64: int64(24 * time.Hour), Valid: true},
RouteID: routeID,
IndexIds: []int64{indexID},
ActiveServiceIds: []string{serviceID},
RouteServiceIds: []string{serviceID},
})
require.NoError(t, err)

// The planner may start the self-join from either side, so accept the index
// on either alias.
plan := strings.Join(explainRecordedQuery(t, client, recorder), "\n")
assert.Regexp(t, `SEARCH \w+ USING (COVERING )?INDEX idx_block_trip_entry_block_id \(block_id=\?`, plan)
}
41 changes: 41 additions & 0 deletions gtfsdb/query_plan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package gtfsdb

import (
"context"
"database/sql"
"testing"

"github.com/stretchr/testify/require"
)

// queryRecorder captures the SQL and arguments a generated query method sends,
// so a test can explain the real query rather than a copy of it.
type queryRecorder struct {
DBTX
query string
args []any
}

func (db *queryRecorder) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
db.query, db.args = query, args
return db.DBTX.QueryContext(ctx, query, args...)
}

// explainRecordedQuery returns the detail column of each EXPLAIN QUERY PLAN row
// for the query the recorder last captured.
func explainRecordedQuery(t *testing.T, client *Client, recorder *queryRecorder) []string {
t.Helper()
rows, err := client.DB.QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+recorder.query, recorder.args...)
require.NoError(t, err)
defer func() { _ = rows.Close() }()

var details []string
for rows.Next() {
var id, parent, unused int
var detail string
require.NoError(t, rows.Scan(&id, &parent, &unused, &detail))
details = append(details, detail)
}
require.NoError(t, rows.Err())
return details
}
5 changes: 5 additions & 0 deletions gtfsdb/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,11 @@ CREATE INDEX IF NOT EXISTS idx_block_trip_index_service_ids ON block_trip_index
-- migrate
CREATE INDEX IF NOT EXISTS idx_block_trip_entry_index_id ON block_trip_entry (block_trip_index_id);

-- migrate
-- GetBlocksForBlockTripIndexIDs (a route's active blocks, for trips-for-route)
-- and GetBlockTripIndexIDsForBlocks both match block_trip_entry rows by block_id.
CREATE INDEX IF NOT EXISTS idx_block_trip_entry_block_id ON block_trip_entry (block_id);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two optional follow-ups, not blockers:

  1. Composite index. Both queries that match on block_id also filter on service_id: GetBlocksForBlockTripIndexIDs (route_bte.service_id / active_bte.service_id IN ...) and GetBlockTripIndexIDsForBlocks (bte.block_id IN ... AND bte.service_id IN ...). An index on (block_id, service_id) would do that filtering inside the index rather than reading each table row. The comment above also only names the first query, so the second one's dependence on this index isn't written down.
  2. Planner statistics. Maglev never runs ANALYZE / PRAGMA optimize after a static import, so SQLite picks join orders without statistics. On a feed where one service_id covers most trips, as on NJ Transit, the plan can still start from active_bte via idx_block_trip_entry_service_id and read most active entries before narrowing to the route. Running PRAGMA optimize once after each import would help every multi-join query, not just this one. That probably belongs in a separate issue.

Generated by Claude Code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5c1e5ae fixes the comment to name both queries. On RABA without statistics, GetBlockTripIndexIDsForBlocks actually plans through idx_block_trip_entry_service_id today, so whether (block_id, service_id) wins depends on planner stats. I'd do PRAGMA optimize after import as a follow-up first, then revisit the composite index.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed as #1971 (PRAGMA optimize first, then the composite index).


-- migrate
CREATE INDEX IF NOT EXISTS idx_block_trip_entry_trip_id ON block_trip_entry (trip_id);

Expand Down
23 changes: 2 additions & 21 deletions gtfsdb/stops_rtree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package gtfsdb

import (
"context"
"database/sql"
"strings"
"testing"

Expand Down Expand Up @@ -47,44 +46,26 @@ func TestGetActiveRoutesWithinBounds_CenteredOnStop(t *testing.T) {
assert.ElementsMatch(t, []string{"15", "154", "159", "160", "44X"}, stop4000RouteIDs)
}

type routeQueryRecorder struct {
DBTX
query string
args []any
}

func (db *routeQueryRecorder) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
db.query, db.args = query, args
return db.DBTX.QueryContext(ctx, query, args...)
}

func TestGetActiveRoutesWithinBounds_FilteredStopTimesLookup(t *testing.T) {
client := newTestClientWithRABA(t)
ctx := t.Context()
var routeID string
require.NoError(t, client.DB.QueryRowContext(ctx, "SELECT id FROM routes LIMIT 1").Scan(&routeID))

recorder := &routeQueryRecorder{DBTX: client.DB}
recorder := &queryRecorder{DBTX: client.DB}
_, err := New(recorder).GetActiveRoutesWithinBounds(ctx, GetActiveRoutesWithinBoundsParams{
Lat: 40.7347, Lon: -74.1644,
MinLat: -90, MaxLat: 90, MinLon: -180, MaxLon: 180,
RouteIDs: []string{routeID}, MaxCount: 100,
})
require.NoError(t, err)
plan, err := client.DB.QueryContext(ctx, "EXPLAIN QUERY PLAN "+recorder.query, recorder.args...)
require.NoError(t, err)
defer func() { _ = plan.Close() }()

var stopTimeLookups []string
for plan.Next() {
var id, parent, unused int
var detail string
require.NoError(t, plan.Scan(&id, &parent, &unused, &detail))
for _, detail := range explainRecordedQuery(t, client, recorder) {
if strings.Contains(detail, "stop_times") {
stopTimeLookups = append(stopTimeLookups, detail)
}
}
require.NoError(t, plan.Err())
require.NotEmpty(t, stopTimeLookups)
for _, lookup := range stopTimeLookups {
assert.Contains(t, lookup, "(trip_id=?)", "filtered searches must look up stop times by matching trip, not nearby stop")
Expand Down
Loading