Skip to content

Stop large class masking jobs from being cancelled before they report any progress#1376

Merged
mihow merged 4 commits into
mainfrom
fix/class-masking-scope-distinct
Jul 22, 2026
Merged

Stop large class masking jobs from being cancelled before they report any progress#1376
mihow merged 4 commits into
mainfrom
fix/class-masking-scope-distinct

Conversation

@mihow

@mihow mihow commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Summary

Large class masking runs were being cancelled roughly eleven minutes in, having reported no progress at all: the job page showed zero classifications checked. The cause was not the masking work itself but the query that decides which classifications are in scope, which asked the database to de-duplicate rows it could not have duplicated. On a scope of 55,530 classifications that took 208 seconds to count and just as long again before the first row could be read, so the run crossed the stalled-job cutoff before it ever committed a batch and reported progress.

Removing the de-duplication brings the same count back in half a second. A masking run now starts working almost immediately and reports the size of the job before it processes the first prediction, so an operator watching a long run can see how much was found rather than a stage sitting at zero.

Checking that claim in review turned up a smaller related gap: post-processing jobs never marked their stage started, so they read as "Waiting to start" until the first progress report. That is fixed here too, and it affects the small size filter as well.

The scope query returns exactly the same classifications, and the masking logic is untouched.

List of Changes

# What changes for the operator How
1 Large masking runs are no longer cancelled before they begin ClassMaskingTask._scoped_classifications no longer calls .distinct() on either scope
2 The run_class_masking command's preflight count returns promptly instead of appearing to hang Same de-duplication removed from the command's count
3 A running job shows how many classifications it found before it starts working through them New on_setup callback reports the scope size before the first row
4 A job that has started but reported no progress yet reads as running, not as "Waiting to start" PostProcessingJob.run marks the stage STARTED before the task runs, matching the other job types
5 Comment norms written down, so the next PR does not repeat the same review round .agents/AGENTS.md gains a rule on comment length and linking to the PR

Detailed Description

Why de-duplication was both unnecessary and expensive

A classification reaches an occurrence through a plain foreign key, and it reaches a collection through a membership table that holds one row per (capture, collection) pair. Filtering on a single occurrence or a single collection therefore cannot multiply rows, so there was nothing to de-duplicate. The two new scope tests pin exactly that, including the case a reader would worry about: a capture that belongs to several collections still contributes one row per classification.

The cost comes from what SELECT DISTINCT has to compare. The selected columns include the logits and scores arrays, and a production classifier can carry tens of thousands of categories, which puts those two arrays at roughly half a megabyte per row. The database was sorting all of them to prove uniqueness of a column set that already contains the primary key.

De-duplication is also a blocking step, so it delayed the iteration pass as well as the count — no row could be returned until the whole sort finished. Both delays land before the first batch commits, and the first batch commit is where the run reports its first progress.

Measured, not estimated

Against a live database, on the scope of a run that had been cancelled:

Query Time Result
Count the scope, with .distinct() 208.0 s 55,530
Count the scope, without .distinct() 0.5 s 55,530
Read the first 200 rows, without .distinct() 0.4 s

For context on the remaining setup work, expanding that classifier's category map (29,176 categories) took 10.4 seconds and reading the taxa list took 0.4 seconds. With the de-duplication gone, the work ahead of the first progress report is on the order of ten seconds rather than minutes.

The stage-status fix, and two corrections it went through

A stage that has started but not yet reported progress reads as "Waiting to start", because get_status_label renders a CREATED stage that way regardless of progress. Every job type is supposed to move its stage to STARTED at the top of run(); PostProcessingJob was the only one that did not, so its stage sat in CREATED until the task's first progress report, which on a large masking run is minutes in.

