Skip to content

SQL Tightening Log

Audit-trail entries for schema changes shipped under the SQL Tightening plan. Each entry names the wave, the change, the rationale, and the revisit trigger if the deferral / drop turns out to need reversing.

The plan itself lives outside the repo (operator's PRD); this file is the in-tree record of what landed and what was deliberately not done.


Wave 2.E — drop nine unused indexes (2026-05-06)

Dropped nine indexes that no production query targets. Each was verified by grep across internal/**/*.go and cmd/ductile/**/*.go for any WHERE, ORDER BY, GROUP BY, or JOIN clause using the indexed column or composite. None matched.

Index Table Why dropped Re-add trigger
job_queue_enqueued_config_snapshot_idx job_queue Only INSERT/UPDATE writes to this column; no read-side filter Future audit query "list jobs admitted under config snapshot X"
job_queue_started_config_snapshot_idx job_queue Same Future audit query "list jobs that started under config snapshot X"
config_snapshots_loaded_at_idx config_snapshots Only WHERE id = ? (PK) lookups; tiny table Future "list recent snapshots" admin query
plugin_facts_job_id_idx plugin_facts Only WHERE plugin_name [AND fact_type] queries; was in requiredIndexes defensively Future "show all facts for job X" debug path. Removed from requiredIndexes in internal/storage/sqlite.go as part of this wave.
event_context_parent_id_idx event_context Recursive lineage CTE walks via JOIN ... ON lineage.parent_id = ec.id — lookup uses ec.id (PK), not ec.parent_id Future query that filters WHERE parent_id = ? directly
job_log_enqueued_config_snapshot_idx job_log No backing query Same as job_queue equivalent
job_log_started_config_snapshot_idx job_log No backing query Same
circuit_breaker_transitions_job_idx (partial, WHERE job_id IS NOT NULL) circuit_breaker_transitions No WHERE job_id = ? query in production code Future "show all breaker transitions caused by job X" path
schedule_entries_status_idx schedule_entries Only WHERE plugin = ? AND schedule_id = ? (PK) lookups; status is a bounded enum on a tiny table Future bulk filter WHERE status = 'active' on a much larger schedule set

What stayed

Sixteen indexes remain. Each has a named backing query — see PRD iteration 8 audit matrix for the full mapping.

job_queue_event_source_idx UNIQUE was kept and a comment added in schema.sql flagging that it carries an integrity guarantee, not just a speed optimisation: it prevents duplicate child enqueue per (parent_job_id, source_event_id).

Migration

scripts/migrate-drop-unused-indexes.py <db> is idempotent. DROP INDEX IF EXISTS is a SQLite metadata operation — no table rebuild, hot-safe.

Deploy ordering

For Mac: binary swap (with the updated requiredIndexes set) then run the migration. Reverse order — migration first, old binary still expecting plugin_facts_job_id_idx — would refuse to start at next restart.

For Thinkpad / Unraid: same ordering. Each instance migrates independently.

Pre-existing audit finding (not addressed in 2.E)

internal/inspect/report.go:312 does WHERE event_context_id = ? on job_queue with no index covering event_context_id. This is already a full-table scan today — slow on large datasets. Not introduced by 2.E; flagged here for a future iteration to either add job_queue_event_context_id_idx (if inspect-by-context is a hot enough path to justify the write cost) or to accept that inspect is an admin-only path with relaxed performance expectations.