Let identifiers apply identifications to many occurrences in one request#1371
Draft
mihow wants to merge 2 commits into
Draft
Let identifiers apply identifications to many occurrences in one request#1371mihow wants to merge 2 commits into
mihow wants to merge 2 commits into
Conversation
Applying determinations to a page of occurrences currently costs one HTTP request per occurrence: the identification UI fans out N POSTs with Promise.allSettled and retries whichever ones were rejected. Add POST /api/v2/identifications/bulk/ so the same work is one request. The request is a list of per-item objects rather than one taxon applied to a list of occurrence IDs, because the "Agree" action sends a different taxon and a different agreement target for every occurrence. This matches the payload the frontend already builds. Each item is saved through the existing Identification.save(), so withdrawing the user's previous identification and recomputing the occurrence determination keep working exactly as they do for a single POST. Items are saved in their own savepoints and reported individually, so one stale occurrence does not discard the rest of the batch. Occurrences and taxa are resolved in one query each, so validation does not scale with the size of the batch. A batch is authorized once against the single project it belongs to; permission to create an identification depends only on the project, and the check is routed through the same Identification.check_permission call the single-item endpoint makes. Co-Authored-By: Claude <noreply@anthropic.com>
✅ Deploy Preview for antenna-ssec canceled.
|
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 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 |
✅ Deploy Preview for antenna-preview canceled.
|
The endpoint promised that one bad item would not discard the rest of the batch, but ATOMIC_REQUESTS wraps the whole request in a transaction, so the per-item atomic() block was a savepoint rather than a separate transaction and nothing committed on its own. An error raised inside save() propagated, aborted the request transaction, and rolled back every identification already made in it, returning a 500 instead of a per-item error. Catch the failure so the savepoint rolls back that item alone and the loop continues, and correct the comment that claimed otherwise. Also fixed while reviewing: - Agreeing with a prediction whose detection had been deleted raised an AttributeError and returned a 500, because Classification.detection is nullable. It is now reported as an item error. - A batch in which no occurrence existed returned a batch-level 400 while a batch in which only some were missing reported per-item errors. The same failure now answers the same way in both cases. - MAX_BULK_IDENTIFICATIONS moved to serializers, where it is enforced, which removes an import of views from inside a serializer method. Tests: the partial-success guarantee is now pinned by forcing a write to fail mid-batch, which fails without the fix above. The cap test used repeated IDs, so the duplicate check rejected the batch and the test passed with the cap removed; it now uses distinct IDs and asserts the message. The equivalence test seeded no prior identification, so it never exercised withdraw-previous. Added coverage for unknown and cross-occurrence agreement targets and for an all-missing batch, and tightened the per-item query budget to the measured cost. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When an identifier confirms or re-identifies a page of occurrences, the site currently sends one request per occurrence. Selecting fifty occurrences and pressing Confirm fires fifty separate POSTs, and if some of them fail the interface asks the person to press the button again to retry the ones that did not land. This is slow on a large page, and it gets slower the more occurrences someone selects — which is exactly when they are working fastest.
This adds a single endpoint that accepts a whole batch of identifications in one request. The visible effect for an identifier is that a mass identification is one round trip instead of fifty, and the result comes back per occurrence: if one occurrence in the batch cannot be identified — because someone else deleted it since the page was loaded, say — that one is reported as an error and the other forty-nine are still saved. Nothing about how an identification behaves changes; this is a faster way to do what the site already does, deliberately not a different thing.
The frontend is not switched over in this PR. The endpoint is additive and the existing per-occurrence path still works, so the interface can move to it separately.
List of Changes
POST /api/v2/identifications/bulk/action onIdentificationViewSet.created_count/error_count.Identification.save(), so withdrawing the user's previous identification and recomputing the occurrence's determination are unchanged rather than reimplemented.Identification.check_permission(user, "create")call the single-item endpoint makes.Detailed Description
Why a list of items, and not one taxon plus a list of occurrence IDs
The shape was decided by what the interface actually sends. There are three bulk callers today, all of which already build a list of per-item values (
ui/src/data-services/hooks/identifications/useCreateIdentifications.ts):ui/src/pages/occurrences/occurrences-actions.tsx) sends, for each occurrence, that occurrence's own current determination as the taxon and that occurrence's own identification or prediction as the agreement target.The Agree flow is the common mass action and it is irreducibly heterogeneous, so a
{taxon_id, occurrence_ids: []}request could not express it. A list of per-item objects covers all three and maps onto the payload the frontend already constructs, which keeps the eventual frontend change small.Request and response
Results carry the submitted
index, so a client matches them back to its request without relying on ordering alone.All-or-nothing was considered and rejected. There is no invariant that spans the items — each identification is independent — so a transaction around the whole batch would buy no consistency, only a simpler error contract. It would also mean a batch containing one occurrence deleted mid-session retries into the identical failure forever unless the client prunes the offending index first. Saving the valid rows matches what someone doing a mass identification actually wants.
How partial success is actually achieved is worth a look during review.
ATOMIC_REQUESTSis on (config/settings/base.py), so the whole request already runs in one transaction and a per-itemtransaction.atomic()is a savepoint rather than an independent commit. That means partial success does not come for free from wrapping each item: an exception escapingsave()would abort the request's transaction and discard every identification already made in it. Each item is therefore saved inside a savepoint and its failure is caught, so rolling back to the savepoint leaves the connection usable and the loop continues. This is what makes the promise in the summary true rather than aspirational, and it is pinned by a test that forces a write to fail part-way through a batch and asserts the surviving items are really committed.A batch may not span projects (400). All three call sites work within one project page, and restricting it keeps authorization unambiguous. This can be relaxed later without breaking clients.
Repeated occurrence IDs in one batch are rejected (400). Two identifications for one occurrence in a single request have no defined winner — the outcome would depend on insert-order tiebreaks rather than on anything the client asked for.
Two places where this is deliberately stricter than the single-item endpoint
Both are intentional, and both are asymmetries a reviewer should agree with rather than discover:
withdrawnis not accepted from clients. The single-item serializer exposes it as writable, so a client can currently create an already-withdrawn identification. The model maintains this field, and letting a client set it breaks the "one active identification per user per occurrence" invariant. Probably worth a separate issue against the single-item endpoint.agreed_with_identification_id/agreed_with_prediction_idmust belong to the occurrence being identified. The single-item endpoint accepts any identification or classification in the system as an agreement target, with no such check. The bulk endpoint validates it per item. The single-item path is left alone to keep this PR to one change.Permissions
A
detail=Falseaction is never routed throughhas_object_permission, so the endpoint performs the check itself, following the transient-instance pattern already used byProjectPipelineConfigPermissionandUserMembershipPermissioninami/base/permissions.py.Two details are worth flagging for review, because both are the kind of thing that is invisible in a diff:
"create", not to its own name.BaseModel.check_permissionlooks the action up in a CRUD map; an action name of"bulk"would miss that map and fall through tocheck_custom_permission, asking for abulk_identificationpermission that no role grants. That fails closed, but only for non-superusers — a test written as a superuser would pass while every identifier got a 403."create"branch ofIdentification.check_permissiondepends only on the occurrence's project. Since that equivalence is an invariant a future edit could quietly break, this PR adds a note atIdentification.check_permissionitself — the line someone would actually be editing — rather than only at the endpoint.Performance
The win here is round trips, not queries: N HTTP requests become one, with one authorization instead of N. Resolving occurrences and taxa is batched (one query each for the whole request), so validation cost does not grow per item.
The write path deliberately remains a loop over the existing
Identification.save(). Measured at 8 queries per item, all of them insidesave()andupdate_occurrence_determination. Replacing that withbulk_createwould mean reimplementing withdraw-previous and the determination recompute, neither of which has test coverage today to compare against — a large correctness risk for a saving that does not yet matter, since the interface can only select occurrences on the page currently displayed (ui/src/pages/occurrences/occurrences.tsxfilters the selection to the visible rows).MAX_BULK_IDENTIFICATIONS = 200bounds the worst case. A batched write is a sensible follow-up once the determination logic has tests, and the request contract would not change.Testing
34 tests in
ami/main/test_bulk_identifications.py, written before the implementation:POST /identifications/and via the bulk endpoint must leave the occurrence in an identical state (determination, score, and the set of identifications with their withdrawn flags), starting from an occurrence that already has an earlier identification so that withdraw-previous is part of what the two paths have to agree on. This is the test that catches anything the bulk path skips or reimplements, without having to enumerate each rule.save()— the user's previous identification is withdrawn; another user's identification is not.save()is reported against its own index while the other items stay committed. The latter fails without the savepoint handling described above, so it pins the behaviour rather than assuming it.withdrawnignored, unknown and cross-occurrence agreement targets, an all-missing batch.Notes for reviewers
Two pre-existing issues turned up while reading the surrounding code. Neither is touched here, and both are stated as observations rather than as things this PR fixes:
update_occurrence_determinationcompares an ID to a Taxon object.current_determinationis read via.values("determination"), which yields the raw ID, and is then compared againsttop_identification.taxon, a model instance (ami/main/models.py). The comparison is therefore always unequal, so the "no update needed" branch is effectively unreachable and every identification rewrites the determination and score. This is verified, not inferred: a single POST agreeing with an existing determination movesdetermination_scorefrom the machine score to 1.0. That may well be the desired behaviour — a human-confirmed occurrence arguably should score 1.0 — but it is currently reached by accident rather than by intent, and the surrounding code reads as though it were meant to no-op. Worth a ticket to decide which behaviour is wanted.user_agrees_with_identification(ami/main/models.py) has no callers. It is also decorated with an unboundedfunctools.cachekeyed on model instances, which would return stale results and grow without limit per worker if it were ever wired up. Worth deleting or fixing before anyone reaches for it.A third, smaller one:
Classification.detectionis nullable, so a classification can outlive the detection that linked it to an occurrence. Agreeing with such a prediction is reported as an item error rather than crashing.