This took two rounds of review to land correctly, and the audit trail is worth keeping:

  • I first cited the cancelled job's stage still being CREATED as evidence of the stall. @copilot pointed out a healthy run looked identical, so that was not diagnostic; the evidence that stands is the counter, still at zero eleven minutes in.
  • I then fixed it inside BasePostProcessingTask.update_progress, guarding the promotion to fire only from CREATED. @coderabbitai correctly noted that Job.update_progress already promotes on any progress above zero, so my first test at 0.5 was vacuous, and that a per-heartbeat write is the wrong shape for a status transition.
  • The takeaway review then placed it correctly: this belongs in PostProcessingJob.run, next to update_status(STARTED), exactly as RegroupEventsJob and SourceImageCollectionPopulateJob already do it. That is one line, it fires before the scope count rather than at the first heartbeat, and it removes the base-class guard entirely. test_post_processing_stage_is_started_before_the_task_runs pins it.

A note on the one .distinct() that stays

ClassMaskingActionForm._algorithms_for_scope still de-duplicates, and should: it joins from Algorithm across a reverse relation to many classifications, so one algorithm genuinely does appear once per classification. It also selects only algorithm columns, so the comparison is cheap.

Testing

  • ami.ml.post_processing, ami.ml.tests and ami.jobs pass, including five new tests.
  • black, isort and flake8 clean on the touched files.
  • The equivalence claim is checked two ways: the new tests assert the scope returns each classification exactly once, and the measurement above confirms both forms of the query return 55,530 on real data.

Class masking resolved its scope with `.distinct()`, which made Postgres
de-duplicate whole classification rows. Those rows carry the `logits` and
`scores` arrays, roughly half a megabyte each for a classifier with tens of
thousands of categories, so the database had to sort all of them to prove
uniqueness of a column set that already contains the primary key.

Neither scope can produce duplicate rows in the first place: a classification
reaches an occurrence through a foreign key, and it reaches a collection
through a membership table holding one row per (capture, collection) pair.

Measured on a 55,530-row scope: counting took 208 seconds with `.distinct()`
and 0.5 seconds without, returning the same number. De-duplication is also a
blocking step, so the iteration pass could not yield its first row until the
same sort finished. Both delays land before the first batch commits, which is
where the run reports its first progress, so large masking jobs crossed the
stalled-job cutoff and were revoked having reported nothing.

Also report the scope size through a new `on_setup` callback before the first
row is processed, so a long run shows how much work it found rather than an
untouched stage, and apply the same fix to the management command's preflight
count.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 22, 2026 17:05
@netlify

netlify Bot commented Jul 22, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-ssec canceled.

Name Link
🔨 Latest commit d44e00b
🔍 Latest deploy log https://app.netlify.com/projects/antenna-ssec/deploys/6a6144a167d0c20008cd3160

@netlify

netlify Bot commented Jul 22, 2026

Copy link
Copy Markdown

Deploy Preview for antenna-preview canceled.

Name Link
🔨 Latest commit d44e00b
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/6a6144a174526200072e3494

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@mihow, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 22 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f79a63f4-efbf-4702-b682-8fc6fe8ef11e

📥 Commits

Reviewing files that changed from the base of the PR and between 41e5e05 and d44e00b.

📒 Files selected for processing (4)
  • ami/jobs/models.py
  • ami/ml/post_processing/class_masking.py
  • ami/ml/post_processing/tests/test_class_masking.py
  • ami/ml/tests.py
📝 Walkthrough

Walkthrough

Class masking now counts and retrieves scoped classifications without DISTINCT de-duplication. The masking pipeline reports its total scope before processing batches, and progress updates mark the post-processing stage as started. Tests cover query shape, uniqueness, callback order, and stage state.

Changes

Class masking execution

Layer / File(s) Summary
Scope query materialization
ami/ml/management/commands/run_class_masking.py, ami/ml/post_processing/class_masking.py, ami/ml/post_processing/tests/test_class_masking.py
Direct counts and scoped querysets no longer use DISTINCT; tests verify one result per classification and the updated query shape.
Pre-processing setup reporting
ami/ml/post_processing/class_masking.py, ami/ml/post_processing/tests/test_class_masking.py
A keyword-only on_setup callback reports the scope total before batch processing, while task metrics initialize progress and classifications_total.
Post-processing stage state
ami/ml/post_processing/base.py, ami/ml/tests.py
Progress updates set the stage status to STARTED, with coverage for the persisted status and percentage label.

