Task S6T02: Heavy previous_move_id Orchestrator¶
Sprint Context¶
- Sprint index: Sprint 6 Task Index
- Tags: database, database-refactor, sprint-6, task-index
Source¶
- DBA source:
task-S6T02.dba.md - Specification source:
task-S6T02.specification.md
Purpose & Scope¶
Creates stats.fn_backfill_heavy_previous_move_id_all() — a convenience wrapper that calls stats.fn_backfill_previous_move_id() decade-by-decade to break the full history into manageable chunks, making a from-scratch production backfill safe to run without manual period calculation.
Wraps the batched S6T01 function into a full-history orchestrator that automatically divides all-time history into year-sized windows and calls fn_backfill_previous_move_id() for each window. Removes need for manual period calculation at deployment time.
Requirements¶
| ID | Requirement |
|---|---|
| REQ-710 | Function created in stats schema with signature (INT) RETURNS TEXT |
| REQ-711 | Default p_batch_size = 50000 |
| REQ-712 | Scans geokrety.gk_moves to find earliest move with previous_move_id IS NULL |
| REQ-713 | Iterates in annual windows from earliest year to current year |
| REQ-714 | Each window delegates to stats.fn_backfill_previous_move_id(window, p_batch_size) |
| REQ-715 | Returns summary text including per-window results |
| REQ-716 | Idempotent: re-run when table already filled exits immediately without error |
| REQ-717 | Logs final completion to stats.job_log |
| REQ-718 | phinx rollback drops function cleanly |
Migration File¶
20260310600200_create_backfill_heavy_previous_move_id.php
pgTAP Unit Tests¶
| Test ID | Assertion | Expected |
|---|---|---|
| T-6.2.001 | has_function('stats','fn_backfill_heavy_previous_move_id_all','{int}') |
pass |
| T-6.2.002 | Calling the orchestrator fills all NULL previous_move_id rows in test dataset |
pass |
| T-6.2.003 | Orchestrator is idempotent (re-run on complete data returns quickly) | pass |
| T-6.2.004 | Function logs to stats.job_log with status='ok' |
pass |
| T-6.2.005 | phinx rollback drops function cleanly |
pass |
| Test ID | Criterion | pgTAP Assertion | Pass Condition |
|---|---|---|---|
| T-6.2.001 | Exists | has_function with '{int}' arg |
pass |
| T-6.2.002 | AC-6.2.1 | NULL previous_move_id count = expected | 0 (minus firsts) |
| T-6.2.003 | AC-6.2.2 | Re-run completes without error | no exception |
| T-6.2.004 | REQ-717 | job_log row with status='ok' |
1 row |
| T-6.2.005 | Rollback | Function absent after phinx rollback |
NOT has_function |
Implementation Checklist¶
- 1. Create
20260310600200_create_backfill_heavy_previous_move_id.php - 2.
phinx migrate— no errors - 3. Verify
\df stats.fn_backfill_heavy_previous_move_id_all - 4. Run on test dataset — verify all NULL previous_move_id rows filled
- 5. Run pgTAP T-6.2.001 through T-6.2.005
-
6.
phinx rollback— function dropped -
Create
20260310600200_create_backfill_heavy_previous_move_id.php - Run
phinx migrate— no errors - Test on sample dataset — verify NULL count drops to expected minimum
- Run pgTAP T-6.2.001 through T-6.2.005
-
phinx rollback— function dropped
SQL DDL¶
CREATE OR REPLACE FUNCTION stats.fn_backfill_heavy_previous_move_id_all(
p_batch_size INT DEFAULT 50000
)
RETURNS TEXT LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_start TIMESTAMPTZ := clock_timestamp();
v_earliest TIMESTAMPTZ;
v_period TSTZRANGE;
v_window INTERVAL := INTERVAL '1 year';
v_slice_start TIMESTAMPTZ;
v_slice_end TIMESTAMPTZ;
v_result TEXT;
v_total TEXT := '';
BEGIN
-- Find earliest move date in the database
SELECT MIN(moved_on_datetime) INTO v_earliest
FROM geokrety.gk_moves
WHERE previous_move_id IS NULL;
IF v_earliest IS NULL THEN
RETURN 'fn_backfill_heavy_previous_move_id_all: nothing to backfill';
END IF;
v_slice_start := DATE_TRUNC('year', v_earliest);
LOOP
v_slice_end := v_slice_start + v_window;
EXIT WHEN v_slice_start > NOW();
v_period := tstzrange(v_slice_start, v_slice_end);
RAISE NOTICE 'Backfilling period: %', v_period;
v_result := stats.fn_backfill_previous_move_id(v_period, p_batch_size);
v_total := v_total || E'\n' || v_result;
v_slice_start := v_slice_end;
END LOOP;
INSERT INTO stats.job_log (job_name, rows_affected, duration, status)
VALUES ('fn_backfill_heavy_previous_move_id_all', NULL, clock_timestamp() - v_start, 'ok');
RETURN 'fn_backfill_heavy_previous_move_id_all completed in ' ||
(clock_timestamp() - v_start)::TEXT || ':' || v_total;
END;
$$;
Phinx PHP Migration¶
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateBackfillHeavyPreviousMoveId extends AbstractMigration
{
public function up(): void
{
$this->execute(<<<'SQL'
CREATE OR REPLACE FUNCTION stats.fn_backfill_heavy_previous_move_id_all(
p_batch_size INT DEFAULT 50000
)
RETURNS TEXT LANGUAGE plpgsql SECURITY DEFINER
AS $func$
/* ... full function body ... */
$func$;
SQL
);
}
public function down(): void
{
$this->execute(<<<'SQL'
DROP FUNCTION IF EXISTS stats.fn_backfill_heavy_previous_move_id_all(INT);
SQL
);
}
}
Usage Examples¶
-- Full history backfill (default 50k batch)
SELECT stats.fn_backfill_heavy_previous_move_id_all();
-- Smaller batch for production under load
SELECT stats.fn_backfill_heavy_previous_move_id_all(20000);
-- Check progress
SELECT COUNT(*) FROM geokrety.gk_moves WHERE previous_move_id IS NULL;
-- View job log
SELECT * FROM stats.job_log WHERE job_name LIKE '%previous_move_id%' ORDER BY started_at DESC LIMIT 20;
AC-6.2.1 — Full-History Completion¶
Given 5000 historical moves with NULL previous_move_id
When fn_backfill_heavy_previous_move_id_all() runs
Then SELECT COUNT(*) FROM geokrety.gk_moves WHERE previous_move_id IS NULL = 0 (only first-per-GK remain)
AC-6.2.2 — Idempotency¶
Given all previous_move_id already filled
When function runs
Then immediate return without error; no corruption
AC-6.2.3 — Progress Reporting¶
When function runs Then NOTICE messages emitted per annual window processed