Task S5T10: Sprint 5 Analytics Indexes¶
Sprint Context¶
- Sprint index: Sprint 5 Task Index
- Tags: database, database-refactor, sprint-5, task-index
Source¶
- DBA source:
task-S5T10.dba.md - Specification source:
task-S5T10.specification.md
Purpose & Scope¶
Creates performance indexes on all Sprint 5 analytics tables. Sprint 5 tables that survive live-data writes (hourly_activity, country_pair_flows, outbox_events) are the primary targets. The milestone and first_finder tables have low cardinality PK-based access, so additional indexes are minimal.
Adds the incremental B-tree indexes needed by canonical Sprint 5 analytics tables. No new tables are created.
Requirements¶
| ID | Requirement |
|---|---|
| REQ-670 | Index idx_hourly_activity_date_desc on stats.hourly_activity(activity_date DESC) created |
| REQ-671 | Index idx_country_pair_flows_month_desc on stats.country_pair_flows(year_month DESC) created |
| REQ-672 | Index idx_country_pair_flows_from on stats.country_pair_flows(from_country, year_month DESC) |
| REQ-673 | Index idx_country_pair_flows_to on stats.country_pair_flows(to_country, year_month DESC) created |
| REQ-674 | Event-bridge read index is defined only if a concrete bridge table is approved |
| REQ-675 | No requirement may depend on a non-canonical processed column |
| REQ-676 | Indexes already owned by Sprint 5 table-creation tasks are not redefined here |
| REQ-677 | This task covers only incremental indexes not already required by the canonical table contracts |
| REQ-678 | All indexes use IF NOT EXISTS to allow idempotent re-run |
| REQ-679 | phinx rollback drops only the indexes actually created by this task |
Migration File¶
20260310500900_create_analytics_indexes.php
Full Phinx Migration PHP Code¶
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateAnalyticsIndexes extends AbstractMigration
{
public function up(): void
{
$this->execute(<<<'SQL'
-- hourly_activity
CREATE INDEX IF NOT EXISTS idx_hourly_activity_date_desc
ON stats.hourly_activity (activity_date DESC);
-- country_pair_flows
CREATE INDEX IF NOT EXISTS idx_country_pair_flows_month_desc
ON stats.country_pair_flows (year_month DESC);
CREATE INDEX IF NOT EXISTS idx_country_pair_flows_from
ON stats.country_pair_flows (from_country, year_month DESC);
CREATE INDEX IF NOT EXISTS idx_country_pair_flows_to
ON stats.country_pair_flows (to_country, year_month DESC);
-- outbox_events
CREATE INDEX IF NOT EXISTS idx_outbox_events_created_at
ON stats.outbox_events (created_at ASC);
CREATE INDEX IF NOT EXISTS idx_outbox_events_unprocessed
ON stats.outbox_events (created_at ASC)
WHERE processed = FALSE;
-- gk_milestone_events
CREATE INDEX IF NOT EXISTS idx_gk_milestone_events_type
ON stats.gk_milestone_events (event_type);
-- first_finder_events
CREATE INDEX IF NOT EXISTS idx_first_finder_events_user
ON stats.first_finder_events (finder_user_id);
SQL
);
}
public function down(): void
{
$this->execute(<<<'SQL'
DROP INDEX IF EXISTS stats.idx_hourly_activity_date_desc;
DROP INDEX IF EXISTS stats.idx_country_pair_flows_month_desc;
DROP INDEX IF EXISTS stats.idx_country_pair_flows_from;
DROP INDEX IF EXISTS stats.idx_country_pair_flows_to;
DROP INDEX IF EXISTS stats.idx_outbox_events_created_at;
DROP INDEX IF EXISTS stats.idx_outbox_events_unprocessed;
DROP INDEX IF EXISTS stats.idx_gk_milestone_events_type;
DROP INDEX IF EXISTS stats.idx_first_finder_events_user;
SQL
);
}
}
Data Contract¶
| Artifact | Table | Columns |
|---|---|---|
idx_hourly_activity_date_desc |
hourly_activity | (activity_date DESC) |
idx_country_pair_flows_month_desc |
country_pair_flows | (year_month DESC) |
idx_country_pair_flows_from |
country_pair_flows | (from_country, year_month DESC) |
idx_country_pair_flows_to |
country_pair_flows | (to_country, year_month DESC) |
| event-bridge read index | approved bridge table only | implementation-specific |
| optional bridge read index | approved bridge table only | implementation-specific |
TimescaleDB Assessment¶
stats.hourly_activity and stats.country_pair_flows are natural candidates for TimescaleDB hypertables if the dataset grows beyond 50M rows. However, PostgreSQL B-tree indexes on (activity_date DESC) and (year_month DESC) provide equivalent performance for dashboard queries in the near term. TimescaleDB conversion is a Sprint 7+ operation if warranted.
pgTAP Unit Tests¶
| Test ID | Assertion | Expected |
|---|---|---|
| T-5.10.001 | has_index('stats','hourly_activity','idx_hourly_activity_date_desc') |
pass |
| T-5.10.002 | has_index('stats','country_pair_flows','idx_country_pair_flows_month_desc') |
pass |
| T-5.10.003 | has_index('stats','country_pair_flows','idx_country_pair_flows_from') |
pass |
| T-5.10.004 | has_index('stats','country_pair_flows','idx_country_pair_flows_to') |
pass |
| T-5.10.005 | has_index('stats','outbox_events','idx_outbox_events_created_at') |
pass |
| T-5.10.006 | has_index('stats','outbox_events','idx_outbox_events_unprocessed') |
pass |
| T-5.10.007 | has_index('stats','gk_milestone_events','idx_gk_milestone_events_type') |
pass |
| T-5.10.008 | has_index('stats','first_finder_events','idx_first_finder_events_user') |
pass |
| T-5.10.009 | EXPLAIN plan for SELECT … FROM hourly_activity WHERE activity_date > now()-interval '7 days' uses index |
pass |
| T-5.10.010 | phinx rollback — all 8 indexes dropped |
pass |
| Test ID | Criterion | pgTAP Assertion | Pass Condition |
|---|---|---|---|
| T-5.10.001 | REQ-670 | has_index('stats','hourly_activity','idx_...') |
pass |
| T-5.10.002 | REQ-671 | has_index('stats','country_pair_flows','idx_..._month_desc') |
pass |
| T-5.10.003 | REQ-672 | has_index('stats','country_pair_flows','idx_..._from') |
pass |
| T-5.10.004 | REQ-673 | has_index('stats','country_pair_flows','idx_..._to') |
pass |
| T-5.10.005 | REQ-674 | optional bridge read index is checked only if a bridge table exists | pass |
| T-5.10.009 | AC-5.10.1 | EXPLAIN shows index scan for recent activity_date query |
index_scan |
| T-5.10.010 | AC-5.10.5 | Task-owned indexes absent after rollback | NOT has_index |
Implementation Checklist¶
- 1. Create
20260310500900_create_analytics_indexes.php - 2.
phinx migrate— no errors - 3. Verify all 8 index names via
\di stats.* - 4. EXPLAIN on recent-activity query — confirm index scan
- 5. EXPLAIN on country-flows from-country query — confirm index scan
- 6. Verify partial index on outbox via
pg_indexessystem view - 7. Run pgTAP T-5.10.001 through T-5.10.010
-
8.
phinx rollback— all 8 indexes dropped cleanly -
Create
20260310500900_create_analytics_indexes.php - Run
phinx migrate— no errors - Verify the task-owned canonical indexes via
\di stats.* - Run EXPLAIN on key dashboard queries to confirm index scans
- Confirm any implementation-specific bridge index only if a bridge table exists
- Run pgTAP T-5.10.001 through T-5.10.010
-
phinx rollback— all 8 indexes dropped
hourly_activity — Descending Date¶
CREATE INDEX IF NOT EXISTS idx_hourly_activity_date_desc
ON stats.hourly_activity (activity_date DESC);
Used by: dashboard queries reading the last N days of activity. The primary key already covers (activity_date, hour_utc, move_type) for exact lookups but a single-column DESC index speeds up range scans from the API.
country_pair_flows — year_month Descending¶
CREATE INDEX IF NOT EXISTS idx_country_pair_flows_month_desc
ON stats.country_pair_flows (year_month DESC);
Used by: leaderboard / map queries that ORDER BY year_month DESC LIMIT N.
country_pair_flows — from_country, year_month Descending¶
CREATE INDEX IF NOT EXISTS idx_country_pair_flows_from
ON stats.country_pair_flows (from_country, year_month DESC);
Used by: "show recent outbound flows from PL" queries. Covers WHERE from_country='PL' ORDER BY year_month DESC.
country_pair_flows — to_country, year_month Descending¶
CREATE INDEX IF NOT EXISTS idx_country_pair_flows_to
ON stats.country_pair_flows (to_country, year_month DESC);
Used by: "show recent inbound flows to DE" queries.
outbox_events — created_at Ascending (Relay Consumption)¶
Used by: AMQP relay worker that reads unprocessed events ORDER BY created_at ASC LIMIT N. Replaces the partial index design (which required WHERE processed = FALSE) to keep it future-proof as other states could be added.
outbox_events — Partial Index on Unprocessed Events (Optional Fast-Path)¶
CREATE INDEX IF NOT EXISTS idx_outbox_events_unprocessed
ON stats.outbox_events (created_at ASC)
WHERE processed = FALSE;
Small partial index for relay worker hot-path. Invisible to index scanner once all rows are processed.
gk_milestone_events — event_type¶
Used by: "how many GKs have hit km_100?" aggregate queries.
first_finder_events — finder_user_id¶
CREATE INDEX IF NOT EXISTS idx_first_finder_events_user
ON stats.first_finder_events (finder_user_id);
Used by: "list all GKs first found by user X" profile queries.
Index Summary Table¶
| Index Name | Table | Columns | Type | Rationale |
|---|---|---|---|---|
| idx_hourly_activity_date_desc | hourly_activity | activity_date DESC | B-tree | Date-range scans for dashboards |
| idx_country_pair_flows_month_desc | country_pair_flows | year_month DESC | B-tree | Latest-month ordering |
| idx_country_pair_flows_from | country_pair_flows | from_country, year_month DESC | B-tree | Outbound flows per country |
| idx_country_pair_flows_to | country_pair_flows | to_country, year_month DESC | B-tree | Inbound flows per country |
| idx_outbox_events_created_at | outbox_events | created_at ASC | B-tree | Relay worker full scan |
| idx_outbox_events_unprocessed | outbox_events | created_at ASC, partial | B-tree | Relay worker hot-path |
| idx_gk_milestone_events_type | gk_milestone_events | event_type | B-tree | Aggregate by milestone type |
| idx_first_finder_events_user | first_finder_events | finder_user_id | B-tree | User profile "firsts" queries |
Master-Spec Alignment¶
This task is governed by ../00-SPEC-DRAFT-v1.obsolete.md, Section 9 and the canonical table contracts for Sprint 5.
- Do not assume a
processedcolumn on any event bridge table unless the master spec is amended to define one. - Indexes already owned by table-creation tasks must not be re-specified here as new mandatory Sprint 5 indexes.
- Any lower text that depends on non-canonical event-bridge columns is obsolete and superseded by this alignment block.
AC-5.10.1 — Hourly Activity Index Used¶
Given hourly_activity table populated, query filters activity_date > NOW() - INTERVAL '7 days'
When EXPLAIN ANALYZE run
Then query plan shows Index Scan on idx_hourly_activity_date_desc
AC-5.10.2 — Country Pair Flows From-Country Filter¶
Given country_pair_flows rows exist, query does WHERE from_country = 'PL' ORDER BY year_month DESC
When EXPLAIN ANALYZE run
Then plan shows Index Scan on idx_country_pair_flows_from
AC-5.10.3 — Event Bridge Read Index¶
Given a concrete bridge-table implementation is approved When the bridge reader query is benchmarked Then the approved implementation-specific read index is used
AC-5.10.4 — All Indexes Present¶
Given migration ran
When SELECT indexname FROM pg_indexes WHERE schemaname='stats' queried
Then the canonical indexes created by this task are listed
AC-5.10.5 — Rollback Removes All Indexes¶
Given migration has been applied
When phinx rollback run
Then none of the indexes created by this task appear in pg_indexes WHERE schemaname='stats'