Agent guidance

Layer / File(s) Summary
Rationale comment style
.agents/AGENTS.md
Agent guidance adds a concise rationale-comment rule and an example reference format.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ClassMaskingTask
  participant make_classifications_filtered_by_taxa_list
  participant ProgressStage
  ClassMaskingTask->>make_classifications_filtered_by_taxa_list: pass on_setup callback
  make_classifications_filtered_by_taxa_list->>make_classifications_filtered_by_taxa_list: compute classification total
  make_classifications_filtered_by_taxa_list->>ClassMaskingTask: invoke on_setup(total)
  ClassMaskingTask->>ProgressStage: set initial progress and classifications_total
  make_classifications_filtered_by_taxa_list-->>ClassMaskingTask: process first batch
Loading

Possibly related PRs

Suggested labels: post-processing

Suggested reviewers: mohamedelabbas1996

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title matches the main change: preventing large class masking jobs from stalling before reporting progress.
Description check ✅ Passed The PR includes Summary, List of Changes, Detailed Description, and Testing; only non-critical sections like issues and deployment notes are missing.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/class-masking-scope-distinct

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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/ml/post_processing/class_masking.py`:
- Around line 342-345: Update the _on_setup callback to mark empty runs
complete: when total is 0, report 100% progress instead of 0%, while preserving
the existing 0% initialization for non-empty runs and the classifications_total
metric.
🪄 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: 781a2e37-02bf-41bc-bbec-cfc62c211c27

📥 Commits

Reviewing files that changed from the base of the PR and between 0693e13 and 566b772.

📒 Files selected for processing (3)
  • ami/ml/management/commands/run_class_masking.py
  • ami/ml/post_processing/class_masking.py
  • ami/ml/post_processing/tests/test_class_masking.py

Comment thread ami/ml/post_processing/class_masking.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the responsiveness and observability of the class-masking post-processing task by removing unnecessary .distinct() calls from large classification scope queries (eliminating an expensive SELECT DISTINCT over large array columns) and adding an early “setup” progress callback so operators can see the scope size before the first batch is processed.

Changes:

  • Remove .distinct() from class-masking scope queries (task and management command) to avoid costly de-duplication over large logits/scores arrays.
  • Add an on_setup(total) callback to report the scope size before iterating classifications, improving early job feedback.
  • Add tests to pin the scope query’s non-duplicating behavior and verify setup callback ordering.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
ami/ml/post_processing/tests/test_class_masking.py Adds tests for scope uniqueness, absence of DISTINCT, and setup-callback ordering.
ami/ml/post_processing/class_masking.py Removes .distinct() from scope query; adds on_setup callback and wires it into ClassMaskingTask.
ami/ml/management/commands/run_class_masking.py Removes .distinct() from preflight count to avoid slow startup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread ami/ml/post_processing/class_masking.py
Comment thread ami/ml/post_processing/tests/test_class_masking.py
Review feedback on #1376.

@copilot pointed out that the new setup callback does not move a stage out of
its created state, and the cause turned out to be broader than the callback:
nothing in the post-processing framework ever set a stage status. The job
wrapper creates the stage and later marks it SUCCESS, and `get_status_label`
reports "Waiting to start" for a created stage whatever its progress, so every
post-processing run — the small size filter included — was labelled as not yet
begun for its whole duration. `update_progress` now marks the stage STARTED.
This does not disturb `JobProgress.is_complete()`, which gates on a final
status, and STARTED is no more final than CREATED.

Also on @copilot's suggestion, the de-duplication test now reads
`query.distinct` instead of matching the word DISTINCT in generated SQL.

The comments and test docstrings added in the previous commit were too long for
this repository's norms: the measurements and the reasoning belong in the pull
request, where they are dated and can be argued with, and the comment should
carry the rule a future editor must not break plus a link. Trimmed accordingly,
and the norm is now written down in the agent instructions so the next change
does not repeat the round.

Co-Authored-By: Claude <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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/ml/post_processing/base.py`:
- Around line 77-85: Update the stage status handling in the progress-update
method around self.job.progress.update_stage: set JobState.STARTED only when the
stage is in a pre-start state, and preserve terminal or cancelling statuses such
as FAILURE, REVOKED, and CANCELING while still updating progress. Ensure
progress-only heartbeats cannot resurrect a completed, failed, or cancelled
stage.

