Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
138 commits
Select commit Hold shift + click to select a range
e18655e
fix(post-processing): write the size filter's last batch even when th…
mihow Oct 3, 2026
514a373
feat(results): store what post-processing runs decide and what people…
mihow Oct 3, 2026
11f3a41
feat(jobs): record which job wrote each detection and classification,…
mihow Oct 3, 2026
04becb4
feat(post-processing): record what class masking and the size filter …
mihow Oct 3, 2026
781279a
feat(occurrences): show an occurrence's history in one list and let i…
mihow Oct 3, 2026
804fe3a
feat(ui): show post-processing results and comments in the occurrence…
mihow Oct 3, 2026
fe0f428
feat(occurrences): name the species list and classifier on a result, …
mihow Oct 3, 2026
ddb0c20
feat(ui): make result cards readable: method as title, species list b…
mihow Oct 3, 2026
22e249f
feat(results): record each run's own figures per kind, with a sortabl…
mihow Oct 3, 2026
d5bb406
feat(ui): show a run's own figures on its result card and drop the co…
mihow Oct 3, 2026
e923e49
docs(results): state that extra is never read for logic and name the …
mihow Oct 3, 2026
c855924
fix(ui): keep the masking card label as "Probability outside the list"
mihow Oct 3, 2026
e169a3d
fix(history): only mark a prediction superseded by the run that repla…
mihow Oct 4, 2026
ab25209
fix(ui): stop showing "Species list #undefined" and an empty history …
mihow Oct 4, 2026
800062f
feat(results): add result kinds without a migration and move results …
mihow Oct 4, 2026
b35ae46
fix(history): survive a deleted taxon or species list and count chang…
mihow Oct 4, 2026
59ed47c
chore(migrations): number the result migrations after the job-provena…
mihow Oct 5, 2026
0806791
refactor(results): move algorithm results into the ml app and take th…
mihow Oct 5, 2026
bb174e2
feat(jobs): list occurrences a job only wrote algorithm results for u…
mihow Oct 5, 2026
f38679d
feat(results): let a result kind declare which data fields reference …
mihow Oct 5, 2026
29aaa49
feat(history): resolve ids shown in an occurrence's history to named …
mihow Oct 5, 2026
fadf0ab
feat(history): organise history entries in sections, show what a run …
mihow Oct 5, 2026
053afe2
feat(history): publish each result kind as a typed OpenAPI component …
mihow Oct 5, 2026
0710826
feat(ui): link every record the history names through one table, and …
mihow Oct 5, 2026
6781705
fix(history): keep the history open for a result of an unregistered k…
mihow Oct 6, 2026
533c29d
fix(results): lock the occurrences before moving their results to the…
mihow Oct 6, 2026
df15721
feat(history): label every job setting from its task's schema, and gi…
mihow Oct 6, 2026
6451e46
refactor(results): read job settings through one parser, and name eac…
mihow Oct 6, 2026
cc41423
refactor(ui): compute a reference's label in one place
mihow Oct 6, 2026
3a419ee
docs: explain how to add an algorithm result kind, with rank roll-up …
mihow Oct 6, 2026
7a0ba67
docs(results): keep the algorithm results guide next to the code, and…
mihow Oct 6, 2026
f1398ce
docs(results): explain how algorithm results fit the post-processing …
mihow Oct 6, 2026
4df1f7c
docs(results): note that result kinds are not tied to post-processing…
mihow Oct 6, 2026
6ddec42
feat(post-processing): tasks declare the result models they write
mihow Oct 6, 2026
8ac8da4
refactor(results): keep every run's result instead of marking one as …
mihow Oct 6, 2026
0994d38
docs(results): say where results can be written, and which algorithm …
mihow Oct 6, 2026
30cc9b7
test(results): build the history and size filter fixtures once per class
mihow Oct 6, 2026
ee0de7f
refactor(results): derive value from data, refuse cross-project moves…
mihow Oct 7, 2026
8c87e39
docs(results): cut the algorithm results guide to the steps and rules
mihow Oct 7, 2026
56197bd
fix(post-processing): leave detections the size filter already flagge…
mihow Oct 7, 2026
b0471e6
test(results): use a kind no branch registers as the unregistered exa…
mihow Oct 7, 2026
f2ee950
feat(tracking): link detections into chains, store occurrence statist…
mihow Oct 4, 2026
48e0205
feat(exports): add a CSV export with one row per detection of each oc…
mihow Oct 4, 2026
b5c5add
feat(post-processing): generate admin action forms from a task's conf…
mihow Oct 4, 2026
8711c7b
feat(tracking): run occurrence tracking as a post-processing task fro…
mihow Oct 4, 2026
7b389d3
docs(tracking): say that re-tracking a session adds links but never u…
mihow Oct 4, 2026
c3c9b6a
refactor(exports): drop the dedicated tracks export
mihow Oct 5, 2026
334f7f4
refactor(tracking): drop the stored occurrence statistics
mihow Oct 5, 2026
249c3c0
refactor(tracking): give tracking one home under ami/ml with pure cod…
mihow Oct 5, 2026
d503ed3
chore(migrations): renumber the next_detection migration after the al…
mihow Oct 6, 2026
4d55301
feat(tracking): record a tracking result for each occurrence a run links
mihow Oct 6, 2026
a3f5a51
feat(ui): show the tracking result on an occurrence's history
mihow Oct 6, 2026
1245868
fix(migrations): add the detection next-detection link without blocki…
mihow Oct 6, 2026
c486d83
fix(regroup): look for occurrences split by a regroup only among the …
mihow Oct 6, 2026
940749e
refactor(tracking): drop the tracking classification, record link cos…
mihow Oct 6, 2026
c1cb15d
feat(ui): show the reworked tracking figures on an occurrence's history
mihow Oct 6, 2026
78437c1
fix(tracking): save job progress while a session is being matched, an…
mihow Oct 6, 2026
5283b0f
feat(tracking): label the session list setting so the history shows i…
mihow Oct 6, 2026
2eb150a
test(ui): give the tracking history fixture a value and no score
mihow Oct 6, 2026
26a0d83
fix(tracking): never delete an occurrence that still holds detections…
mihow Oct 6, 2026
2353d77
refactor(tracking): follow the result framework's kind and settings d…
mihow Oct 6, 2026
0072d30
refactor(tracking): declare the tracking result model and drop the cu…
mihow Oct 6, 2026
e989d46
test(tracking): build tracking test fixtures once per class
mihow Oct 6, 2026
caadb93
refactor(tracking): let the result framework derive the tracking valu…
mihow Oct 7, 2026
d68a2ad
perf(main): stop update_occurrence_determination from running the loo…
mihow Oct 7, 2026
ed114d9
feat(tracking): write a session in bulk, record the grouping for undo…
mihow Oct 7, 2026
049ce4b
fix(tracking): skip the regroup split where nothing was tracked, and …
mihow Oct 7, 2026
d4bb07b
fix(migrations): give up after 10 s rather than queue behind a long q…
mihow Oct 7, 2026
8b387eb
refactor(tracking): reuse the admin's error mapping and drop the card…
mihow Oct 7, 2026
f9995f2
feat(tracking): name each taxon the labels disagree on in the trackin…
mihow Oct 8, 2026
3f98411
fix(tracking): count each detection once, by the label the determinat…
mihow Oct 8, 2026
36fcdb6
feat(tracking): record how long an occurrence lasted and how confiden…
mihow Oct 8, 2026
30df286
fix(post-processing): write the size filter's last batch even when th…
mihow Oct 3, 2026
320bf20
feat(results): store what post-processing runs decide and what people…
mihow Oct 3, 2026
137cf9c
feat(jobs): record which job wrote each detection and classification,…
mihow Oct 3, 2026
2f0fe57
feat(post-processing): record what class masking and the size filter …
mihow Oct 3, 2026
aa215d4
feat(occurrences): show an occurrence's history in one list and let i…
mihow Oct 3, 2026
7b68732
feat(ui): show post-processing results and comments in the occurrence…
mihow Oct 3, 2026
4968833
feat(occurrences): name the species list and classifier on a result, …
mihow Oct 3, 2026
129ce13
feat(ui): make result cards readable: method as title, species list b…
mihow Oct 3, 2026
75b3951
feat(results): record each run's own figures per kind, with a sortabl…
mihow Oct 3, 2026
0080645
feat(ui): show a run's own figures on its result card and drop the co…
mihow Oct 3, 2026
2532887
docs(results): state that extra is never read for logic and name the …
mihow Oct 3, 2026
0a8c901
fix(ui): keep the masking card label as "Probability outside the list"
mihow Oct 3, 2026
ae942c7
fix(history): only mark a prediction superseded by the run that repla…
mihow Oct 4, 2026
db1de1d
fix(ui): stop showing "Species list #undefined" and an empty history …
mihow Oct 4, 2026
7f4802c
feat(results): add result kinds without a migration and move results …
mihow Oct 4, 2026
ef44b61
fix(history): survive a deleted taxon or species list and count chang…
mihow Oct 4, 2026
370f9c2
chore(migrations): number the result migrations after the job-provena…
mihow Oct 5, 2026
9002675
refactor(results): move algorithm results into the ml app and take th…
mihow Oct 5, 2026
1e4b3ad
feat(jobs): list occurrences a job only wrote algorithm results for u…
mihow Oct 5, 2026
1608dac
feat(results): let a result kind declare which data fields reference …
mihow Oct 5, 2026
f42730f
feat(history): resolve ids shown in an occurrence's history to named …
mihow Oct 5, 2026
4874af9
feat(history): organise history entries in sections, show what a run …
mihow Oct 5, 2026
ebb17a9
feat(history): publish each result kind as a typed OpenAPI component …
mihow Oct 5, 2026
7179e80
feat(ui): link every record the history names through one table, and …
mihow Oct 5, 2026
8f1ff86
fix(history): keep the history open for a result of an unregistered k…
mihow Oct 6, 2026
0bdf3d9
fix(results): lock the occurrences before moving their results to the…
mihow Oct 6, 2026
de6656b
feat(history): label every job setting from its task's schema, and gi…
mihow Oct 6, 2026
6c1bb09
refactor(results): read job settings through one parser, and name eac…
mihow Oct 6, 2026
a417b74
refactor(ui): compute a reference's label in one place
mihow Oct 6, 2026
8b004af
docs: explain how to add an algorithm result kind, with rank roll-up …
mihow Oct 6, 2026
eba6978
docs(results): keep the algorithm results guide next to the code, and…
mihow Oct 6, 2026
6fce35b
docs(results): explain how algorithm results fit the post-processing …
mihow Oct 6, 2026
6366c57
docs(results): note that result kinds are not tied to post-processing…
mihow Oct 6, 2026
7656cfb
feat(post-processing): tasks declare the result models they write
mihow Oct 6, 2026
a786479
refactor(results): keep every run's result instead of marking one as …
mihow Oct 6, 2026
2b7d14a
docs(results): say where results can be written, and which algorithm …
mihow Oct 6, 2026
5c122ad
test(results): build the history and size filter fixtures once per class
mihow Oct 6, 2026
8651e35
refactor(results): derive value from data, refuse cross-project moves…
mihow Oct 7, 2026
46d3db8
docs(results): cut the algorithm results guide to the steps and rules
mihow Oct 7, 2026
ead5e18
fix(post-processing): leave detections the size filter already flagge…
mihow Oct 7, 2026
e52d959
test(results): use a kind no branch registers as the unregistered exa…
mihow Oct 7, 2026
756fafc
fix(ui): keep history labels narrower than their values so labels wra…
mihow Oct 7, 2026
f8b47aa
refactor(results): give references and job config their own homes, an…
mihow Oct 8, 2026
28f8b7d
feat(ui): show algorithm results with the prediction card and its top…
mihow Oct 8, 2026
2c033ca
refactor(results): name record references, give the history its own p…
mihow Oct 8, 2026
cea4d65
refactor(ui): call references EntityRef, share their label and link, …
mihow Oct 8, 2026
e74f4af
fix(history): ignore the occurrence list's filters on the history end…
mihow Oct 8, 2026
a95a0b7
Merge #1461 (feat/post-processing-results-history) into run-only trac…
mihow Oct 8, 2026
f985ff8
refactor(ui): call a record reference ServerModelRef, after the API's…
mihow Oct 9, 2026
50f2cb4
docs(results): keep detection and capture targets open for algorithm …
mihow Oct 9, 2026
941b417
Merge remote-tracking branch 'origin/feat/post-processing-results-his…
mihow Oct 9, 2026
423f13a
fix(ui): name the taxa list's links with ModelRefValue, after #1461's…
mihow Oct 9, 2026
ddde8bc
feat(occurrences): merge occurrences through one operation that keeps…
mihow Oct 9, 2026
720e7de
feat(tracking): weigh box overlap double by default, so a newly lande…
mihow Oct 9, 2026
c34d64f
feat(references): let a config field name a list of records, and name…
mihow Oct 9, 2026
7dc1be9
fix(ui): stack the taxa of a tracking result one per line
mihow Oct 9, 2026
7d386f9
feat(tracking): track sessions people have identified, keeping their …
mihow Oct 10, 2026
03ada58
Merge remote-tracking branch 'origin/feat/post-processing-results-his…
mihow Oct 10, 2026
7f6b66f
fix(tracking): record one link cost per detection, so each cost pairs…
mihow Oct 10, 2026
6f1dee0
feat(tracking): name a tracking run's sessions as records, and show a…
mihow Oct 10, 2026
82d14fd
fix(tracking): keep one tracking result per occurrence when a job is …
mihow Oct 10, 2026
4da8f2c
fix(history): show one entry per run when merged occurrences bring th…
mihow Oct 10, 2026
b46e43e
fix(history): show only the count and classifications on a card that …
mihow Oct 10, 2026
bb9af93
Merge origin/main into feat/run-tracking-post-processing, after #1461…
mihow Oct 10, 2026
7157cd2
fix(tracking): keep one result per occurrence when a retry merges occ…
mihow Oct 10, 2026
471d010
test(tracking): cut duplicated tracking tests, share their fixtures, …
mihow Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion ami/main/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
from ami.ml.post_processing.admin.actions import make_post_processing_action
from ami.ml.post_processing.admin.class_masking_form import ClassMaskingActionForm
from ami.ml.post_processing.admin.small_size_filter_form import SmallSizeFilterActionForm
from ami.ml.post_processing.admin.tracking_actions import build_tracking_jobs_for_events
from ami.ml.post_processing.admin.tracking_form import TrackingActionForm
from ami.ml.post_processing.class_masking import ClassMaskingTask
from ami.ml.post_processing.small_size_filter import SmallSizeFilterTask
from ami.ml.post_processing.tracking import TrackingTask
from ami.ml.tasks import remove_duplicate_classifications

