Database migration runbook¶
This runbook describes the manual steps and commands used to apply and validate the snapshot/materialized-view/index migrations and to run scoped snapshot runs (1-month and 3-month). Use a safe test/staging copy first and always take a backup.
Prerequisites
- PostgreSQL client tools:
psql,pg_dump,pg_restore(or equivalent) pg_prove(optional) for running pgTAP tests- Project's phinx helper script at
.github/skills/phinx/scripts/phinx.sh - Database credentials with a user
geokrety(adjust as necessary)
Backup (required)
- Full custom-format dump (fast restore):
mkdir -p ~/db-backups
BACKUP=~/db-backups/geokrety-$(date +%Y%m%d%H%M).dump
pg_dump -U geokrety -h localhost -Fc geokrety > "$BACKUP"
ls -lh "$BACKUP"
Apply migrations
- From repository root apply migrations in
website/dbusing the project script:
If you need to rollback the last applied migration:
Materialized views refresh (manual verification) After the migrations that create MVs, you can refresh them concurrently:
-- run in psql or here-doc
REFRESH MATERIALIZED VIEW CONCURRENTLY stats.mv_country_month_rollup;
REFRESH MATERIALIZED VIEW CONCURRENTLY stats.mv_top_caches_global;
REFRESH MATERIALIZED VIEW CONCURRENTLY stats.mv_global_kpi;
Example as a shell heredoc:
psql -U geokrety -d geokrety <<'EOF'
REFRESH MATERIALIZED VIEW CONCURRENTLY stats.mv_country_month_rollup;
REFRESH MATERIALIZED VIEW CONCURRENTLY stats.mv_top_caches_global;
REFRESH MATERIALIZED VIEW CONCURRENTLY stats.mv_global_kpi;
EOF
Run snapshots (scoped by period)
The main runner is stats.fn_run_all_snapshots(p_phases, p_period, p_batch_size).
Pass NULL::text[] to run the default phase list. Use a tstzrange for p_period with the form tstzrange('YYYY-MM-DD HH24:MI:SS+TZ','YYYY-MM-DD HH24:MI:SS+TZ','[)') (left-inclusive, right-exclusive).
For full historical backfills, do not call the full runner with NULL period. Run the full pre-phases once, replay the sliceable phases month by month from 2007-10-01 up to tomorrow 00:00 UTC, then run the full post-phases once.
The four new full rebuild phases for the backfilled tables are:
fn_snapshot_daily_entity_countsfn_snapshot_gk_country_historyfn_snapshot_first_finder_eventsfn_snapshot_gk_milestone_events
For these four phases, the automation script uses an explicit transaction with SET LOCAL session_replication_role = replica to bypass target-table constraint checks during the bulk rebuild. SET LOCAL only works inside a transaction, so the manual SQL recipe must do the same.
Copy-paste monthly backfill block
set -euo pipefail
export PGDATABASE="${PGDATABASE:-geokrety}"
export BATCH_SIZE="${BATCH_SIZE:-50000}"
export FULL_START_DATE="${FULL_START_DATE:-2007-10-01}"
export FULL_END_DATE="${FULL_END_DATE:-$(date -u -d tomorrow +%F)}"
run_full_phase_replica() {
local phase="$1"
psql -v ON_ERROR_STOP=1 -P pager=off <<EOF
BEGIN;
SET LOCAL session_replication_role = replica;
SELECT stats.fn_run_snapshot_phase('${phase}', NULL, ${BATCH_SIZE});
COMMIT;
EOF
}
psql -v ON_ERROR_STOP=1 -P pager=off -c \
"SELECT stats.fn_run_snapshot_phase('fn_snapshot_entity_counters', NULL, ${BATCH_SIZE});"
run_full_phase_replica "fn_snapshot_daily_entity_counts"
cursor_date="$FULL_START_DATE"
while [[ "$cursor_date" < "$FULL_END_DATE" ]]; do
slice_start="$(date -u -d "$cursor_date" '+%Y-%m-%d 00:00:00+00')"
next_month_date="$(date -u -d "$cursor_date +1 month" +%F)"
slice_end="$(date -u -d "$next_month_date" '+%Y-%m-%d 00:00:00+00')"
if [[ "$next_month_date" > "$FULL_END_DATE" ]]; then
slice_end="$(date -u -d "$FULL_END_DATE" '+%Y-%m-%d 00:00:00+00')"
fi
echo "== Slice ${slice_start} -> ${slice_end} =="
psql -v ON_ERROR_STOP=1 -P pager=off -c \
"SELECT stats.fn_run_snapshot_phase('fn_backfill_heavy_previous_move_id_all', tstzrange('${slice_start}','${slice_end}','[)'), ${BATCH_SIZE});"
psql -v ON_ERROR_STOP=1 -P pager=off -c \
"SELECT stats.fn_run_snapshot_phase('fn_seed_daily_activity', tstzrange('${slice_start}','${slice_end}','[)'), ${BATCH_SIZE});"
psql -v ON_ERROR_STOP=1 -P pager=off -c \
"SELECT stats.fn_run_snapshot_phase('fn_snapshot_daily_country_stats', tstzrange('${slice_start}','${slice_end}','[)'), ${BATCH_SIZE});"
psql -v ON_ERROR_STOP=1 -P pager=off -c \
"SELECT stats.fn_run_snapshot_phase('fn_snapshot_user_country_stats', tstzrange('${slice_start}','${slice_end}','[)'), ${BATCH_SIZE});"
psql -v ON_ERROR_STOP=1 -P pager=off -c \
"SELECT stats.fn_run_snapshot_phase('fn_snapshot_gk_country_stats', tstzrange('${slice_start}','${slice_end}','[)'), ${BATCH_SIZE});"
psql -v ON_ERROR_STOP=1 -P pager=off -c \
"SELECT stats.fn_run_snapshot_phase('fn_snapshot_relationship_tables', tstzrange('${slice_start}','${slice_end}','[)'), ${BATCH_SIZE});"
psql -v ON_ERROR_STOP=1 -P pager=off -c \
"SELECT stats.fn_run_snapshot_phase('fn_snapshot_hourly_activity', tstzrange('${slice_start}','${slice_end}','[)'), ${BATCH_SIZE});"
psql -v ON_ERROR_STOP=1 -P pager=off -c \
"SELECT stats.fn_run_snapshot_phase('fn_snapshot_country_pair_flows', tstzrange('${slice_start}','${slice_end}','[)'), ${BATCH_SIZE});"
cursor_date="$next_month_date"
done
run_full_phase_replica "fn_snapshot_gk_country_history"
run_full_phase_replica "fn_snapshot_first_finder_events"
run_full_phase_replica "fn_snapshot_gk_milestone_events"
Notes:
fn_snapshot_entity_countersis fast enough to run once without slicing and does not use replica mode.fn_snapshot_daily_entity_countsruns once before the monthly loop because it rebuilds the entire trend table from source history.fn_backfill_heavy_previous_move_id_alland other sliced phases run once per month (monthly basis) for efficient backfill.fn_snapshot_gk_country_history,fn_snapshot_first_finder_events, andfn_snapshot_gk_milestone_eventsrun once after the monthly loop because they rebuild whole-history tables.- The last slice is partial for the current month and stops at tomorrow
00:00 UTC. - Override
FULL_START_DATE,FULL_END_DATE, orBATCH_SIZEin the shell if you need a narrower replay window.
Automation script
Use the helper script next to this runbook for the same phase order with per-phase timings, progress tracking, ETA with date/time, colors, and emoji:
python /home/kumy/GIT/geokrety-stats/docs/database-refactor/run_snapshot_backfill.py
python /home/kumy/GIT/geokrety-stats/docs/database-refactor/run_snapshot_backfill.py --start 2007-10 --end 2008-01
python /home/kumy/GIT/geokrety-stats/docs/database-refactor/run_snapshot_backfill.py --start 2026-01 --end 2026-02 --batch-size 50000
python /home/kumy/GIT/geokrety-stats/docs/database-refactor/run_snapshot_backfill.py --start 2007-10 --end 2008-01 --dry-run
python /home/kumy/GIT/geokrety-stats/docs/database-refactor/run_snapshot_backfill.py --no-replica-role
python /home/kumy/GIT/geokrety-stats/docs/database-refactor/run_snapshot_backfill.py --clear-resume-markers --dry-run
python /home/kumy/GIT/geokrety-stats/docs/database-refactor/run_snapshot_backfill.py --no-resume
The script provides:
- Live progress tracking with table format showing phase, slice period, elapsed time, and ETA datetime
- Color-coded output for easy visual scanning
- Emoji indicators for status (âŗ pending, âī¸ running, â done, đ stats)
- ETA with datetime (not just duration), so you know when the run will complete
- Per-phase timing from both wall-clock and server-side measurements
- Throughput statistics (steps per hour) at the end
- --dry-run mode to preview all planned steps before execution
- Replica-role fast path by default for
daily_entity_counts,gk_country_history,first_finder_events, andgk_milestone_events; pass--no-replica-roleto disable it - Exact-run resume by default using runner-owned
stats.job_logmarkers (job_name = 'run_snapshot_backfill_step') - --clear-resume-markers to delete only the markers for the exact resolved run key before planning
- --no-resume to force a full replay even when exact-run markers already exist
Resume semantics
- The runner computes a
run_keyfrom the resolved request window, current source bounds, batch size, parallel mode, replica-role mode, and the--skip-entity-countersflag. - Every completed phase/slice step writes one canonical marker row into
stats.job_logwithjob_name = 'run_snapshot_backfill_step'. - Re-running the script with the exact same parameters and unchanged source bounds filters those completed steps out of the plan, so the process continues instead of replaying already-finished work.
- If the source bounds change, the
run_keychanges too, so stale markers are ignored automatically. - The runner takes a PostgreSQL advisory lock for the resolved request/source window so two identical runs cannot overlap.
- For an operator reset, use
--clear-resume-markers; for a deliberate full replay, use--no-resume.
Example continuation flow:
# First run for a bounded window.
python /home/kumy/GIT/geokrety-stats/docs/database-refactor/run_snapshot_backfill.py \
--start 2026-03 --end 2026-04
# Same command again: completed steps are skipped and the runner exits quickly.
python /home/kumy/GIT/geokrety-stats/docs/database-refactor/run_snapshot_backfill.py \
--start 2026-03 --end 2026-04
# Force a fresh replay for the same window.
python /home/kumy/GIT/geokrety-stats/docs/database-refactor/run_snapshot_backfill.py \
--start 2026-03 --end 2026-04 --no-resume
# Clear only the exact-run markers, then preview the rebuilt plan.
python /home/kumy/GIT/geokrety-stats/docs/database-refactor/run_snapshot_backfill.py \
--start 2026-03 --end 2026-04 --clear-resume-markers --dry-run
Example output:
âšī¸ Source bounds: 2007-10-01 00:00:00+00 â 2026-03-15 00:00:00+00
âšī¸ Requested run: 2007-10-01 00:00:00+00 â 2007-11-01 00:00:00+00
đ Slices: 1 | Steps: 7 | Batch size: 50000
# â Phase (50 chars) â Slice (25 chars) â Elapsed â ETA (UTC)
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
âī¸ 1 â fn_snapshot_entity_counters â full â 00:02:15 â 2026-03-15 12:45:30
â
1 â fn_snapshot_entity_counters â full â 00:02:15 â 2026-03-15 12:30:00
âī¸ 2 â fn_snapshot_daily_entity_counts â full â 00:00:04 â 2026-03-15 12:30:04
â
2 â fn_snapshot_daily_entity_counts â full â 00:00:04 â 2026-03-15 12:30:04
âī¸ 3 â fn_backfill_heavy_previous_move_id_all â 2007-10-01..2007-11-01 â 00:00:01 â 2026-03-15 12:30:05
âī¸ 4 â PARALLEL (7 phases) â 2007-10-01..2007-11-01 â 00:00:08 â 2026-03-15 12:30:13
âī¸ 5 â fn_snapshot_gk_country_history â full â 00:00:24 â 2026-03-15 12:30:37
âī¸ 6 â fn_snapshot_first_finder_events â full â 00:00:01 â 2026-03-15 12:30:38
âī¸ 7 â fn_snapshot_gk_milestone_events â full â 00:00:17 â 2026-03-15 12:30:55
...
â====ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
đ Completed 13 phases in 00:00:58 (799.1 steps/hour)
Sliced heavy phases
The fn_backfill_heavy_previous_move_id_all function runs on a per-month basis (sliced). This allows:
- Progressive backfill of historical data
- Better resource usage with bounded batch sizes
- Resumable runs if one month fails (re-run just that month)
Examples below use UTC timestamps â adjust timezone offsets if needed.
- Run for a 1-month period (example: 2026-02-01 => 2026-03-01):
START='2026-02-01 00:00:00+00'
END='2026-03-01 00:00:00+00'
psql -U geokrety -d geokrety -c "SELECT stats.fn_run_all_snapshots(NULL::text[], tstzrange('$START','$END','[)'), 50000);"
- Run for a 3-month period (example: 2026-01-01 => 2026-04-01):
START='2026-01-01 00:00:00+00'
END='2026-04-01 00:00:00+00'
psql -U geokrety -d geokrety -c "SELECT stats.fn_run_all_snapshots(NULL::text[], tstzrange('$START','$END','[)'), 50000);"
Notes:
- If you want to run a single phase only, pass the phase names as the first argument, e.g.
ARRAY['fn_snapshot_hourly_activity']::text[]. - If the snapshot run is long or times out, lower
p_batch_size(third argument) and retry.
Run focused pgTAP tests
- Preferred:
pg_prove(if available):
- Fallback: run SQL test file with
psql(some tests expect pgTAP harness):
Full test suite
- Use your project test runner or
pg_proveover thewebsite/db/testsdirectory if set up. Example:
or use the project's test script if present.
Verification queries
- Check recent snapshot job results:
- Inspect runner-owned resume markers for the latest exact run:
SELECT
metadata->>'run_key' AS run_key,
metadata->>'phase' AS phase,
COALESCE(metadata->>'slice_start', 'full') AS slice_start,
metadata->>'parallel_mode' AS parallel_mode,
completed_at
FROM stats.job_log
WHERE job_name = 'run_snapshot_backfill_step'
ORDER BY completed_at DESC, id DESC
LIMIT 50;
- Confirm materialized views exist:
- Inspect gk_moves indexes (verify the expected indexes remain):
SELECT schemaname, tablename, indexname
FROM pg_indexes
WHERE schemaname = 'geokrety' AND tablename = 'gk_moves'
ORDER BY indexname;
Rollback plan (if a run fails or you need to undo a migration)
- Rollback last migration:
- If you need to restore DB from backup (fast):
Troubleshooting & tips
- If pgTAP reports "duplicate plan()" errors, inspect the SQL test file for multiple
plan()declarations and remove duplicates. - Pre-commit hooks may auto-fix EOF/trailing whitespace; if a commit fails,
git addthe modified files again and re-commit. - Long-running
REFRESH MATERIALIZED VIEWshould be run withCONCURRENTLYwhen possible to avoid exclusive locks. - Use
screen/tmuxor background jobs for long snapshot runs and capture output to a log file:
psql -U geokrety -d geokrety -c "SELECT stats.fn_run_all_snapshots(NULL::text[], tstzrange('$START','$END','[)'), 50000);" | tee snapshot-run.log
Change log & notes
- This runbook covers the manual steps used during the March 2026 recovery: restoring orchestration functions, materialized views, runtime snapshot indexes, index cleanup, and move-history view. Adjust dates and phase lists to your environment.