In `@ami/ml/tests.py`:
- Around line 1677-1683: Update the test around
SmallSizeFilterTask.update_progress to use 0.0 progress instead of 0.5, while
preserving the refresh and assertions that the job stage transitions to STARTED
with the expected status label for the zero-progress heartbeat.
🪄 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: b4758c1c-4d6f-4678-9f8a-af4651090909

📥 Commits

Reviewing files that changed from the base of the PR and between 566b772 and 41e5e05.

📒 Files selected for processing (6)
  • .agents/AGENTS.md
  • ami/ml/management/commands/run_class_masking.py
  • ami/ml/post_processing/base.py
  • ami/ml/post_processing/class_masking.py
  • ami/ml/post_processing/tests/test_class_masking.py
  • ami/ml/tests.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • ami/ml/management/commands/run_class_masking.py
  • ami/ml/post_processing/class_masking.py
  • ami/ml/post_processing/tests/test_class_masking.py

Comment thread ami/ml/post_processing/base.py Outdated
Comment thread ami/ml/tests.py Outdated
mihow and others added 2 commits July 22, 2026 14:18
Review feedback on #1376, both points from @coderabbitai.

My previous commit message claimed nothing ever set a stage status. That was
wrong: `Job.update_progress` promotes a stage from CREATED to STARTED as soon as
its progress exceeds zero. The gap is narrower than I described — progress of
exactly zero does not clear that bar, and exactly zero is what the new setup
report means, so it would have left the stage looking untouched.

Two consequences. The promotion now happens only from CREATED, mirroring the
condition `Job.update_progress` already applies, so a late heartbeat cannot pull
a finished, failed or cancelled stage back to STARTED and hide the outcome. And
the test that was meant to pin this used a progress of 0.5, which the existing
promotion already handled, so it passed with or without the change; it now uses
zero, and a second test covers the stage that must not be resurrected.

Co-Authored-By: Claude <noreply@anthropic.com>
…beat

Takeaway review feedback on #1376.

The previous two commits marked the stage STARTED from inside
BasePostProcessingTask.update_progress. That was the wrong layer. Every other
job type sets its stage started at the top of run() — RegroupEventsJob and
SourceImageCollectionPopulateJob both do — and PostProcessingJob was simply
missing that line. Doing it there fires before the scope is even counted,
rather than at the first heartbeat, so the stage reads as running through the
whole setup delay this PR is about. It also lets update_progress return to
main's version, dropping the per-heartbeat status write and the guard that came
with it. Pinned by test_post_processing_stage_is_started_before_the_task_runs.

Also from the review:

- The comment on _scoped_classifications now says when de-duplication would
  become necessary again — broadening either scope to match several collections
  or a whole project would fan out — rather than only that it is currently
  forbidden.

- Dropped test_occurrence_scope_returns_each_classification_once: an occurrence
  filters a foreign key, which structurally cannot duplicate a row, so the test
  passed under any change. The collection variant, where a capture belongs to
  two collections, is the one that earns its place.

Co-Authored-By: Claude <noreply@anthropic.com>
@mihow
mihow merged commit 06791c4 into main Jul 22, 2026
7 checks passed
@mihow
mihow deleted the fix/class-masking-scope-distinct branch July 22, 2026 23:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants