fix(sequences): start sequences at the first detection's recorded_at - #687
Conversation
Sequences expose the capture time of the detection that started them, so the platform can show one consistent time alongside detections. The migration backfills existing rows from their earliest detection and falls back to started_at.
…d_at Drop the separate recorded_at column: started_at itself now carries the capture time of the first detection. The migration becomes data-only and realigns existing sequences on their earliest detection.
| from alembic import op | ||
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision: str = "d6f3a8b2c4e1" |
|
|
||
| # revision identifiers, used by Alembic. | ||
| revision: str = "d6f3a8b2c4e1" | ||
| down_revision: Union[str, None] = "c4e9f1a2b3d5" |
| # revision identifiers, used by Alembic. | ||
| revision: str = "d6f3a8b2c4e1" | ||
| down_revision: Union[str, None] = "c4e9f1a2b3d5" | ||
| branch_labels: Union[str, Sequence[str], None] = None |
| revision: str = "d6f3a8b2c4e1" | ||
| down_revision: Union[str, None] = "c4e9f1a2b3d5" | ||
| branch_labels: Union[str, Sequence[str], None] = None | ||
| depends_on: Union[str, Sequence[str], None] = None |
fe51
left a comment
There was a problem hiding this comment.
Hi @MateoLostanlen ,
Thanks fort the PR. I have noticed some stuff to challenge introducing this small updates, and uses Claude to detailed it. happy to discuss it
The core change is right and minimally scoped: started_at = first_det.recorded_at makes the sequence agree with what the UI already shows for detections, the revision chain stays linear and single-headed, and the new test genuinely fails on the old code.
Four inline comments cover the code changes. The rest below touches files this PR doesn't modify, so it lands here.
Freshness windows use the server clock (last_seen_at); event time and date bucketing use started_at.
That is what makes the asymmetry in this PR deliberate rather than accidental — and it decides every item below.
A. The 24h feed window now sits on a camera clock
fetch_latest_unlabeled_sequences (src/app/api/api_v1/endpoints/sequences.py:179) gates the unlabeled feed with started_at > utcnow() - 24h. After this PR that compares a camera clock against the server clock: a camera with a lagging RTC (booted without NTP) emits live sequences that silently never enter the feed, and nobody thinks to check created_at to find out why.
stmt: Any = (
select(Sequence)
# Freshness window on last_seen_at, not started_at: started_at is now the camera's
# capture clock, so a camera with a lagging RTC (booted without NTP) would emit live
# sequences that silently never enter this feed. last_seen_at is always written from
# the server clock, which keeps the window honest. Same rule as the alert feed.
.where(Sequence.last_seen_at > utcnow() - timedelta(hours=24))
.where(Sequence.is_wildfire.is_(None)) # type: ignore[union-attr]
)The semantics shift slightly, and in the feed's favour: it becomes "sequences seen in the last 24h" rather than "sequences that started in the last 24h", so a sequence that began 30h ago and is still active shows up. That matches alerts.py:97, which already windows on last_seen_at.
Optional companion, and a visible UX change so it's your call: sequences.py:194 orders by started_at.desc() and limits to 15. A camera clock running ahead pins a bogus row to the top; one running behind buries a real one. order_by(Sequence.last_seen_at.desc()) — "most recently active first" — is the consistent partner to the filter above.
B. Deliberately not changing
last_seen_atstaysdet.created_at(detections.py:646,:690). It is a liveness gate, not a display field:_get_continuity_sequencescompares it againstutcnow() - 120s(SEQUENCE_CONTINUITY_SECONDS), and sequence matching againstutcnow() - 120min. On a camera clock, a routine two-minute upload lag would drop the sequence out of the continuity window and put holes in the temporal model's frame timeline. It is also written unconditionally, with nomax()— andrecorded_atis not monotonic, so an out-of-order upload would shrink the window. Not a one-word swap.sequences.py:216(func.date(started_at) == from_date) keepsstarted_at. That query means "what happened on this day", so event time is the correct basis.overlap.py:348-349stays as is. Mixed clocks stretch each sequence's interval by the camera lag, making the temporal gate marginally more permissive, butTRIANGULATION_RELAXATION_SECONDSdefaults to 30 minutes, which swallows any realistic capture-to-insert lag.
Follow-up, as its own issue: last_seen_at is doing two jobs — server-side liveness gate and displayed end-of-event — which is why the window can't be single-clock today. Splitting them fixes it: keep last_seen_at on the server clock for the three gates, add last_recorded_at (event time) for display, the CSV duration and overlap.py. Schema change + migration + three call sites, so not this PR.
C. On the migration
The revision chain is clean — linear, single head (c4e9f1a2b3d5 → d6f3a8b2c4e1), no branch. The s.started_at <> d.recorded_at guard keeping the write set to genuinely-changed rows is the right instinct, and the single DISTINCT ON pass is the correct shape given detections.sequence_id is unindexed: a correlated subquery per sequence would be far worse.
Two things to know before running it in prod. It is a full scan and sort of detections (the largest table), and Alembic wraps it in one transaction, so every sequences row it updates stays locked until commit. Only recently-active sequences contend with ingestion, but if the migration runs 20 minutes, an unlucky detection POST blocks for 20 minutes. The sort may also spill to disk.
So: run SELECT count(*) FROM detections; first. Under ~10M rows this is likely under a minute and none of the above matters; above that, schedule it off-peak. An index on detections(sequence_id, recorded_at) would turn the scan into an index scan and speed up the ingest path too — but that belongs in the PR already introducing indexes, not this one. Rebasing on it afterwards makes the problem disappear.
downgrade as a no-op is defensible for a data-only migration, but it makes this a one-way door: the previous started_at values are gone. Worth a line in the release notes. They stay reconstructible from detections.created_at if anyone ever needs them.
There was a problem hiding this comment.
first_det must be first captured, not first inserted so must rely on recorded_at
| first_det = min(overlapping_dets, key=lambda item: (item.recorded_at, item.created_at, item.id)) |
| SELECT DISTINCT ON (sequence_id) sequence_id, recorded_at | ||
| FROM detections | ||
| WHERE sequence_id IS NOT NULL | ||
| ORDER BY sequence_id, created_at, id |
There was a problem hiding this comment.
| ORDER BY sequence_id, created_at, id | |
| ORDER BY sequence_id, recorded_at, created_at, id |
Same ordering key as first_det in create_detection, so backfilled rows match what new code writes.
(so same ordering as suggested in detections.py679)
There was a problem hiding this comment.
I think the comment is depreciated, recorded_at is not only for display
The engine may report when the image was actually captured; fall back to now when it doesn't. This is the event time the platform displays, and it now sets the sequence's started_at.
| assert seq is not None | ||
| assert seq.started_at == first_capture | ||
|
|
||
|
|
There was a problem hiding this comment.
One more test ?
The new test covers capture-vs-insertion time but not the new ordering key. Add a case where the two disagree: post two detections whose recorded_at is descending (second upload carries the earlier capture time, as in a backlog flush) and assert started_at equals the earlier recorded_at
…time A backlog flush can upload detections out of capture order; started_at is event time, so the seeding detection is min(recorded_at, created_at, id). The backfill migration uses the same ordering key, and a new test covers the out-of-order case. Also refresh the stale recorded_at comment.
started_at is now event time from the device clock (drift, backlog flush), so a late upload could silently miss the 24h feed. last_seen_at is written from the server clock, same rule as the alert feed.
21ccbe2 to
f2810ac
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #687 +/- ##
==========================================
+ Coverage 91.30% 93.87% +2.56%
==========================================
Files 3 59 +56
Lines 138 3214 +3076
==========================================
+ Hits 126 3017 +2891
- Misses 12 197 +185
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
fe51
left a comment
There was a problem hiding this comment.
@MateoLostanlen thanks for taking into account the updates, few remarks worth to read before merge, but approved
Low risk, worth evaluating rather than fixing now. started_at is computed from the seeding batch only, so a frame captured earlier but attached later (match path, detections.py:647) doesn't pull it back — the sequence would then hold a detection older than its own start, and the backfill would write a different value than the code. It only bites if uploads arrive out of capture order. Easy to count on detections when we get a moment; not a reason to hold this up.
Merge coordination: d6f3a8b2c4e1 and #664's e8f3a6c9d1b7 both revise c4e9f1a2b3d5. Git merges clean, then alembic upgrade head fails on two heads — whoever goes second repoints their down_revision. Landing #664 first also makes this backfill cheaper.
Non-blocking:
alerts.py:205can export a negative duration now the two timestamps sit on different clocks. Leaving it: nothing breaks on it, and clamping would hide the skew. Noted so it's not a surprise later.- Half a line of comment on the
ORDER BY last_seen_at, since/alerts/unlabeled/lateststill orders onstarted_at. - Check
count(*)ondetectionsand run the migration off-peak;downgradeis a no-op, so worth a line in the release notes. - e2e was red from
85fa639until today — does it run in CI?
|
Nothing worrying for me here. The clock issues aren't really issues in my view: if a Pi can send an alert it has internet, and if it has internet it's on time via NTP, so we should be fine except maybe in extremely rare cases I added a line in the release note for no-op yes ec2 runs |
recorded_at, so the platform showed two different times for the same event (Fix sequence creation timestamps to rely on recorded_at #675).started_atnow takes the first detection'srecorded_at(earliest capture time, robust to out-of-order uploads); the unlabeled feed windows and orders onlast_seen_at(server clock), same rule as the alert feed.recorded_at(no-op where both already match).downgradeis a no-op — the previousstarted_atvalues are not restored (they stay reconstructible fromdetections.created_at). RunSELECT count(*) FROM detections;first and schedule off-peak on large tables.Closes #675