from .models import (
Expand Down Expand Up @@ -314,8 +317,16 @@ def update_calculated_fields(self, request: HttpRequest, queryset: QuerySet[Even
update_calculated_fields_for_events(qs=queryset)
self.message_user(request, f"Updated {queryset.count()} events.")

# One Job per project, since a Job belongs to a single project and the changelist can span several.
run_tracking = make_post_processing_action(
TrackingTask,
TrackingActionForm,
build_jobs=build_tracking_jobs_for_events,
description="Run Occurrence tracking on the selected sessions (async)",
)

list_filter = ("deployment", "project", "start")
actions = [update_calculated_fields]
actions = [update_calculated_fields, run_tracking]


@admin.register(SourceImage)
Expand Down Expand Up @@ -861,11 +872,20 @@ def populate_collection_async(self, request: HttpRequest, queryset: QuerySet[Sou
f"Post-processing: {task_cls.name} on Capture Set {collection.pk}"
),
)
run_tracking = make_post_processing_action(
TrackingTask,
TrackingActionForm,
scope_resolver=lambda collection: {"source_image_collection_id": collection.pk},
name_resolver=lambda task_cls, collection: (
f"Post-processing: {task_cls.name} on Capture Set {collection.pk}"
),
)
actions = [
populate_collection,
populate_collection_async,
run_small_size_filter,
run_class_masking,
run_tracking,
]

# Hide images many-to-many field from form. This would list all source images in the database.
Expand Down
6 changes: 6 additions & 0 deletions ami/main/api/occurrence_history/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ class AlgorithmResultEntrySerializer(HistoryEntryBaseSerializer):
classifications = CreatedClassificationSerializer(
many=True, help_text="The classifications the run created, best score first."
)
results_merged_in = serializers.IntegerField(
help_text=(
"Other results of the same run, brought onto this occurrence by merging occurrences. The entry "
"shows the newest result, with the classifications all of them created."
)
)


HISTORY_ENTRY_SERIALIZERS = {
Expand Down
65 changes: 62 additions & 3 deletions ami/main/api/occurrence_history/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,24 @@ def get(self, user: User | None = None):
return response.data

def _add_history(self, start: datetime.datetime, rounds: int = 1) -> None:
"""Per round: a size filter result with the classification it created, and an identification."""
"""Per round: a size filter result with the classification it created, and an identification.

The first result is ``self.job``'s; each later one comes from a run of its own with the same config,
since one run records one result per occurrence.
"""
for i in range(rounds):
at = start + datetime.timedelta(hours=4 * i)
job = (
self.job
if not AlgorithmResult.objects.filter(job=self.job, occurrence=self.occurrence).exists()
else Job.objects.create(
project=self.project, name=self.job.name, job_type_key="post_processing", params=self.job.params
)
)
result = AlgorithmResult.objects.record(
occurrence=self.occurrence,
algorithm=self.size_filter,
job=self.job,
job=job,
kind=SIZE_FILTER,
value=0.001,
data={
Expand All @@ -128,7 +139,7 @@ def _add_history(self, start: datetime.datetime, rounds: int = 1) -> None:
score=0.1,
algorithm=self.size_filter,
timestamp=at,
job=self.job,
job=job,
algorithm_result=result,
)
identification = Identification.objects.create(
Expand Down Expand Up @@ -426,6 +437,54 @@ def test_predictions_carry_the_job_that_wrote_them(self):
self.assertTrue(predictions)
self.assertTrue(all(e.job == self.job for e in predictions))

def test_results_of_one_run_on_merged_occurrences_show_as_one_entry(self):
"""A merge brings the absorbed occurrences' results along, so one run can hold several on the kept one.

They show as the newest result with every classification the run created, and a count of the rest;
a result of another run, and one with no job, stay entries of their own.
"""
now = datetime.datetime.now() - datetime.timedelta(days=1)
other_job = Job.objects.create(
project=self.project, name="Second run", job_type_key="post_processing", params=self.job.params
)
rows = []
for job, detection, hours in (
(self.job, self.detections[0], 3),
(self.job, self.detections[1], 2),
(self.job, self.detections[2], 1),
(other_job, self.detections[3], 4),
(None, self.detections[3], 5),
):
at = now - datetime.timedelta(hours=hours)
result = AlgorithmResult.objects.record(
occurrence=self.occurrence,
algorithm=self.size_filter,
job=job,
kind=SIZE_FILTER,
data={"relative_size": 0.001 * hours},
timestamp=at,
)
Classification.objects.create(
detection=detection,
taxon=self.taxon,
score=0.1,
algorithm=self.size_filter,
timestamp=at,
job=job,
algorithm_result=result,
)
rows.append(result)

entries = [e for e in self.get() if e["type"] == "algorithm_result"]

self.assertEqual([e["id"] for e in entries], [rows[2].pk, rows[3].pk, rows[4].pk])
newest = entries[0]
self.assertEqual(newest["results_merged_in"], 2)
self.assertEqual(
sorted(c["detection_id"] for c in newest["classifications"]), sorted(d.pk for d in self.detections[:3])
)
self.assertEqual([e["results_merged_in"] for e in entries[1:]], [0, 0])

def test_a_result_without_a_job_has_no_config(self):
AlgorithmResult.objects.record(
occurrence=self.occurrence,
Expand Down
60 changes: 45 additions & 15 deletions ami/main/api/occurrence_history/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class OccurrenceTimelineEntry:
determination_before: Taxon | None = None
determination_after: Taxon | None = None
classifications: list[CreatedClassification] = dataclasses.field(default_factory=list)
# Other results of the same run, brought here by merging occurrences; see ``_one_entry_per_run``.
results_merged_in: int = 0
# --- Identification and prediction
details: dict = dataclasses.field(default_factory=dict)

Expand All @@ -77,22 +79,31 @@ def occurrence_timeline(occurrence: Occurrence) -> list[OccurrenceTimelineEntry]
)
created_by_result = _classifications_created_by(results, occurrence)
created_ids = {c.classification.pk for created in created_by_result.values() for c in created}
entries = [
OccurrenceTimelineEntry(
type="algorithm_result",
id=result.pk,
timestamp=result.timestamp,
algorithm=result.algorithm,
job=result.job,
value=result.value,
kind=result.kind,
data=result.data,
determination_before=taxa.get(result.data.get("determination_before_id")),
determination_after=taxa.get(result.data.get("determination_after_id")),
classifications=created_by_result.get(result.pk, []),
entries = []
for result, merged_in in _one_entry_per_run(results):
classifications = [c for r in (result, *merged_in) for c in created_by_result.get(r.pk, [])]
if merged_in:
# Best score first, as for one result's classifications.
classifications.sort(
key=lambda c: (c.classification.score is not None, c.classification.score or 0, c.classification.pk),
reverse=True,
)
entries.append(
OccurrenceTimelineEntry(
type="algorithm_result",
id=result.pk,
timestamp=result.timestamp,
algorithm=result.algorithm,
job=result.job,
value=result.value,
kind=result.kind,
data=result.data,
determination_before=taxa.get(result.data.get("determination_before_id")),
determination_after=taxa.get(result.data.get("determination_after_id")),
classifications=classifications,
results_merged_in=len(merged_in),
)
)
for result in results
]

identifications = Identification.objects.filter(occurrence=occurrence).select_related("user", "taxon")
entries.extend(
Expand Down Expand Up @@ -133,6 +144,25 @@ def occurrence_timeline(occurrence: Occurrence) -> list[OccurrenceTimelineEntry]
return entries


def _one_entry_per_run(results: list[AlgorithmResult]) -> list[tuple[AlgorithmResult, list[AlgorithmResult]]]:
"""Each run's newest result on the occurrence, with its other results there, keeping the results' order.

A run records one result per occurrence, so more than one of a job, kind and algorithm means merged
occurrences brought theirs along. They show as one entry until results can name detections (#1412).
The entry carries the newest result's figures and determination, which may describe an absorbed
occurrence, so the card shows only the count and the classifications of such an entry. A result with
no job is its own entry.
"""
runs: dict[tuple, tuple[AlgorithmResult, list[AlgorithmResult]]] = {}
for result in results:
key = (result.job_id, result.kind, result.algorithm_id) if result.job_id is not None else ("result", result.pk)
if key in runs:
runs[key][1].append(result)
else:
runs[key] = (result, [])
return list(runs.values())


def _by_id(model, ids: set) -> dict:
ids.discard(None)
return {row.pk: row for row in model.objects.filter(pk__in=ids)} if ids else {}
Expand Down
45 changes: 45 additions & 0 deletions ami/main/migrations/0100_detection_next_detection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
"""Add the column that links a detection to the one that follows it in a tracking sequence.

Django would add this column together with a unique constraint and a foreign key in one
statement, which takes a lock on the large detection table that blocks reads while the
unique index is built. Here the column is added on its own: a nullable column without a
default is a catalogue change that does not scan the table. Migration 0101 adds the unique
constraint and the foreign key afterwards without blocking readers or writers.
"""

dependencies = [
("main", "0099_classification_algorithm_result_index"),
]

operations = [
# Adding the column takes a brief exclusive lock. Give up rather than queue behind a long query on
# the table, which would block every other query until it ends; rerun the migration if it gives up.
migrations.RunSQL(sql="SET LOCAL lock_timeout = '10s';", reverse_sql=migrations.RunSQL.noop),
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.AddField(
model_name="detection",
name="next_detection",
field=models.OneToOneField(
blank=True,
help_text="The detection that follows this one in the tracking sequence.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="previous_detection",
to="main.detection",
),
),
],
database_operations=[
migrations.RunSQL(
sql='ALTER TABLE "main_detection" ADD COLUMN "next_detection_id" bigint NULL;',
reverse_sql='ALTER TABLE "main_detection" DROP COLUMN "next_detection_id";',
),
],
),
]
72 changes: 72 additions & 0 deletions ami/main/migrations/0101_detection_next_detection_constraints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from django.db import migrations


class Migration(migrations.Migration):
"""Give ``Detection.next_detection`` the unique constraint and foreign key that Django would have created.

Both are built so that neither blocks reads or writes on the large detection table. The
unique index is built CONCURRENTLY, which needs a non-atomic migration, and is then attached
as a constraint, which is a catalogue change. The foreign key is added NOT VALID, so existing
rows are not checked while a strong lock is held, and validated afterwards, which takes only a
light lock. See 0093 for why the statement timeout is cleared and restored around the build.

The constraint names match the ones Django generates, so later AlterField migrations find them.
If the index build is interrupted it leaves an invalid index of the same name, and if a lock times out the
index already exists; drop it before retrying.
"""

atomic = False

dependencies = [
("main", "0100_detection_next_detection"),
]

operations = [
migrations.RunSQL(
sql="SET statement_timeout = 0;",
reverse_sql=migrations.RunSQL.noop,
),
migrations.RunSQL(
sql=(
'CREATE UNIQUE INDEX CONCURRENTLY "main_detection_next_detection_id_key" '
'ON "main_detection" ("next_detection_id");'
),
reverse_sql=migrations.RunSQL.noop,
),
# The two ALTER TABLE statements below take brief strong locks. Give up rather than queue behind a long
# query on the table, which would block every other query until it ends.
migrations.RunSQL(sql="SET lock_timeout = '10s';", reverse_sql=migrations.RunSQL.noop),
migrations.RunSQL(
sql=(
'ALTER TABLE "main_detection" ADD CONSTRAINT "main_detection_next_detection_id_key" '
'UNIQUE USING INDEX "main_detection_next_detection_id_key";'
),
reverse_sql='ALTER TABLE "main_detection" DROP CONSTRAINT "main_detection_next_detection_id_key";',
),
migrations.RunSQL(
sql=(
'ALTER TABLE "main_detection" ADD CONSTRAINT "main_detection_next_detection_id_f0201e13_fk_main_detection_id" '
'FOREIGN KEY ("next_detection_id") REFERENCES "main_detection" ("id") '
"DEFERRABLE INITIALLY DEFERRED NOT VALID;"
),
reverse_sql=(
'ALTER TABLE "main_detection" '
'DROP CONSTRAINT "main_detection_next_detection_id_f0201e13_fk_main_detection_id";'
),
),
migrations.RunSQL(
sql=(
'ALTER TABLE "main_detection" VALIDATE CONSTRAINT '
'"main_detection_next_detection_id_f0201e13_fk_main_detection_id";'
),
reverse_sql=migrations.RunSQL.noop,
),
migrations.RunSQL(
sql="RESET lock_timeout;",
reverse_sql=migrations.RunSQL.noop,
),
migrations.RunSQL(
sql="RESET statement_timeout;",
reverse_sql=migrations.RunSQL.noop,
),
]
Loading
Loading