Repository navigation
Pin and reorder the timed_belief primary key - #2378
Conversation
TimedBeliefDBMixin marks five columns primary_key=True and leaves the order to
SQLAlchemy, which collects attributes declared on the subclass before the
mixin's. The key's shape is therefore an accident of which columns a host
redeclares, and it changes silently if anyone adds a column override. Our own
source_id override already moves it to the front, so a schema built by
create_all() disagrees with a migrated one and nothing catches it.
Pin the order with an explicit PrimaryKeyConstraint, and make it a deliberate
choice rather than an incidental one:
(sensor_id, source_id, event_start, belief_horizon, cumulative_probability)
- sensor_id first: virtually every query filters on a single sensor, and a key
that does not lead with it cannot serve those queries at all -- which is why
deployments end up hand-adding a sensor-leading composite index.
- source_id second: this makes (sensor_id, source_id, event_start,
belief_horizon) a prefix of the key, so such a hand-added index is redundant.
On the dataset profiled it was the largest and most-scanned index on the
table, and the queries that used it get faster, being served by a key that no
longer needs a second structure maintained alongside it on every insert.
- cumulative_probability last: it is very nearly a constant (0.5 for every
deterministic belief) and contributes no selectivity wherever it sits.
- sensor_id and source_id adjacent: both are 4-byte integers, so keeping them
together avoids the alignment padding that separating them forces into every
index tuple -- roughly 15% of the index's size.
The set of key columns is unchanged, so uniqueness semantics are identical and
on_conflict_do_update (which takes index_elements as a set) is unaffected.
Migration d4a7c1e93b52 recreates the constraint and drops the redundant
composite index, but only after checking its definition matches, so an index
merely sharing the name is left alone. The downgrade recreates that index first
so the queries relying on it are never left unserved. This rebuilds an index but
does not rewrite the table: no heap pages are touched. The docstring documents
the CREATE UNIQUE INDEX CONCURRENTLY + ADD CONSTRAINT USING INDEX route for
large deployments, since op.create_primary_key holds an exclusive lock for the
whole build.
Verified by running upgrade() and downgrade() against a real PostgreSQL table
shaped like the pre-migration one, with both a hand-added composite index and a
decoy index sharing a similar name: the reorder, the guarded drop, the decoy and
the row count all behave. New DB-free tests pin the order, assert sensor_id
leads, assert the two integer columns stay adjacent, and assert timely_beliefs'
own indexes survive the __table_args__ override.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: F.N. Claessen <felix@seita.nl>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <felix@seita.nl>
Documentation build overview
93 files changed ·
|
|
One consequence I should flag rather than leave for review to find.
I do not think this blocks the change:
So my inclination is to accept it as a deliberate trade rather than add an index for it. But it is a real regression in that one path, and it should be a conscious decision rather than something noticed later — if we do start deleting data sources routinely, an index on Worth noting the sensor side is fine: |
timed_belief carried single-column indexes on event_start and on sensor_id,
created because timely_beliefs declared index=True on both columns. Each is
fully covered by a composite index the same library declares:
(event_start) -> timed_belief_search_session_idx
(event_start, sensor_id, source_id) INCLUDE (belief_horizon)
(sensor_id) -> timed_belief_search_session_singleevent_idx
(sensor_id, event_start)
A btree serves a leading-column lookup just as well as a dedicated single-column
index, so neither offered anything a query could use. They only occupied space on
what is usually our largest table and slowed down every write that had to
maintain them. Where this was profiled, neither had ever served a single index
scan, while every other index on the table had scan counts in the hundreds of
thousands or millions, so the statistics were not merely young.
SeitaBV/timely-beliefs#244, released in 4.2.0, stopped declaring them, so newly
created databases no longer get them. Migration b7e5a2c40f18 removes them from
existing ones, and the timely-beliefs floor moves to 4.2.0 so the two cannot
disagree -- dropping them while the ORM still declared them would recreate
exactly the divergence between create_all() and migrated schemas that the
previous commit set out to fix.
Dropping the sensor_id index is safe for the ON DELETE CASCADE on its foreign
key: PostgreSQL only needs to find rows by sensor_id, and
timed_belief_search_session_singleevent_idx leads with that column. A new test
pins that property, so a future reordering of those composites cannot quietly
remove the coverage this relies on.
The migration matches indexes structurally rather than by name -- single-column,
non-unique, not backing a constraint, on one of the two columns -- so it works
whatever naming convention a deployment's indexes were created under, and is a
no-op where they are already gone. Verified against a real PostgreSQL table
carrying both redundant indexes plus four decoys: a unique index on event_start,
a constraint-backed unique on sensor_id, a single-column index on another
column, and both composites. Exactly the two intended indexes were dropped, the
downgrade recreated them, and a repeated upgrade was a no-op.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: F.N. Claessen <felix@seita.nl>
|
Added step 2 in New migration The floor moves to The migration matches structurally rather than by name — single-column, non-unique, not backing a constraint, on one of the two columns — so it works whatever naming convention a deployment's indexes were created under, and is a no-op where they are already gone. Verified against a real PostgreSQL table carrying both redundant indexes plus four deliberate decoys: a unique index on Safety of dropping the Tests: One thing to look at: |
|
Profiled the
So the regression is large in relative terms — roughly four orders of magnitude on the delete — but its absolute cost is a single ~20-second statement. Two details worth knowing:
Recommendation: accept it, don't add the indexThe mitigation costs a permanent index — on the order of 12% of the space this PR reclaims — to speed up an operation that no CLI or API path performs. The only bulk delete of data sources is in Paying that forever to turn a rare 20-second admin command into an instant one is the wrong way round. What would change the answer: if deleting data sources ever becomes routine — a UI action, a cleanup job, pruning of versioned sources — add the index then. It is a one-line Worth noting the old key was never free here either. It happened to cover this case as a side effect of an ordering that costs us on every read and every write. This change makes that implicit subsidy explicit, which seems the right way round — but it is a deliberate trade, so it should be someone's decision rather than a discovery later. |
There was a problem hiding this comment.
🟡 Not ready to approve
The migrations have correctness issues (downgrade restores the wrong prior PK order, and redundant-index detection isn’t schema-scoped and may target the wrong table in multi-schema databases).
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Pins and standardizes the timed_belief primary key column order (and removes redundant indexes) to ensure ORM-generated schemas (create_all()) and migrated schemas stay aligned, while improving index usefulness and footprint for common query patterns.
Changes:
- Add an explicit
PrimaryKeyConstraintonTimedBeliefto pin PK column order to(sensor_id, source_id, event_start, belief_horizon, cumulative_probability). - Add Alembic migrations to reorder the PK and drop redundant single-column indexes on
timed_belief. - Add schema-level tests to assert PK order and index expectations, and bump
timely-beliefsto>=4.2.0.
File summaries
| File | Description |
|---|---|
pyproject.toml |
Bumps timely-beliefs floor to >=4.2.0 to match ORM/index expectations. |
flexmeasures/data/models/time_series.py |
Pins timed_belief PK order via explicit PrimaryKeyConstraint while preserving mixin indexes. |
flexmeasures/data/tests/test_timed_belief_schema.py |
Adds metadata-only tests to enforce PK order and index presence/absence. |
flexmeasures/data/migrations/versions/d4a7c1e93b52_reorder_timed_belief_primary_key.py |
Rebuilds PK in the new order and conditionally drops a now-redundant composite index. |
flexmeasures/data/migrations/versions/b7e5a2c40f18_drop_redundant_timed_belief_indexes.py |
Drops redundant single-column indexes on event_start and sensor_id based on structural detection. |
documentation/changelog.rst |
Adds changelog entries describing the PK reorder and index drops. |
Review details
Suppressed comments (1)
flexmeasures/data/migrations/versions/d4a7c1e93b52_reorder_timed_belief_primary_key.py:76
- The downgrade primary-key column order (OLD_ORDER) does not match the pre-upgrade order created by FlexMeasures migrations (see 04f0e2d2924a, which created (event_start, belief_horizon, cumulative_probability, sensor_id, source_id)). As written, a downgrade will not restore the prior migrated schema shape.
# The order SQLAlchemy produced before the primary key was pinned explicitly.
OLD_ORDER = [
"source_id",
"event_start",
"belief_horizon",
"cumulative_probability",
"sensor_id",
]
- Files reviewed: 6/7 changed files
- Comments generated: 6
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
…comments Both migrations now run online, so neither needs a maintenance window. The primary key swap builds its replacement with CREATE UNIQUE INDEX CONCURRENTLY inside an autocommit_block, then promotes it with ADD CONSTRAINT ... USING INDEX. Building the index is the slow part and no longer blocks reads or writes; only the promotion takes an ACCESS EXCLUSIVE lock, and that is catalog-only, because the index already exists by then. The redundant index drops are CONCURRENTLY too. This removes the need for the manual out-of-band recipe the docstring used to carry, and for any opt-in flag on `flexmeasures db upgrade`. The trade is that concurrent steps are not transactional, so a failure partway can leave an unused (possibly invalid) timed_belief_pkey_new behind. Both migrations therefore clean up before they start, which makes a retry safe. DROP INDEX CONCURRENTLY cannot run inside a DO block, so the conditional logic that picked which indexes to drop moved from plpgsql into Python; the selection criteria are unchanged. Fixes a real bug Copilot caught: the catalog query that finds the redundant single-column indexes filtered only on relname, not on the namespace, so in a database with several schemas it could have dropped indexes belonging to a different timed_belief table. Verified against a two-schema database: the unfixed query does match the other schema's indexes, and the fixed one leaves them alone. Also per review: - combine the two changelog entries into one short, user-focused line, and say there that the migration is online; - drop the stale "3.5.5" rationale above the timely-beliefs pin, and record why 4.2.0 is the floor; - reflow docstrings and inline comments so lines break only after punctuation, per .github/instructions/docstrings.instructions.md. Verified end to end against PostgreSQL, driving the migrations through a real MigrationContext transaction so autocommit_block behaves as it does under env.py: upgrade reorders the key and drops all three redundant indexes, a decoy index and the other schema survive, re-running is a no-op, the downgrades restore everything with rows intact, no invalid indexes are left at any point, and an injected stale timed_belief_pkey_new is cleaned up on retry. flexmeasures/data: 249 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <felix@seita.nl>
There was a problem hiding this comment.
🟡 Not ready to approve
There are a couple of concrete migration robustness/conventions issues (docstring line-breaking convention and unsafe identifier quoting in a DROP INDEX statement) that should be fixed before merge.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (2)
flexmeasures/data/migrations/versions/d4a7c1e93b52_reorder_timed_belief_primary_key.py:33
- Module docstring breaks a line mid-phrase (ends with "inside an"), but this repo’s docstring convention is to only break lines after punctuation (see .github/instructions/docstrings.instructions.md:38). Please reflow these lines so each line ends with punctuation.
The swap is done online, so this does not need a maintenance window.
Building the replacement index is the slow part, and it runs ``CONCURRENTLY`` inside an
``autocommit_block``, so reads and writes continue throughout.
flexmeasures/data/migrations/versions/b7e5a2c40f18_drop_redundant_timed_belief_indexes.py:88
- The migration claims it quotes index identifiers safely, but wrapping the name in double quotes without escaping embedded quotes is not equivalent to PostgreSQL’s quoting and can make the migration fail on oddly-named indexes. Escape embedded double quotes before interpolating the identifier.
for name in names:
# Quote the identifier the same way PostgreSQL would, in case of odd naming.
with op.get_context().autocommit_block():
op.execute(f'DROP INDEX CONCURRENTLY IF EXISTS "{name}"')
- Files reviewed: 6/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
The DROP INDEX statement wrapped the index name in double quotes itself, which is not what PostgreSQL's own quoting does: a name containing a double quote produced a syntax error rather than a valid statement. Verified, rather than assumed -- an index named `weird"quoted_es_idx` makes the old form fail with `syntax error at or near "quoted_es_idx"`. Let PostgreSQL do the quoting instead. The lookup now returns `quote_ident(schema) || '.' || quote_ident(index)`, so the identifier arrives ready to interpolate, correctly quoted whether it needs quoting, contains quotes, or is mixed case. Tested against indexes named `weird"quoted_es_idx` and `Mixed Case sid idx`: both are found and dropped. Also reflow two more docstring lines that still broke mid-phrase. One of them Copilot flagged; the other I found by checking every added line against the convention, which is a better guard than reading them over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <felix@seita.nl>
|
Round 2 addressed in Unsafe identifier quoting. The Fixed by letting PostgreSQL do the quoting. The lookup now returns Docstring line breaks. Reflowed. Copilot flagged one; I found a second by mechanically checking every added line against the convention rather than re-reading them, which is the better guard. Re-verified after the change: the migrations still pass the full end-to-end run (upgrade, both downgrades, no-op re-run, decoy and other-schema survival, no invalid indexes at any point, stale-index retry), and |
There was a problem hiding this comment.
🟡 Not ready to approve
The main changelog entry currently violates the repository’s one-sentence changelog entry convention and should be reflowed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (1)
documentation/changelog.rst:52
- Main changelog entries are expected to be one sentence (no period at the end). This new entry is split into multiple sentences with periods ("...redundant." / "Requires..." / "The migration...") which breaks the established format in this file.
* Speed up sensor data queries and free up disk space by reordering the ``timed_belief`` primary key to lead with ``sensor_id``, and dropping three indexes it makes redundant. Requires ``timely-beliefs`` 4.2.0. The migration runs online, so no maintenance window is needed, but building the new index can take a while on a large database [see `PR #2378 <https://www.github.com/FlexMeasures/flexmeasures/pull/2378>`_]
- Files reviewed: 6/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
.github/instructions/changelog.instructions.md asks for one sentence, no period at the end, written for end users with technical details abstracted away where possible. The entry had grown to three sentences. Fold it back into one, drop the timely-beliefs version (a packaging detail the migration and PR already record), and keep the part hosts actually act on: that the migration runs online and needs no maintenance window, though it can still take a while on a large database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <felix@seita.nl>
|
Round 3 addressed in The entry is now:
I dropped the That is three rounds of Copilot review; all findings across them were valid, and two were substantive (the missing schema constraint, and the identifier quoting). Handing back to you from here rather than looping further. |
There was a problem hiding this comment.
🟡 Not ready to approve
The migrations’ index-matching logic can drop unintended non-redundant indexes (e.g., superset/partial/expression indexes) due to overly broad catalog checks, which should be tightened before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (2)
flexmeasures/data/migrations/versions/d4a7c1e93b52_reorder_timed_belief_primary_key.py:90
- The redundant-index guard uses
indexdef LIKE '%(sensor_id, source_id, event_start, belief_horizon)%', which can also match indexes that start with those columns but have extra key columns or INCLUDE columns. In that case, the migration would drop a non-redundant index despite the comment claiming it only drops an exact structural match. Consider switching to apg_index-based check that verifies: (a) exactly 4 key columns, (b) no INCLUDE columns, (c) no predicate/expression, and (d) the exact key-column name sequence.
WHERE schemaname = current_schema()
AND tablename = 'timed_belief'
AND indexname = :name
AND indexdef LIKE '%(sensor_id, source_id, event_start, belief_horizon)%'
"""
flexmeasures/data/migrations/versions/b7e5a2c40f18_drop_redundant_timed_belief_indexes.py:71
FIND_REDUNDANT_INDEXESwill also match and drop partial or expression indexes onevent_start/sensor_id(e.g. a deployment-specific partial index), because it only checksindnatts = 1and the leading attribute name. Since the intent is to remove the plain single-column indexes previously created by timely-beliefs (which are non-partial and non-expression), it’s safer to exclude indexes with predicates (indpred) or expressions (indexprs).
AND n.nspname = current_schema()
AND x.indnatts = 1
AND NOT x.indisunique
AND NOT x.indisprimary
AND a.attname = ANY(:columns)
- Files reviewed: 6/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
nhoening
left a comment
There was a problem hiding this comment.
I tried this locally and the migration(s) worked.
I would like to make explicit what the benefits are. I understand that it makes our system more predictable, as sqlachemy updates can change the order unexpectedly.
And we save space as we drop an index, as well as time to maintain this index with each db update.
But are there probably speedups in queries to be expected right away?
Or maybe per definition none, and the only drawback has already been pointed out (deleting sources)?
To show you what happened on my dev db (might not resemble production). For me, only one index was dropped, and I did not have the single-column index on ``sensor_id`:
"timed_belief_event_start_idx" btree (event_start)
Maybe I didn't have the one(s) that would be dropped on production.
This is my before and after:
flexmeasures_prod=> \d timed_belief
Table "public.timed_belief"
Column | Type | Collation | Nullable | Default
------------------------+--------------------------+-----------+----------+---------
source_id | integer | | not null |
event_start | timestamp with time zone | | not null |
belief_horizon | interval | | not null |
cumulative_probability | double precision | | not null |
event_value | double precision | | not null |
sensor_id | integer | | not null |
Indexes:
"timed_belief_pkey" PRIMARY KEY, btree (source_id, event_start, belief_horizon, cumulative_probability, sensor_id)
"timed_belief_event_start_idx" btree (event_start)
"timed_belief_search_session_idx" btree (event_start, sensor_id, source_id)
"timed_belief_search_session_singleevent_idx" btree (sensor_id, event_start)
Foreign-key constraints:
"timed_belief_sensor_id_sensor_fkey" FOREIGN KEY (sensor_id) REFERENCES sensor(id) ON DELETE CASCADE
"timed_belief_source_id_data_source_fkey" FOREIGN KEY (source_id) REFERENCES data_source(id)
flexmeasures_prod=> select * from alembic_version ;
version_num
--------------
3bc1e29ca1f4
(1 row)
flexmeasures_prod=> \d timed_belief
Table "public.timed_belief"
Column | Type | Collation | Nullable | Default
------------------------+--------------------------+-----------+----------+---------
source_id | integer | | not null |
event_start | timestamp with time zone | | not null |
belief_horizon | interval | | not null |
cumulative_probability | double precision | | not null |
event_value | double precision | | not null |
sensor_id | integer | | not null |
Indexes:
"timed_belief_pkey" PRIMARY KEY, btree (sensor_id, source_id, event_start, belief_horizon, cumulative_probability)
"timed_belief_search_session_idx" btree (event_start, sensor_id, source_id)
"timed_belief_search_session_singleevent_idx" btree (sensor_id, event_start)
Foreign-key constraints:
"timed_belief_sensor_id_sensor_fkey" FOREIGN KEY (sensor_id) REFERENCES sensor(id) ON DELETE CASCADE
"timed_belief_source_id_data_source_fkey" FOREIGN KEY (source_id) REFERENCES data_source(id)
On production db we run right now, the indexes look indeed different:
… a future-failure message Time passed since this branch was opened: main cut v1.0.0 and moved 48 commits ahead, and this branch's changelog entry had landed inside what is now that released section instead of the new unreleased one. Three conflicts, all mechanical: - changelog.rst: restored the v1.0.0 section to main's text exactly, and moved this branch's entry into the new v1.1.0 section instead. - pyproject.toml: kept this branch's timely-beliefs>=4.2.0 (still needed, for the single-column indexes it stops declaring), and took main's unrelated removal of the now-unused [forecast] extra. - uv.lock: regenerated on top of main's own lock rather than merged by hand, upgrading only timely-beliefs. The diff is mostly marker-string reformatting from a different uv version than whichever produced main's committed file -- cosmetic, and `uv sync --frozen` does not check the lock against pyproject.toml, so it does not matter which uv wrote the formatting. Also, per review: reworded the assertion message in test_dropped_index_columns_still_lead_a_composite_index so that a failure does not read as if dropping the index were a decision being made at that moment -- by the time this could fail, that already happened, in migration b7e5a2c40f18. Verified after the merge: the model still imports, the primary key and index set are unchanged, and there is a single Alembic head (main added no new migrations in the interim). flexmeasures/data: 253 passed, 3 failed. Checked each failure individually rather than assuming they were fallout from the merge: - the two test_closest_sensor failures are the same pre-existing, environment-specific ones from every earlier run on this branch (this sandbox lacks the cube/earthdistance extensions). - test_sensor_data_sources_and_data_source_sensors_load_fast (100 ms budget) is new to this run, but reproduces identically against plain origin/main with none of this branch's changes (131.5 ms there, 115-120 ms here) -- a pre-existing, machine-speed-dependent test, not something this PR causes or worsens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <claessen@seita.nl>
|
Thanks for the approval and the comment, Nicolas — the wording fix is in That same commit also merges
One thing worth flagging rather than leaving for someone to discover in CI: |
CI's `pre-commit run --all-files` reformatted these two files; the merge commit did not, because pre-commit only checks the conflicted files when it runs as part of a merge commit, and neither migration was conflicted. The reformat is due: main moved black 24.8.0 -> 26.3.1 while this branch was open, and the new stable style drops the second blank line between the imports and the `# revision identifiers` comment. Two blank lines, no code change. `black --check .` across the branch reports these two files and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <claessen@seita.nl>
This branch is based on feat/timed-belief-pk-column-order, which just took a merge of main after v1.0.0 shipped. Bringing that here keeps the two in step. One conflict, in documentation/changelog.rst, and it is the same one the base branch had: both entries had landed inside what is now the released v1.0.0 section. Restored that section to main's text and moved both entries -- this branch's and #2378's -- into the new v1.1.0 section, keeping them adjacent, as they describe two halves of one change. flexmeasures/data/models/data_sources.py merged cleanly: #2378's __table_args__ work and this branch's SensorDataSource sit in different places. Verified after the merge: the primary key order and index set are unchanged, the sensor_data_source model and the trigger constants still import, and flexmeasures/data passes 263 tests with the 2 pre-existing environment-specific test_closest_sensor failures this sandbox always shows (it lacks the cube and earthdistance extensions). Worth recording: test_sensor_data_sources_and_data_source_sensors_load_fast, the 100 ms guard on Sensor.data_sources and DataSource.sensors, fails on #2378 alone and passes here. It also fails on plain main on this machine, so it is too noisy to read as a clean before/after -- but it does at least not regress with the summary table in place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <claessen@seita.nl>
|
Thanks for running the migration against a real database — that is the part I could not check myself. On the benefits. You have the two real ones; I would rather not oversell a third.
There is a genuine cost in the other direction, which is the drawback you are circling: after the reorder no index leads with On only one index being dropped. That is the migration working as intended, not a case it failed to handle. It does not drop by name; it matches by shape — single-column, non-unique, not backing a constraint, not partial, not an expression index — on One asymmetry I would like your opinion on, since your database is precisely the case that exposes it: Two housekeeping notes since you approved:
|
|
So after downgrading, everything works but I got actually two new indexes:
We have to decide if people need to be sheltered from this - users who deliberately used less or other indexes. I see two options:
Let me know if there are any caveats or complexity issues with 2), because then I advise we go for 1) and move on. |
…are, and say what was touched Nicolas downgraded a real database and ended up with two indexes it never had: idx_tb_sensor_source_event_horizon and timed_belief_sensor_id_idx. Both upgrades take real care to touch only what is actually present -- matching by shape, checking the catalog, refusing to drop a same-named index that covers something else -- and then both downgrades threw that care away and created indexes unconditionally. The two cases are not the same, though, and the difference is not whose index it is but whether this project declares it: - timed_belief_event_start_idx and timed_belief_sensor_id_idx are declared by the project: timely-beliefs declared index=True on both columns before 4.2.0, so a database on the version being downgraded to would have had them. Recreating them is restoring schema the code declares, so this keeps doing that -- but now says that a database carrying only one of them gains the other, and that dropping it again is safe. - idx_tb_sensor_source_event_horizon is declared nowhere in this project; no model or migration creates it. Where it exists, someone added it by hand. Recreating it on downgrade handed the index to every database that downgrades, including the ones that never carried it, so the downgrade no longer creates it. It prints the CREATE INDEX statement instead, and the upgrade says up front that it will not come back. Both migrations now report what they actually did to the database in front of them, rather than what they can do in general, since the upgrade output is the only record an operator gets of which indexes were dropped. Printed as well as logged, following f1c8a3d75e29, because our logging setup does not surface the alembic logger. Verified by reproducing Nicolas' case end to end against a real database, built by running the migration chain rather than by hand: starting from a table with timed_belief_event_start_idx and neither of the other two, - upgrade drops exactly one index and names it; - downgrade recreates both single-column indexes (explaining the gain) and does not create idx_tb_sensor_source_event_horizon (printing how to); - the primary key is back to the old column order afterwards. Worth noting for whoever reads this next: the freshly migrated database had a different primary key column order again than Nicolas' dev database, which is the unpredictability this PR exists to remove. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <claessen@seita.nl>
…owngrade adds The v1.1.0 entry said the migration drops the indexes the reordered primary key makes redundant, but not that which of them a given database carries varies, nor what a downgrade puts back. Both matter to anyone maintaining their own indexes on timed_belief, and a changelog warning is where they would look. Placed as a `.. warning::` directly under the version heading, following v1.0.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <claessen@seita.nl>
The link for PR #1480 was missing the space before its URL, so docutils read the whole thing as one target name and could not resolve it. It was the only structural error the changelog produced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <claessen@seita.nl>
|
Caveats with 2), yes — enough that I agree with going for 1). But looking into it turned up something that makes 1) smaller than expected, so it is worth a moment. On 2). Capturing the state is the easy half: The thing worth changing anyway. Your two indexes are not the same case, and once separated, one of them needs no warning at all. Both upgrades already take real care to touch only what is present — matching by shape, checking the catalog, refusing to drop a same-named index that covers something else — and then both downgrades threw that away and created unconditionally. Making the downgrades as careful as the upgrades splits by whether this project declares the index:
Both migrations now report what they did to the database in front of them rather than what they can do in general, since the upgrade output is the only record you get of what was dropped. Reproduced your case end to end to check, against a database built by running the chain rather than assembled by hand — starting from One deliberate trade to flag: a database that did carry Also added a |
The primary key reorder went looking for idx_tb_sensor_source_event_horizon on every database it ran against, dropped it where it found it, and argued in the changelog about whether a downgrade should put it back. But no model or migration in this project creates that index. It exists because someone added it by hand, and the only database known to carry it is one we administer, where it can be dropped by hand just as easily. So every host was running a catalog probe for an index they do not have, and the release notes documented one deployment's private schema choice as though it were a general concern. That is the wrong layer, and it is what the review discussion on the downgrade had been circling. Removed: the REDUNDANT_INDEX constant, the catalog query that matched it, the conditional drop, the reporting that went with it, and the changelog paragraph about not recreating it. The b7e5a2c40f18 migration is untouched: the indexes it drops are declared by the project, via timely-beliefs' index=True before 4.2.0, so handling those is squarely our business. Two claims went with it, and both should have gone anyway: - the docstring called dropping that index "typically the single largest win here", which was true of one database and oversold the migration to everyone else. It now states the general fact instead, that the reordered key makes any index on a prefix of it redundant, and that FlexMeasures does not drop those because it cannot know which ones were meant to be kept. - the changelog warning said the same thing, and now carries the general note. Verified up and down against a database built by running the migration chain, with the hand-added index present as it is in production: the upgrade drops the two project-declared single-column indexes, names them, and leaves idx_tb_sensor_source_event_horizon untouched; after removing it by hand, the downgrade does not bring it back, restores both single-column indexes, and returns the primary key to its old column order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <claessen@seita.nl>
|
Went a step further than my last comment, in
It is gone now: the constant, the catalog match, the conditional drop, the reporting, and the changelog paragraph about not recreating it. The Two claims went out with it that should have gone regardless. The docstring called dropping that index "typically the single largest win here" — true of one database, and overselling the migration to everyone else. It now states the general fact instead: the reordered key leads with Verified up and down against a database built by running the chain, with the hand-added index present: the upgrade drops the two project-declared single-column indexes, names them, and leaves the hand-added one alone; after dropping that one by hand, the downgrade does not bring it back and the primary key returns to its old order. So no need for either of your options for that index — no warning, and nothing to record. Option 1's warning stays for the |
But we could print the suggestion to drop them in the migration output? |
An index whose columns are a prefix of the new primary key answers nothing the primary key does not already answer. Such an index is not part of FlexMeasures' schema, so the migration must not drop it, but it can say which ones qualify and leave the decision to the operator. The primary key's column list is read from the catalog rather than repeated in the query, and the conditions are strict on purpose: partial, expression, unique, constraint-backing, non-btree and INCLUDE indexes are all excluded, as is anything with a non-default sort order or operator class, since the primary key cannot substitute for those. Verified against a database built by the migration chain: of a hand-added prefix index and three near misses (partial, DESC, wrong column order), only the prefix index was named. The report lives in b7e5a2c40f18 rather than in d4a7c1e93b52, so that the two single-column indexes FlexMeasures drops itself are already gone by the time the list is produced. Signed-off-by: F.N. Claessen <claessen@seita.nl>
|
Good idea — done in 750f44c. The migration now looks for indexes whose columns are a prefix of the new primary key and names them, without touching them. On a database that has none (i.e. any schema FlexMeasures created itself) it stays quiet; on one that has such an index it prints: Two things I was careful about: The suggestion has to be conservative, because a human is going to act on it. A naive prefix match would name indexes that are still pulling weight, so the query excludes partial and expression indexes (they answer queries the primary key does not), unique and constraint-backing indexes, non-btree indexes, indexes with The primary key's column list is read from the catalog rather than repeated in the query, so this stays correct if we ever revise that order again. It reports from Nothing about the migration's behaviour varies by database — only its output does, which was the property that made the last round work out. |
nhoening
left a comment
There was a problem hiding this comment.
I tried it locally and am happy :)
Regenerates uv.lock (the only real change is the timely-beliefs specifier, already satisfied by main's resolved 4.2.0) and adds a merge migration joining the automations heads with the pk-reorder chain, since both landed on main independently. Signed-off-by: F.N. Claessen <claessen@seita.nl>
* Look up a sensor's most recent belief per source type without scanning its beliefs The asset page reports, per sensor, how up to date each source type's data is. It got that by running one most_recent_only search per source type, for all seven default types, for every sensor on the page. Each of those is timely-beliefs' fast track, ORDER BY event_start DESC, belief_horizon ASC LIMIT 1, but with a join to data_source filtering on its type. That filter is the part no index on timed_belief can serve: PostgreSQL walks the sensor's events from the newest backwards, rechecking the type of each belief's source until it finds a match. Where a type recorded nothing for that sensor -- the common case, since most sensors have data from one or two types -- there is no match to find, so the scan reads every belief the sensor has before returning empty. The cost is therefore paid several times per sensor and grows with the data. The sensor_data_source summary (#2382) now answers which sources ever recorded for a sensor as a handful of rows. So resolve the sensor's sources once, group them by type, and name them in the belief query instead of filtering on their type. A type with no sources is skipped without a query at all, which is where the scans were, and a named source lets the reordered primary key (#2378), which leads with (sensor_id, source_id, event_start, belief_horizon), answer the LIMIT 1 as a backwards index scan reading a single row. Measured against a sensor carrying beliefs from one source, timing the whole per-sensor status lookup: at 300k beliefs 210 ms -> 5 ms, at 1M beliefs 611 ms -> 5 ms. The old path grows with the row count; the new one does not. Semantics are unchanged. The summary is a documented superset, so it can list a source whose beliefs have since been deleted -- costing one query that returns nothing, exactly as before -- but it cannot omit a source that has data. The source and exclude_source_types filters of the staleness search are applied to the source lookup instead of to the belief query, so they still hold. Tests pin that only the types that recorded are queried, that a second type is picked up, and that both source filters are honoured. The first, second and fourth fail against the old code with 7 queries where 1 or 2 are expected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * update the comments and docstring Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com> --------- Signed-off-by: Ahmad-Wahid <ahmedwahid16101@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Shorten the timed_belief index warning so hosts don't need to read PR #2378 first, downgrade the scheduler data source warning to a note and trim it, drop the mention of "sequential schedule" as an unrecognizable internal term, and remove the inflect warning. Closes #2614 Signed-off-by: F.N. Claessen <claessen@seita.nl> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Closes steps 1 and 2 of #2377. Shipped in v1.1.0.
If you maintain your own indexes on
timed_beliefThis is what the v1.1.0 changelog points here for. Two things change for hand-added indexes on that table:
1. Any index on a prefix of the new primary key is now redundant. The key is reordered to
(sensor_id, source_id, event_start, belief_horizon, cumulative_probability), and a btree serves a leading-column lookup just as well as a dedicated index does, so the primary key already answers everything such an index answers.The migration does not drop these. They are not part of FlexMeasures' schema — only the deployment that created one knows what it was for — so
b7e5a2c40f18names the ones it finds in the upgrade output and leaves the decision to you. Keep that output. Dropping one afterwards, without locking the table:The report is deliberately strict about what it names: partial, expression, unique, constraint-backing, non-btree and
INCLUDEindexes are all excluded, as is anything with a non-default sort order or operator class, since the primary key cannot substitute for those. It is silent when there is nothing to name, which is the case for any database whose schema FlexMeasures created.2. The single-column indexes on
event_startandsensor_idare dropped for you. Those two are project schema —timely-beliefsdeclaredindex=Trueon both before 4.2.0 — and each is fully covered by a composite index the same library declares:(event_start)timed_belief_search_session_idx (event_start, sensor_id, source_id) INCLUDE (belief_horizon)(sensor_id)timed_belief_search_session_singleevent_idx (sensor_id, event_start)Whether your database carries one, both or neither depends on its age and on the
timely-beliefsversion that first created the table, so the migration reports exactly which ones it dropped. They are matched structurally rather than by name — single-column, non-unique, non-partial, not backing a constraint — so this works whatever naming convention your indexes were created under, and is a no-op where they are already gone. On a downgrade both are recreated, so a database that carried only one of them gains the other; dropping that one again is safe.Both migrations run online, so neither needs a maintenance window. See Migrations below for what that costs and what a failed run leaves behind.
What
Pins the column order of
timed_belief's primary key with an explicitPrimaryKeyConstraint, and reorders it to:The set of key columns is unchanged, so uniqueness semantics are identical — this is purely a reordering.
timely_beliefspassesindex_elementstoon_conflict_do_updateas a set, so upserts are unaffected.Also drops the two redundant single-column indexes described above, and moves the
timely-beliefsfloor to>=4.2.0in the same commit — deliberately, since dropping them while the ORM still declared them would recreate exactly thecreate_all()-vs-migrated divergence this PR set out to fix.Why pin it
TimedBeliefDBMixinmarks five columnsprimary_key=Trueand leaves the order to SQLAlchemy, which collects attributes declared on the subclass before the mixin's. So the key's shape is an accident of which columns a host happens to redeclare, and it silently changes if anyone adds a column override.That is not hypothetical: FlexMeasures'
source_idoverride already moves it to the front, and a schema built bycreate_all()therefore disagrees with a migrated one. Nothing catches that today. The new tests do.Why this order
sensor_idfirst — virtually every query filters on a single sensor. A key that does not lead with it cannot serve those queries at all, which is exactly why deployments end up hand-adding a sensor-leading composite index.source_idsecond — this makes(sensor_id, source_id, event_start, belief_horizon)a prefix of the key, so such a hand-added index becomes redundant. On the dataset profiled this was the largest and most-scanned index on the table, and the queries that used it get faster, since they are now served by a key that no longer needs a second structure maintained alongside it on every insert.cumulative_probabilitylast — it is very nearly a constant (0.5 for every deterministic belief), so it contributes no selectivity wherever it sits.sensor_idandsource_idadjacent — both are 4-byte integers, so keeping them together avoids the alignment padding that separating them forces into every index tuple. Measured at roughly 15% of the index's size, which was the most surprising result of the profiling.Migrations
d4a7c1e93b52swaps the primary key. It builds the replacement withCREATE UNIQUE INDEX CONCURRENTLYinside anautocommit_block, then promotes it withADD CONSTRAINT ... USING INDEX. Building the index is the slow part and does not block reads or writes; only the promotion takes anACCESS EXCLUSIVElock, and that is catalog-only (milliseconds), because the index already exists by then. This rebuilds an index but does not rewrite the table — no heap pages are touched, and no other index is affected.b7e5a2c40f18drops the two redundant single-column indexes,CONCURRENTLYas well, and then reports the prefix-redundant indexes it will not touch. Reporting it there rather than ind4a7c1e93b52keeps the list to indexes FlexMeasures leaves alone, since the two it drops itself are gone by that point. Both migrationsprint()as well as log, because FlexMeasures' logging setup does not surface the alembic logger and the upgrade output is the only record a deployment gets.The trade for staying online is that the concurrent steps are not transactional: a run that fails partway can leave an unused — possibly invalid —
timed_belief_pkey_newbehind. Both migrations clean up before they start, so a retry is safe.Both resolve
timed_belief's schema once from the catalog withpg_table_is_visible, rather than trustingsearch_path, so a same-named table in another schema is never touched and the checks cannot disagree with the DDL that acts on them.Downgrades restore the previous key order and recreate both single-column indexes.
Known trade-off
After the reorder no index leads with
source_id, so deleting adata_source— whose foreign key isNO ACTION— falls back to a sequential scan. Profiled and discussed in a comment below: it costs a single ~20-second statement for an operation no CLI or API path performs, against a permanent index costing ~12% of what this PR reclaims. Accepted as such; easily reversed with aCREATE INDEX CONCURRENTLYif source deletion ever becomes routine.Dropping the
sensor_idindex, by contrast, is safe for theON DELETE CASCADEon its foreign key: PostgreSQL only needs to find rows bysensor_id, andtimed_belief_search_session_singleevent_idxleads with exactly that column. A test pins that property.Verification
Driven through a real
MigrationContexttransaction against PostgreSQL, soautocommit_blockbehaves as it does underenv.py:timed_belief_pkey_newis cleaned up on retry.INCLUDEvariant of the composite, partial indexes onevent_startand onsensor_id, an expression index, a unique index onevent_start, a constraint-backed unique onsensor_id, and a single-column index on another column.timed_beliefindexes are left alone.DESC, wrong column order): only the prefix index is named, and none is dropped.Six new tests (
test_timed_belief_schema.py, no database needed) pin the key's order, assertsensor_idleads it, assert the two integer columns stay adjacent, asserttimely_beliefs' own indexes survive the__table_args__override, assert no single-column index is declared again, and assert both formerly-indexed columns still lead a composite.Full
flexmeasures/datasuite passes.🤖 Generated with Claude Code
https://claude.ai/code/session_01JkJ55eNSD5PMUbURUNBSNQ