Summarise which sources recorded for which sensors - #2382
Conversation
Two mirrored accessors ask which data sources have recorded for which sensors: Sensor.data_sources reads timed_belief in sensor_id order, DataSource.sensors reads it in source_id order. Both answer a question about a relation bounded by sensors times sources, a few thousand rows at most, by reading the largest table in the database. Reordering the primary key to lead with sensor_id (d4a7c1e93b52) makes the second one worse: nothing leads with source_id any more, so it degrades to a sequential scan. An index on source_id would fix that, but at a large fraction of what that migration reclaims, to answer a question about a few thousand rows. Add a sensor_data_source summary table instead, keyed on the pair. It serves both accessors as a plain lookup, so it also removes the sensor-leading one's dependence on scanning beliefs. Maintained by a statement-level trigger rather than from the save path. That was not the first design: the hook originally lived in TimedBelief.add_to_session, on the reasoning that every save funnels through it. Two existing tests then failed with empty results, because they insert beliefs without going through that path -- and so do bulk inserts, COPY, plugins and raw SQL. A hook would have left the summary silently incomplete in exactly the cases nobody checks, so the trigger does it instead: unbypassable whatever the insert route, and one small upsert per statement rather than per row. A test inserts through raw SQL specifically to pin that. The trigger and its function are created both by the migration and by a create_all DDL listener, so a schema built either way behaves the same. Both are written idempotently, since create_all runs once per test module. The summary is deliberately a superset: pairs are added on insert and not removed on delete, because deciding whether a pair went stale needs exactly the scan this avoids. Sensor.search_data_sources still reads timed_belief whenever time filters are given, so time-bounded questions stay exact. A test pins the superset behaviour so it reads as a choice rather than a surprise. Verified against PostgreSQL: the backfill recovers exactly the distinct pairs (6 from 12000 belief rows in the fixture), deleting a data source cascades its summary rows away, and the downgrade removes trigger, function and table. flexmeasures/data: 256 passed. 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>
There was a problem hiding this comment.
Pull request overview
This PR introduces a sensor_data_source (sensor_id, source_id) summary table (maintained by a PostgreSQL statement-level trigger) so Sensor.data_sources and DataSource.sensors can answer “which sources recorded for which sensors” without scanning the large timed_belief table.
Changes:
- Add
sensor_data_sourcemodel + migration with backfill and trigger/function to maintain the summary on inserts intotimed_belief. - Update
Sensor.search_data_sourcesandDataSource.sensorsto read from the summary table when no time filters are provided. - Add dedicated tests covering trigger maintenance (including raw SQL insert), idempotency, and the “superset” semantics; add changelog entry.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| flexmeasures/data/tests/test_sensor_data_source.py | New tests for summary-table maintenance, superset semantics, and time-filtered correctness |
| flexmeasures/data/models/time_series.py | Switch unfiltered sensor→sources lookup to sensor_data_source; add create_all DDL hook to create trigger/function on PostgreSQL |
| flexmeasures/data/models/data_sources.py | Add SensorDataSource model and switch DataSource.sensors to query via the summary |
| flexmeasures/data/migrations/versions/f1c8a3d75e29_add_sensor_data_source_table.py | Migration to create/backfill summary table and install trigger/function; downgrade removes them |
| documentation/changelog.rst | Add Infrastructure/Support changelog entry for the new summary-table lookup |
Suppressed comments (3)
flexmeasures/data/models/time_series.py:1226
- Comment wraps mid-phrase (line ends with "insert"), violating the repo’s docstring/comment line-break convention (each line must end with punctuation). See .github/instructions/docstrings.instructions.md:38.
# FOR EACH STATEMENT with a transition table costs one small upsert per insert
# statement, however many rows that statement carries, rather than one per row.
flexmeasures/data/models/data_sources.py:361
- This docstring wraps mid-phrase (line ends with
source_id), violating the repo’s docstring/comment line-break convention (each line must end with punctuation). See .github/instructions/docstrings.instructions.md:38.
Reads the ``sensor_data_source`` summary rather than ``timed_belief``.
Answering this from the beliefs table would mean scanning it in ``source_id``
order, which no index serves, to produce a handful of rows.
flexmeasures/data/migrations/versions/f1c8a3d75e29_add_sensor_data_source_table.py:13
- The migration module docstring wraps mid-phrase (line break between “rather than” and “by application code”), violating the repo’s docstring/comment line-break convention (each line must end with punctuation). See .github/instructions/docstrings.instructions.md:38.
The table is kept current by a statement-level trigger on ``timed_belief`` rather than
by application code.
Documentation build overview
5 files changed ·
|
Reflow the docstrings and inline comments added here so that every physical line ends at a comma, semicolon, colon or period, per .github/instructions/docstrings.instructions.md. Copilot flagged six places; three more were found by checking every added prose line against the rule rather than only the reported ones. That scan is the reliable way to catch these -- this is the third review round across these branches to raise the same convention, so relying on remembering it while writing has not worked. flexmeasures/data: 256 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <felix@seita.nl>
|
All four addressed in Worth saying plainly: this is the third review round across these branches to raise the same rule, and I had already saved it after the first. Remembering it while writing has not worked; running the scan before pushing does. That is what I will do on the remaining PRs.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
flexmeasures/data/models/time_series.py:1255
- The trigger DDL is attached to
TimedBelief.__table__'safter_createevent, but the DDL referencessensor_data_source. Because there is no FK dependency betweentimed_beliefandsensor_data_source,create_all()table creation order can change and could attempt to create the trigger beforesensor_data_sourceexists, which would fail schema creation. Attaching this DDL todb.metadataafter_createmakes it robust by running after all tables are created.
event.listen(
TimedBelief.__table__,
"after_create",
RECORD_SENSOR_DATA_SOURCES_DDL.execute_if(dialect="postgresql"),
)
flexmeasures/data/models/time_series.py:785
Sensor.data_sourcesnow delegates tosearch_data_sources(), which (without time filters) uses thesensor_data_sourcesummary rather than scanningtimed_belief. Thedata_sourcesdocstring still describes the old distinct-from-timed_beliefapproach and claims scalability properties that no longer match the implementation.
This issue also appears on line 1251 of the same file.
DataSource.id.in_(
select(SensorDataSource.source_id).where(
SensorDataSource.sensor_id == self.id
)
)
…docstrings The trigger DDL was attached to timed_belief's own after_create, but the function it installs refers to sensor_data_source, and nothing orders that table's creation relative to timed_belief's. The failure this could cause is narrower than it looks: plpgsql resolves table references lazily, so creating the function and trigger succeeds even when the referenced table does not exist yet, which is why the tests passed. Verified that directly against PostgreSQL rather than assuming it. But relying on late binding is a fragile reason for it to work -- a function written in plain SQL would be bound eagerly and fail -- so move the listener to the metadata, where it runs once every table exists. Doing so needed the DDL to become a small Python listener rather than a DDL object, because guarding it in SQL would mean nesting dollar-quoted bodies inside a DO block. The listener skips quietly unless both tables are present, so a partial create_all does not break. Also fix two docstrings that still described the old implementation. Sensor.data_sources claimed to scale by not fetching every belief row, which stopped being how it works; and search_data_sources had no note that where its answer comes from now depends on whether time filters are given, which is the one thing a caller needs to know. flexmeasures/data: 256 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <felix@seita.nl>
|
Round 2 addressed in Trigger DDL ordering. Moved the listener from One correction to the reasoning, though: the failure described would not actually happen. plpgsql resolves table references lazily, so creating the function and trigger succeeds even when That meant turning the Stale docstrings. Correct, and a fair hit — I had flagged that exact claim as misleading when writing the issue, then left it in place.
|
… lost The migration backfilled sensor_data_source and only then created the trigger, both inside Alembic's transaction. That loses data. The backfill reads a snapshot taken when its statement begins, so beliefs inserted while it runs are not in it, and a trigger created afterwards in the same transaction is not visible to the sessions doing that inserting either, so nothing ever records those pairs. On a large table the backfill takes minutes, so the window is not theoretical. Create the trigger first and commit it, then backfill. The two then overlap rather than leaving a gap between them, and ON CONFLICT DO NOTHING absorbs the overlap. Because the steps now commit as they go, a run that fails during the backfill leaves the table and trigger behind, so creating the table is guarded and a retry gets past it. Demonstrated both ways by stepping two connections explicitly, with the second inserting a new pair between the backfill and the trigger creation. The first attempt used a sleep and failed to reproduce the loss, which would have made the problem look theoretical; sequencing the connections instead shows the old order dropping the pair and the new order keeping it. Also merges the base branch, whose only conflict was the changelog: this branch still carried the pre-correction "three indexes" wording alongside its own entry, so the resolution keeps both entries with the base's wording. flexmeasures/data: 256 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <claessen@seita.nl>
|
Merge conflict resolved and the base branch merged in ( Also in that commit: the migration ordering fix from the outstanding review comment above. One housekeeping note — the earlier commits on this branch show as Unverified ( |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (3)
flexmeasures/data/tests/test_sensor_data_source.py:94
- This idempotency assertion can’t detect duplicates because pairs() is a set (and even if it were a list, the table’s primary key already prevents duplicates). If the goal is to pin idempotency at the DB level, query the table directly for the row count of the specific pair.
matching = [p for p in pairs(db) if p == (sensor.id, source.id)]
assert len(matching) == 1
flexmeasures/data/migrations/versions/f1c8a3d75e29_add_sensor_data_source_table.py:1
- The migration module docstring’s summary line should follow the repo’s docstring convention (capitalized and ending with a period). Most other migrations use this style, and it helps keep autogenerated docs consistent.
"""add the sensor_data_source summary table
flexmeasures/data/tests/test_sensor_data_source.py:18
- pairs() currently returns a set of SQLAlchemy Row objects. The tests then compare those against plain (sensor_id, source_id) tuples; that relies on Row hashing/equality details and is easy to misread. Returning explicit (int, int) tuples makes the intent clear and robust.
This issue also appears on line 93 of the same file.
def pairs(db) -> set:
return set(
db.session.execute(
select(SensorDataSource.sensor_id, SensorDataSource.source_id)
).all()
nhoening
left a comment
There was a problem hiding this comment.
This looks like a great idea, but I need a little help / better explanations when looking at some details.
| connection.execute(sa_text(RECORD_SENSOR_DATA_SOURCES_TRIGGER)) | ||
|
|
||
|
|
||
| event.listen(db.metadata, "after_create", create_sensor_data_source_trigger) |
There was a problem hiding this comment.
"after_create" is attached to CREATE events.
How are we sure this trigger will be installed? Which CREATE events will install it?
Is it the one in this migration, when the table sensor_data_source is created?
I miss an explicit explanation of this, and the text in lines 1264f is too technical. (What is "plpgsql"?)
There was a problem hiding this comment.
Good question, and the docstring did not answer it. Rewritten in e04891c6 to name both routes explicitly:
A FlexMeasures database gets its schema in one of two ways, and each needs its own route to the trigger:
- built by Alembic migrations, as in production: migration f1c8a3d75e29 installs it there.
- built by
db.create_all(), as in the test suite and some development setups: this function installs it there.
So it is not the migration that triggers this listener — the two are independent paths to the same objects. after_create fires when SQLAlchemy has created the tables in the metadata, which is why it listens on the metadata rather than on timed_belief: listening on the one table would be a bet on creation order, since the trigger's function reads sensor_data_source and nothing guarantees that table is created first.
I dropped the word plpgsql; it now says a function written in PostgreSQL's procedural language does not look up the tables it names until it first runs — which is why the wrong order would happen to work today, and why relying on that is fragile.
…ing claims Three points from review, all fair. The comment on why a trigger is used, rather than code in the save path, assumed the reader already knew why the save path is not enough. Say it plainly instead: beliefs reach timed_belief by several routes, code in one route only ever sees the beliefs that took it, and a trigger on the table sees every insert whatever the route. The per-statement behaviour is now stated concretely too -- a million beliefs in one statement adds one small insert, not a million. The listener's docstring did not say when it actually runs. It now names both routes a database can take to the trigger: Alembic migrations install it in production, this listener installs it for schemas built by create_all(), which is the test suite and some development setups. It also explains why it listens on the metadata rather than on timed_belief -- that would bet on table creation order -- without leaning on the word plpgsql. test_data_source_sensors_uses_the_summary claimed to show the answer came from the summary rather than from timed_belief, and did not: it saved a belief and checked the accessor found the sensor, which passes either way. Replaced with a test that writes a pair into the summary for which no belief exists, so an accessor reading timed_belief could not return it and one reading the summary must. Confirmed it discriminates by temporarily restoring the old belief-scanning implementation, against which it fails. The two behavioural tests remain, renamed to say what they do check. flexmeasures/data: 257 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: F.N. Claessen <claessen@seita.nl>
nhoening
left a comment
There was a problem hiding this comment.
Thanks for the improved explanations!
I see one thing to discuss - we have duplicated SQL functions now, CREATE_FUNCTION and CREATE_TRIGGER in the migration are equal to RECORD_SENSOR_DATA_SOURCES_FUNCTION and RECORD_SENSOR_DATA_SOURCES_TRIGGER i time_series.py.
You wrote that this is on purpose, as the migration should keep doing what it does.
Now, if we change the code in time_series.py one day - so that create_sensor_data_source_trigger() now has some different effect: When will that new trigger ever be installed, in an existing production system? Only when -some day- a new table is created, so that after_create happens as an event, I reckon.
This might be misleading to future developers, who would change the code and expect the trigger will work as they changed it, immeditately.
Closes #2381. Part of #2377. Based on #2378, so review that first — this targets its branch and the diff will shrink once it merges.
What
Adds a
sensor_data_source (sensor_id, source_id)summary table, so the two accessors that ask "which sources recorded for which sensors" stop readingtimed_belief.Why
Sensor.data_sourcesSELECT DISTINCT source_id FROM timed_belief WHERE sensor_id = ?DataSource.sensorsSELECT DISTINCT sensor_id FROM timed_belief WHERE source_id = ?Both answer a question about a relation bounded by sensors × sources — a few thousand rows at most — by reading the largest table in the database.
#2378 makes the second one worse: once the primary key leads with
sensor_id, nothing leads withsource_id, so it degrades to a sequential scan. An index onsource_idwould fix that, but at a large fraction of what #2378 reclaims, to answer a question about a few thousand rows. This is a data-modelling problem, not an indexing one.Maintained by a trigger, and that was not the first design
The maintenance hook originally lived in
TimedBelief.add_to_session, on the reasoning that every save funnels through it.Two existing tests then failed with empty results — because they insert beliefs without going through that path. So do bulk inserts,
COPY, plugins and raw SQL. A hook would have produced a summary that is silently incomplete in exactly the cases nobody checks.So a statement-level trigger maintains it instead: unbypassable whatever the insert route, and one small upsert per insert statement rather than per row, using a transition table. A test inserts through raw SQL specifically to pin that.
The trigger and its function are created both by the migration and by a
create_allDDL listener, so a schema built either way behaves identically — the same class of divergence #2378 exists to fix. Both are idempotent, sincecreate_allruns once per test module.The honest limitation
It is a superset. Pairs are added on insert and not removed on delete, because deciding whether a pair went stale needs exactly the scan this table avoids. A row means "this source has recorded for this sensor at some point", not "right now".
That is the right trade for the discovery and filtering these accessors feed, and
Sensor.search_data_sourcesstill readstimed_beliefwhenever time filters are given — so time-bounded questions stay exact. Both properties have tests.Verification
Against PostgreSQL:
save_to_dbstill records the pair.flexmeasures/data: 256 passed, with the two pre-existingtest_closest_sensorfailures unchanged (environmental — my sandbox lackscube/earthdistance).Not yet measured
The real distinct-pair count at production scale. The bound is sensors × sources; worth confirming when a full dataset is next available, but it does not affect the design.
🤖 Generated with Claude Code