fix(ml): create null detection markers only after real saves succeed#1312
fix(ml): create null detection markers only after real saves succeed#1312mihow wants to merge 9 commits into
Conversation
Issue #1310: null detections (empty-bbox sentinels marking "image processed, nothing found") were created before create_detections / create_classifications / create_and_update_occurrences_for_detections ran. Two consequences: 1. If any of those downstream steps failed, the image was already flagged as processed via the null marker — filter_processed_images would skip it on the next run, leaving the image permanently in a "processed but no detections" state. Observed on project 171 (400 captures with only null detections). 2. create_and_update_occurrences_for_detections iterated every detection including nulls, so each null marker spawned a phantom Occurrence with determination=NULL. Fix in ami/ml/models/pipeline.py save_results: - Run create_detections / create_classifications / create_and_update_occurrences on the real DetectionResponses only. - After those succeed, build null DetectionResponses for images that ended up without any detections and persist them via a second create_detections call. - Null responses never enter the classification / occurrence loops, so no phantom Occurrence is created even in the happy path. Tests in ami/ml/tests.py TestPipeline: - test_null_detection_does_not_create_phantom_occurrence: asserts the happy path "pipeline found nothing" creates the null marker but no Occurrence. - test_captures_not_marked_processed_after_failure: asserts that when a downstream step (create_classifications) raises, the image without a real detection is left unmarked and filter_processed_images re-yields it. Co-Authored-By: Claude <noreply@anthropic.com>
✅ Deploy Preview for antenna-ssec canceled.
|
✅ Deploy Preview for antenna-preview canceled.
|
✅ Deploy Preview for antenna-preview canceled.
|
📝 WalkthroughWalkthroughDefers creation/persistence of null-marker detections until after real detections/classifications/occurrence persistence; centralizes null-marker representation and query helpers, updates call-sites to use Detection.objects.valid()/null_markers(), adds a cleanup command, and extends tests and docs. ChangesFix captures marked as processed with zero detections
Sequence Diagram(s) sequenceDiagram
participant Pipeline
participant DB as Database
participant DetectionSvc as DetectionService
participant Classifier as ClassificationService
participant Broker
Pipeline->>DetectionSvc: run detections -> real DetectionResponses
DetectionSvc-->>Pipeline: detection responses
Pipeline->>Classifier: create_classifications(detections)
Classifier-->>Pipeline: classifications
Pipeline->>DB: create_detections(detections, classifications, occurrences)
DB-->>Pipeline: persisted detections/classifications/occurrences
Pipeline->>Broker: create_detection_images.delay(persisted_ids)
Broker-->>Pipeline: dispatch ack
alt no real detections for an image
Pipeline->>DB: create_detections(null_detection_responses) %% final step
DB-->>Pipeline: persisted null markers
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR fixes a pipeline persistence ordering bug where null (bbox=None) “processed, nothing found” detection markers were created too early, causing images to be skipped on retry after downstream failures and occasionally creating phantom Occurrence rows tied only to null detections.
Changes:
- Reorders
save_resultsto persist real detections/classifications/occurrences first, then creates null detection markers in a second pass. - Ensures null detections never enter the classification/occurrence creation paths.
- Adds regression tests for “no phantom occurrence on null” and “no processed marker after failure”.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
ami/ml/models/pipeline.py |
Moves null-marker creation to after real detection/classification/occurrence persistence and saves nulls via a separate create_detections call. |
ami/ml/tests.py |
Adds tests covering the phantom-occurrence regression and retry behavior after a simulated downstream failure. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Plan for the takeaway-review follow-up work on PR #1312: move-null-to-end + null-detection abstraction (DetectionQuerySet.valid / .null_markers, Detection.is_null_marker, Detection.build_null_marker) + sweep call sites + tighten OccurrenceQuerySet.valid + cleanup command for project 171. Captures rationale for splitting transaction.atomic into a separate follow-up PR (PR-1261 scar). Co-Authored-By: Claude <noreply@anthropic.com>
Adds test_null_marker_not_persisted_when_broker_dispatch_fails to TestPipeline. Patches create_detection_images.delay to raise, asserts the unmatched image has no null marker persisted and that filter_processed_images yields it for re-processing. Verified RED against current ordering — null persistence still runs before delay, so the assertion fails. Next commit moves null persistence to the absolute final step. Co-Authored-By: Claude <noreply@anthropic.com>
Closes the failure window the previous fix left open: null markers were persisted after real-detection / classification / occurrence saves but BEFORE source_image.save, create_detection_images.delay, update_calculated_fields_for_events, and Deployment.update_calculated_fields. A raise in any of those four steps (broker outage, DB error) still left the image flagged as processed. Null markers now run as the last write in save_results so they only persist when every prior step succeeds. Remaining failure window is the return statement. Makes RED test from prior commit pass. Co-Authored-By: Claude <noreply@anthropic.com>
Introduces a single source of truth for "this detection row is a sentinel that records that an algorithm ran against an image and found nothing": - Detection.NULL_BBOX = None (canonical bbox value for new null markers) - Detection.is_null_marker (recognises both bbox=None and legacy bbox=[]) - Detection.build_null_marker(source_image, detection_algorithm) classmethod - DetectionQuerySet.valid() — consumer default, excludes null markers - DetectionQuerySet.null_markers() — narrow, for "has this image been processed?" checks (renamed from .null_detections()) valid() is named to grow: future predicates to fold in include soft-delete tombstones, detections missing an algorithm reference, and detections missing classifications. Consumers asking "give me detections" should default to .valid(). Adds TestDetectionNullMarker covering: is_null_marker for bbox=None / bbox=[] / real bbox, build_null_marker field setup, and disjointness of .valid() / .null_markers() over a fixture with all three row types. Next commit sweeps existing inline NULL_DETECTIONS_FILTER usage to the new API. Co-Authored-By: Claude <noreply@anthropic.com>
…null_markers() Migrates 7 inline NULL_DETECTIONS_FILTER usages to the new manager methods: - Detection.objects.exclude(NULL_DETECTIONS_FILTER) → .valid() - self.detections.exclude(NULL_DETECTIONS_FILTER) → self.detections.all().valid() - subquery .exclude(NULL_DETECTIONS_FILTER) → .valid() - aggregate filter at SourceImageCollectionQuerySet.with_source_images_with_detections_count was the drifted inline ~Q(...bbox__isnull=True) & ~Q(...bbox=[]); now uses a new null_detections_q(prefix) helper for relation-prefixed Q expressions. Touched: - ami/main/models.py: Deployment.get_detections_count, Event.get_detections_count, SourceImage.create_occurrences_from_detections, _annotate_detections_count_subquery, SourceImageCollectionQuerySet.with_source_images_with_detections_count - ami/main/api/views.py: OccurrenceViewSet prefetch_queryset, DetectionViewSet.queryset - ami/ml/models/pipeline.py: filter_processed_images null-only and unclassified checks NULL_DETECTIONS_FILTER constant is retained at module level. Direct get_or_create_detection lookup keeps bbox__isnull=True (algorithm-scoped, narrower than .null_markers() which also includes legacy bbox=[] from other pipelines); added a comment pointing readers to Detection.NULL_BBOX for the canonical sentinel. 176/176 tests in ami.ml, ami.main.TestDetectionNullMarker, ami.jobs pass. Co-Authored-By: Claude <noreply@anthropic.com>
OccurrenceQuerySet.valid() previously only excluded occurrences with no detections at all. Field bug from Issue #1310 created two new phantom shapes that still leaked to the API: 1. Occurrences whose only detections are null-marker sentinels (no real bounding box backing them). 2. Occurrences with determination__isnull=True. valid() now requires at least one .valid() Detection (real, non-null) AND a non-null determination. Built on top of the new Detection.objects.valid() helper so both layers stay in sync as the predicate grows (soft-delete, missing algo, etc.). Downstream callers updated automatically: ami/exports/format_types.py and OccurrenceViewSet.get_queryset both invoke OccurrenceQuerySet.valid and will now filter out the project-171 phantoms once existing rows are cleaned up (next commit). Adds TestOccurrenceValidQuerySet covering all three exclusion shapes. Co-Authored-By: Claude <noreply@anthropic.com>
One-shot per-project cleanup for the Issue #1310 field bug. Deletes: - Phantom occurrences (no valid detections OR null determination) - Orphan null-marker Detection rows on source images with no real detections After running, the affected source images become eligible for re-processing by filter_processed_images on the next ML run. Dry-run by default; pass --commit to delete. python manage.py cleanup_null_only_occurrences --project 171 # dry-run python manage.py cleanup_null_only_occurrences --project 171 --commit Idempotent — re-running on a cleaned project reports zero candidates and exits without touching the database. Adds TestCleanupNullOnlyOccurrencesCommand covering dry-run, commit, and idempotency. Valid occurrences and null markers on images with at least one real detection are explicitly preserved. Co-Authored-By: Claude <noreply@anthropic.com>
|
Claude says: Pushed takeaway-review follow-up commits on top of the original fix. Summary of what's new since the original push: Commits added (e5f004a is the new HEAD):
What's NOT in this PR (deferred to follow-up):
Local test status:
Manual e2e on a dev deployment is queued next. |
|
Claude says: E2E validation on a dev deployment complete. All three paths pass. Path 1 — Happy path (async_api job)Job completed in 28.21s. 10 images, 68 detections, 121 classifications, 0 failed. NATS path through the new ordering works end-to-end. Path 2 — Broker outage simulationLive Pre-fix behavior would have shown Path 3 — Calc-field DB errorSame shape, with Same result — null marker never persisted because the failure runs before null creation in the new order. Cleanup command dry-runRan on dev DB to validate idempotent dry-run behavior (the real project-171 cleanup will run after merge against a prod DB): The 112 phantoms match the delta between Branch is in good shapeTest counts:
Ready for review. |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
ami/main/api/views.py (1)
613-628:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
has_detectionsstill disagrees withwith_detections.This prefetch correctly drops null markers, but
filter_by_has_detections()still checks for anyDetectionrow./captures/?has_detections=true&with_detections=truecan therefore return captures whosefiltered_detectionsis empty.Suggested fix
def filter_by_has_detections(self, queryset: QuerySet) -> QuerySet: has_detections = self.request.query_params.get("has_detections") if has_detections is not None: has_detections = BooleanField(required=False).clean(has_detections) queryset = queryset.annotate( - has_detections=models.Exists(Detection.objects.filter(source_image=models.OuterRef("pk"))), + has_detections=models.Exists( + Detection.objects.valid().filter(source_image=models.OuterRef("pk")) + ), ).filter(has_detections=has_detections) return queryset🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ami/main/api/views.py` around lines 613 - 628, The has_detections check currently looks for any Detection row while filtered_detections drops null/non-qualifying rows, causing mismatches; update filter_by_has_detections to only count detections that meet the same criteria used for the prefetch (i.e. use the annotated Detection queryset created from Detection.objects.valid() that includes occurrence_meets_criteria or an Exists/Subquery against qualifying_occurrence_ids) so the filter requires existence of at least one Detection with occurrence_meets_criteria=True (or occurrence_id in qualifying_occurrence_ids and score >= score) rather than any Detection row; adjust the logic referencing filter_by_has_detections, Detection.objects.valid(), occurrence_meets_criteria, qualifying_occurrence_ids and filtered_detections accordingly.ami/ml/models/pipeline.py (1)
442-459:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winDedup the legacy
bbox=[]null marker here too.This lookup only matches canonical
bbox IS NULLrows. If the image already has a legacy empty-list sentinel for the same algorithm, reprocessing will create a second null marker instead of reusing the existing one.Suggested fix
- existing_detection = Detection.objects.filter( - source_image=source_image, - bbox__isnull=True, - detection_algorithm=detection_algo, - ).first() + existing_detection = ( + Detection.objects.filter( + source_image=source_image, + detection_algorithm=detection_algo, + ) + .null_markers() + .first() + )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@ami/ml/models/pipeline.py` around lines 442 - 459, The dedupe query only checks for bbox__isnull=True and misses legacy empty-list sentinel rows, causing duplicate null markers; update the existing_detection lookup in the pipeline to include both canonical NULL and the legacy sentinel by OR-ing bbox__isnull=True with bbox equal to the legacy sentinel (use Detection.NULL_BBOX or the empty-list value) — e.g., replace the single filter(...) call that sets existing_detection with a query using Q(...) or bbox__in=[None, Detection.NULL_BBOX] while keeping the same source_image and detection_algorithm (refer to Detection, existing_detection, detection_algo, and detection_resp.algorithm.key).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@ami/main/management/commands/cleanup_null_only_occurrences.py`:
- Around line 76-82: The success message currently uses the delete() return
values (null_deleted, phantom_deleted) which include cascaded rows; replace
those with the pre-calculated counters (null_count and phantom_count) when
writing the final message—inside the same transaction.atomic() block after
orphan_null_markers.delete() and phantom_occs.delete(), call
self.stdout.write(self.style.SUCCESS(f"Deleted {phantom_count} phantom
occurrences and {null_count} orphan null markers.")) so the log reports only the
intended occurrence counts.
In `@ami/main/models.py`:
- Around line 4165-4169: The sampling for detections_only is out of sync: the
annotate uses source_images_with_detections_count with
filter=~null_detections_q("images__detections__") but
SourceImageCollection.sample_detections_only() still uses the simpler
detections__isnull=False and can include null-marker-only images; update
sample_detections_only to apply the same filter logic (i.e. use
null_detections_q("images__detections__") negated or reuse the same annotated
queryset/condition) so the sampled collection matches the
source_images_with_detections_count semantics and only includes images that the
new counter considers as having detections.
- Around line 2837-2844: The null-marker builder currently sets
timestamp=timezone.now() in Detection.build_null_marker which forces the marker
to use processing time; remove that explicit timestamp (or set timestamp=None)
so the Detection instance is created without a timestamp and Detection.save()
can backfill the correct capture timestamp; update the build_null_marker
constructor call that uses cls(NULL_BBOX, source_image, detection_algorithm,
...) accordingly and keep other fields (source_image, bbox=cls.NULL_BBOX,
detection_algorithm) unchanged.
---
Outside diff comments:
In `@ami/main/api/views.py`:
- Around line 613-628: The has_detections check currently looks for any
Detection row while filtered_detections drops null/non-qualifying rows, causing
mismatches; update filter_by_has_detections to only count detections that meet
the same criteria used for the prefetch (i.e. use the annotated Detection
queryset created from Detection.objects.valid() that includes
occurrence_meets_criteria or an Exists/Subquery against
qualifying_occurrence_ids) so the filter requires existence of at least one
Detection with occurrence_meets_criteria=True (or occurrence_id in
qualifying_occurrence_ids and score >= score) rather than any Detection row;
adjust the logic referencing filter_by_has_detections,
Detection.objects.valid(), occurrence_meets_criteria, qualifying_occurrence_ids
and filtered_detections accordingly.
In `@ami/ml/models/pipeline.py`:
- Around line 442-459: The dedupe query only checks for bbox__isnull=True and
misses legacy empty-list sentinel rows, causing duplicate null markers; update
the existing_detection lookup in the pipeline to include both canonical NULL and
the legacy sentinel by OR-ing bbox__isnull=True with bbox equal to the legacy
sentinel (use Detection.NULL_BBOX or the empty-list value) — e.g., replace the
single filter(...) call that sets existing_detection with a query using Q(...)
or bbox__in=[None, Detection.NULL_BBOX] while keeping the same source_image and
detection_algorithm (refer to Detection, existing_detection, detection_algo, and
detection_resp.algorithm.key).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 277e1e60-2244-4c0e-9ce4-f2f61f800478
📒 Files selected for processing (8)
ami/main/api/views.pyami/main/management/commands/cleanup_null_only_occurrences.pyami/main/models.pyami/main/tests.pyami/ml/models/pipeline.pyami/ml/tests.pydocs/claude/planning/pr-1312-null-marker-followup.mddocs/claude/sessions/2026-05-19-pr-1312-premptive-processed-marker.md
✅ Files skipped from review due to trivial changes (2)
- docs/claude/sessions/2026-05-19-pr-1312-premptive-processed-marker.md
- docs/claude/planning/pr-1312-null-marker-followup.md
Five drift / quick-win fixes from PR #1312 review: 1. ami/main/api/views.py — filter_by_has_detections now uses Detection.objects.valid() so /captures/?has_detections=true agrees with the with_detections prefetch (which already drops null markers). Without this, has_detections=true could return captures whose filtered_detections array was empty. 2. ami/main/models.py — SourceImageCollection.sample_detections_only now samples by Detection.objects.valid() instead of detections__isnull=False, matching the tightened source_images_with_detections_count annotation. A detections_only collection no longer admits images that have only null markers. 3. ami/main/models.py — Detection.build_null_marker drops timestamp=timezone.now(). Detection.save() backfills timestamp from the source image's capture time, so the marker sorts/filters by capture time rather than processing time. Test asserts timestamp is None on the builder output. 4. ami/ml/models/pipeline.py — get_or_create_detection null-marker dedup now goes through .null_markers() so legacy bbox=[] sentinels from older runs are re-used. The lookup is still detection_algorithm-scoped, so the wider .null_markers() predicate stays narrow at the call site (no false matches across algorithms). 5. ami/main/management/commands/cleanup_null_only_occurrences.py — success message now reports the pre-calculated phantom_count / null_count instead of the .delete() return tuple, which includes cascade-deleted rows and would mislead the operator about what the command targeted. 68/68 in ami.main null-marker tests + ami.ml.tests pass. Co-Authored-By: Claude <noreply@anthropic.com>
|
Claude says: Pushed CodeRabbit feedback round in Inline-thread items (3) — resolved on each thread:
Outside-diff items (2) — applied in same commit:
68/68 in |
|
Claude says: Cleanup command validated on a dev deployment (project 9) — 112 phantom occurrences + 112 orphan null markers deleted, post-cleanup counts match |
Summary
Fixes #1310.
Null detections (empty-bbox sentinels marking "image processed, nothing found") were being created before the downstream save steps inside
save_results. Two consequences:filter_processed_imageswould then skip it on retry, leaving the image permanently stuck as "processed, zero detections." Observed on a project where ~400 captures had only null detections and no real ones.create_and_update_occurrences_for_detectionsiterated every detection including nulls, so each null marker spawned anOccurrencewithdetermination=NULL. Those leaked throughOccurrenceQuerySet.valid()(which only excluded occurrences with zero detections, not occurrences whose only detection is a null).Reviewer heads-up — silent semantic change to
OccurrenceQuerySet.valid()Commit 5 changes the meaning of
Occurrence.objects.valid()from "has any detection" to "has at least oneDetection.objects.valid()row ANDdeterminationis not null." Three call sites pick this up without any line change at the call site:ami/main/api/views.py:1221—OccurrenceViewSet.get_queryset. Intended target of the fix. Phantom occurrences stop appearing in the list endpoint.ami/main/api/views.py:1886— project summary statsoccurrences_count. Will silently decrease on any deployment that has accumulated phantoms (e.g. project 9: 112; project 171: ~400). No-op on clean deployments.ami/exports/format_types.py:52,221— DwC-A export. Null-determination occurrences will be excluded from exports. Probably correct (DwC requirestaxonID) but not validated against an actual export run in this PR.If any of those three are load-bearing in a way I'm missing, flag it.
Changes (7 commits)
test(ml)— RED test for broker-outage path: asserts null marker is never persisted ifcreate_detection_images.delayraises andfilter_processed_imagesre-yields the image.fix(ml)— move null persistence to the absolute final step insave_results. Null markers now run aftersource_image.save()loop,create_detection_images.delay(),update_calculated_fields_for_events, andDeployment.update_calculated_fields(save=True). Closes the silent-bug window the prior reorder left open (Copilot review caught this on the original PR).refactor(main)— null-marker abstraction onDetection.Detection.NULL_BBOX = None— canonical sentinel value for new writes.Detection.is_null_markerproperty — recognises bothbbox=Noneand legacybbox=[].Detection.build_null_marker(source_image, detection_algorithm)classmethod — single construction point.DetectionQuerySet.valid()— consumer default (excludes null markers; named to grow with soft-delete / missing-algorithm / missing-classifications predicates over time).DetectionQuerySet.null_markers()— narrow, for "has this image been processed?" checks. Renamed from.null_detections().refactor(main)— sweep 9 inlineNULL_DETECTIONS_FILTERcall sites to the new manager methods. Migratesami/main/models.py,ami/main/api/views.py,ami/ml/models/pipeline.py. Fixes the drifted inline atSourceImageCollectionQuerySet.with_source_images_with_detections_countvia a newnull_detections_q(prefix)helper for relation-prefixed Q expressions.fix(main)— tightenOccurrenceQuerySet.valid()to require at least oneDetection.objects.valid()row AND a non-null determination. Closes the phantom-Occurrence leak from prod data — no code changes needed inOccurrenceViewSetorformat_types.pybecause they already call.valid(). See "Reviewer heads-up" above for the consumers that pick up the new semantic.feat(main)—cleanup_null_only_occurrencesmanagement command for per-project cleanup of the field bug. Dry-run default. Deletes phantom occurrences (no valid detections OR null determination) and orphan null-marker Detection rows on source images with no real detections. Idempotent.docs(planning)— PR follow-up plan + prior session notes indocs/claude/.Test plan
test_null_marker_not_persisted_when_broker_dispatch_fails— RED, then GREEN after move-to-end.TestDetectionNullMarker(5 tests) —is_null_markerforNone/[]/ real bbox,build_null_markerfield setup,valid()/null_markers()disjointness.TestOccurrenceValidQuerySet— fixture with real / null-only-detection / null-determination occurrences, assertsvalid()returns only real.TestCleanupNullOnlyOccurrencesCommand(3 tests) — dry-run reports without deleting,--commitdeletes phantoms but preserves valid rows and null markers on processed-with-real-detection images, idempotent on second run.ami.ml.tests+ami.main.TestDetectionNullMarker+ami.jobs.tests: 176/176 pass.ami.main.tests+ami.ml.tests+ami.jobs.tests+ami.exports.tests: 385/389 pass. The 4 failures inTestRolePermissionsare pre-existing and reproduce on the prior PR head4e33f96; unrelated to this PR.Manual e2e (dev box)
Happy path async_api job — project 9 / collection 38 / pipeline
quebec_vermont_moths_2023. 28s, 10 imgs, 68 dets, 121 classifs, 0 failed. No new phantoms.Broker-outage simulation — patched
create_detection_images.delayto raise mid-job. 0 null markers persisted, 0 phantoms; image stays infilter_processed_imagesyield list.Calc-field DB error — patched
update_calculated_fields_for_eventsto raise. Same result: 0 null markers persisted.Cleanup command — dry-run on dev-deployment project 9 reported 112 phantom occurrences + 112 orphan null markers. Ran
--commit:OccurrencerowsOccurrence.objects.valid()DetectionrowsDetection.objects.valid()Detection.objects.null_markers()Second dry-run reported
0 / 0(idempotent). The originally-affected deployment is a post-merge ops step.Out of scope — deferred follow-up
transaction.atomic()wrap. The 5-step persistence block (real detections → classifications → occurrences → calc-fields → null marker) is still composed of unrelated DB writes that can partially commit if one mid-block step raises mid-loop. PR-A here closes the ordering window (null marker writing before downstream steps); it does not close the within-block partial-commit window. A narrowtransaction.atomic()wrap withtransaction.on_commitfor celery dispatch is the structural fix and is deferred to a separate PR because tx changes carry concurrency risk (see PR #1261 scar inami/jobs/tasks.py:561-571:select_for_update+ATOMIC_REQUESTScontention) and need their own multi-worker e2e + clean revert path.Dual-form
bbox=Nonevsbbox=[]. New writes go throughDetection.NULL_BBOX = None. Legacy rows from earlier runs still carrybbox=[]..null_markers()/.is_null_marker/null_detections_q()all recognise both, so no consumer breaks — but the dual form is permanent until a data migration backfills legacy rows. Worth a follow-up ticket; not urgent.Re-classification gap. Unrelated but adjacent:
filter_processed_imagesnotes "we don't yet have a mechanism to reclassify detections" — current behavior reprocesses from scratch. Worth a separate ticket.Summary by CodeRabbit
Release Notes
New Features
cleanup_null_only_occurrencesmanagement command to remove phantom occurrence records and orphan null-detection markers for a specified project (dry-run by default).Bug Fixes