diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json
index 50af99e601..3d71a334ea 100644
--- a/frontend/src/locale/de.json
+++ b/frontend/src/locale/de.json
@@ -511,6 +511,7 @@
"ROW": "Zeile",
"APPLY_TO_ALL_ROWS_NOTE": "+ kennzeichnet, dass die Änderung auf alle Zeilen dieser Spalte angewendet werden kann",
"BULK_IMPORT_DELETE_TASK": "Massenimport-Aufgabe löschen",
+ "BULK_IMPORT_DELETE_TASK_IN_PROGRESS": "Wird gelöscht...",
"BULK_IMPORT_DATA_UPLOADED": "Daten hochgeladen",
"BULK_IMPORT_IMAGE_UPLOADED": "Bild hochgeladen",
"SPREADSHEET_UPLOADED_TITLE": "Tabellen-Datei hochgeladen: {fileName}",
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index e2942a0460..1cbe499eaf 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -509,6 +509,7 @@
"ROW": "Row",
"APPLY_TO_ALL_ROWS_NOTE": "+ denotes change can apply to all rows for this column",
"BULK_IMPORT_DELETE_TASK": "Delete Import Task",
+ "BULK_IMPORT_DELETE_TASK_IN_PROGRESS": "Deleting...",
"BULK_IMPORT_DATA_UPLOADED": "Data Uploaded",
"BULK_IMPORT_IMAGE_UPLOADED": "Image Uploaded",
"SPREADSHEET_UPLOADED_TITLE": "Spreadsheet Uploaded: {fileName}",
diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json
index 78f88a12d2..804332b537 100644
--- a/frontend/src/locale/es.json
+++ b/frontend/src/locale/es.json
@@ -511,6 +511,7 @@
"ROW": "Fila",
"APPLY_TO_ALL_ROWS_NOTE": "+ denota que el cambio se puede aplicar a todas las filas de esta columna",
"BULK_IMPORT_DELETE_TASK": "Eliminar tarea de importación masiva",
+ "BULK_IMPORT_DELETE_TASK_IN_PROGRESS": "Eliminando...",
"BULK_IMPORT_DATA_UPLOADED": "Datos subidos",
"BULK_IMPORT_IMAGE_UPLOADED": "Imagen subida",
"SPREADSHEET_UPLOADED_TITLE": "Hoja de cálculo subida: {fileName}",
diff --git a/frontend/src/locale/fr.json b/frontend/src/locale/fr.json
index f6cdb7970b..b45d791b8e 100644
--- a/frontend/src/locale/fr.json
+++ b/frontend/src/locale/fr.json
@@ -511,6 +511,7 @@
"ROW": "Ligne",
"APPLY_TO_ALL_ROWS_NOTE": "+ indique que la modification peut s'appliquer à toutes les lignes de cette colonne",
"BULK_IMPORT_DELETE_TASK": "Supprimer la tâche d'importation en masse",
+ "BULK_IMPORT_DELETE_TASK_IN_PROGRESS": "Suppression...",
"BULK_IMPORT_DATA_UPLOADED": "Données téléchargées",
"BULK_IMPORT_IMAGE_UPLOADED": "Image téléchargée",
"SPREADSHEET_UPLOADED_TITLE": "Feuille de calcul téléchargée: {fileName}",
diff --git a/frontend/src/locale/it.json b/frontend/src/locale/it.json
index 9afee9807f..bf495567d0 100644
--- a/frontend/src/locale/it.json
+++ b/frontend/src/locale/it.json
@@ -511,6 +511,7 @@
"ROW": "Riga",
"APPLY_TO_ALL_ROWS_NOTE": "+ indica che la modifica può essere applicata a tutte le righe di questa colonna",
"BULK_IMPORT_DELETE_TASK": "Elimina attività di importazione",
+ "BULK_IMPORT_DELETE_TASK_IN_PROGRESS": "Eliminazione in corso...",
"BULK_IMPORT_DATA_UPLOADED": "Dati caricati",
"BULK_IMPORT_IMAGE_UPLOADED": "Immagine caricata",
"SPREADSHEET_UPLOADED_TITLE": "Foglio di calcolo caricato: {fileName}",
diff --git a/frontend/src/pages/BulkImport/BulkImportTask.jsx b/frontend/src/pages/BulkImport/BulkImportTask.jsx
index 5ccab73160..d51448365f 100644
--- a/frontend/src/pages/BulkImport/BulkImportTask.jsx
+++ b/frontend/src/pages/BulkImport/BulkImportTask.jsx
@@ -35,6 +35,7 @@ const BulkImportTask = observer(() => {
const [userRoles, setUserRoles] = useState(null);
const store = useLocalObservable(() => new BulkImportTaskStore());
const [rowsPerPage, setRowsPerPage] = useState(10);
+ const [isDeleting, setIsDeleting] = useState(false);
const previousLocationID = task?.matchingLocations || [];
@@ -76,12 +77,14 @@ const BulkImportTask = observer(() => {
const deleteTask = async () => {
if (!task?.id) return;
+ if (isDeleting) return;
const confirmed = window.confirm(
intl.formatMessage({ id: "BULK_IMPORT_DELETE_TASK_CONFIRM" }),
);
if (!confirmed) return;
+ setIsDeleting(true);
try {
const res = await fetch(`/api/v3/bulk-import/${task.id}`, {
method: "DELETE",
@@ -102,6 +105,7 @@ const BulkImportTask = observer(() => {
{ error: err.message || "" },
),
);
+ setIsDeleting(false);
}
};
@@ -568,6 +572,7 @@ const BulkImportTask = observer(() => {
{
marginLeft: 0,
marginTop: "1rem",
marginBottom: "2rem",
+ opacity: isDeleting ? 0.7 : 1,
+ cursor: isDeleting ? "not-allowed" : "pointer",
}}
>
-
+ {isDeleting ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
diff --git a/src/main/java/org/ecocean/api/patch/EncounterPatchValidator.java b/src/main/java/org/ecocean/api/patch/EncounterPatchValidator.java
index 540226db18..0b6a95ca20 100644
--- a/src/main/java/org/ecocean/api/patch/EncounterPatchValidator.java
+++ b/src/main/java/org/ecocean/api/patch/EncounterPatchValidator.java
@@ -248,7 +248,7 @@ public static JSONObject applyPatch(Encounter enc, JSONObject patch, User user,
enc.addAnnotation(triv);
myShepherd.getPM().makePersistent(triv);
}
- myShepherd.getPM().deletePersistent(ann);
+ myShepherd.throwAwayAnnotation(ann);
value = ann;
} else if (path.equals("occurrenceId")) {
// this may be overkill. *technically* an Encounter should be contained in (at most) ONE Occurrence
diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java
index c3ca6897ed..6f1825c80d 100644
--- a/src/main/java/org/ecocean/ia/MatchResult.java
+++ b/src/main/java/org/ecocean/ia/MatchResult.java
@@ -397,6 +397,10 @@ public int numberProspects() {
return Util.collectionSize(prospects);
}
+ public Set getCandidates() {
+ return candidates;
+ }
+
public Set prospectScoreTypes() {
Set types = new HashSet();
diff --git a/src/main/java/org/ecocean/servlet/AnnotationEdit.java b/src/main/java/org/ecocean/servlet/AnnotationEdit.java
index 24e327ca8e..fd9863a930 100644
--- a/src/main/java/org/ecocean/servlet/AnnotationEdit.java
+++ b/src/main/java/org/ecocean/servlet/AnnotationEdit.java
@@ -163,7 +163,7 @@ public class AnnotationEdit extends HttpServlet {
myShepherd.getPM().deletePersistent(enc);
rtn.put("encounterDeleted", true);
}
- myShepherd.getPM().deletePersistent(annot);
+ myShepherd.throwAwayAnnotation(annot);
myShepherd.getPM().deletePersistent(feat);
System.out.println(
"INFO: AnnotationEdit.remove deleted [enc,annot,feat]=[" +
diff --git a/src/main/java/org/ecocean/servlet/EncounterRemoveAnnotation.java b/src/main/java/org/ecocean/servlet/EncounterRemoveAnnotation.java
index 8841fe0e4e..0ee4ccb92d 100644
--- a/src/main/java/org/ecocean/servlet/EncounterRemoveAnnotation.java
+++ b/src/main/java/org/ecocean/servlet/EncounterRemoveAnnotation.java
@@ -104,7 +104,7 @@ public void doPost(HttpServletRequest request, HttpServletResponse response)
enc.addComments("Annotation deleted by " + user.getDisplayName() + " on " +
Util.prettyTimeStamp() + "
");
- myShepherd.getPM().deletePersistent(ann);
+ myShepherd.throwAwayAnnotation(ann);
myShepherd.updateDBTransaction();
res.put("revertToTrivial", true);
}
@@ -121,7 +121,7 @@ else if (!ann.isTrivial()) {
"\">Annotation deleted by " + user.getDisplayName() + " on " +
Util.prettyTimeStamp() + "");
enc.removeAnnotation(ann);
- myShepherd.getPM().deletePersistent(ann);
+ myShepherd.throwAwayAnnotation(ann);
myShepherd.commitDBTransaction();
}
response.setStatus(HttpServletResponse.SC_OK);
diff --git a/src/main/java/org/ecocean/servlet/importer/ImportTask.java b/src/main/java/org/ecocean/servlet/importer/ImportTask.java
index 73906e0b10..6fded9d75b 100644
--- a/src/main/java/org/ecocean/servlet/importer/ImportTask.java
+++ b/src/main/java/org/ecocean/servlet/importer/ImportTask.java
@@ -498,44 +498,36 @@ public static void deleteWithRelated(String id, User user, Shepherd myShepherd)
if (!user.isAdmin(myShepherd) && !Collaboration.canUserAccessImportTask(itask,
myShepherd.getContext(), user.getUsername()))
throw new IOException("user does not have privileges to delete task");
+ long startedAt = System.currentTimeMillis();
Util.mark("ImportTask.deleteWithRelated(" + id + ") started");
try {
List allEncs = new ArrayList(itask.getEncounters());
- int total = allEncs.size();
- for (int i = 0; i < allEncs.size(); i++) {
- Encounter enc = allEncs.get(i);
+ // First pass: detach annotations from encounters and collect them for a single
+ // batched FK-cleanup at the end. The previous per-annotation throwAwayAnnotation
+ // ran ~6 unindexed JDOQL queries per annotation, which becomes catastrophic for
+ // large imports. The batch path collapses that to a constant number of queries.
+ List allAnns = new ArrayList();
+ long phaseStart = System.currentTimeMillis();
+ for (Encounter enc : allEncs) {
Occurrence occ = myShepherd.getOccurrence(enc);
MarkedIndividual mark = myShepherd.getMarkedIndividualQuiet(enc.getIndividualID());
List projects = myShepherd.getProjectsForEncounter(enc);
ArrayList anns = enc.getAnnotations();
- for (Annotation ann : anns) {
- enc.removeAnnotation(ann);
- // myShepherd.updateDBTransaction();
- List iaTasks = Task.getTasksFor(ann, myShepherd);
- if (iaTasks != null && !iaTasks.isEmpty()) {
- for (Task iaTask : iaTasks) {
- iaTask.removeObject(ann);
- // myShepherd.updateDBTransaction();
- }
+ if (anns != null) {
+ for (Annotation ann : new ArrayList(anns)) {
+ enc.removeAnnotation(ann);
+ if (ann != null) allAnns.add(ann);
}
- myShepherd.throwAwayAnnotation(ann);
- // myShepherd.updateDBTransaction();
}
- // handle occurrences
if (occ != null) {
occ.removeEncounter(enc);
- // myShepherd.updateDBTransaction();
if (occ.getEncounters().size() == 0) {
myShepherd.throwAwayOccurrence(occ);
- // myShepherd.updateDBTransaction();
}
}
- // handle markedindividual
if (mark != null) {
mark.removeEncounter(enc);
- // myShepherd.updateDBTransaction();
if (mark.getEncounters().size() == 0) {
- // remove scheduled tasks referencing this individual
List mergeTasks =
myShepherd.getAllIncompleteScheduledIndividualMerges();
if (mergeTasks != null) {
@@ -546,44 +538,43 @@ public static void deleteWithRelated(String id, User user, Shepherd myShepherd)
}
}
}
- // check for social unit membership and remove
List units = myShepherd.getAllSocialUnitsForMarkedIndividual(
mark);
if (units != null && units.size() > 0) {
for (SocialUnit unit : units) {
- boolean worked = unit.removeMember(mark, myShepherd);
- // if (worked) myShepherd.updateDBTransaction();
+ unit.removeMember(mark, myShepherd);
}
}
myShepherd.throwAwayMarkedIndividual(mark);
- // myShepherd.updateDBTransaction();
}
}
- // handle projects
if (projects != null && projects.size() > 0) {
for (Project project : projects) {
project.removeEncounter(enc);
- // myShepherd.updateDBTransaction();
}
}
itask.removeEncounter(enc);
itask.addLog("Servlet DeleteImportTask removed Encounter: " +
enc.getCatalogNumber());
- // myShepherd.updateDBTransaction();
try {
myShepherd.throwAwayEncounter(enc);
} catch (Exception e) {
System.out.println("Exception on throwAwayEncounter!!");
e.printStackTrace();
}
- // myShepherd.updateDBTransaction();
}
+ Util.mark("ImportTask.deleteWithRelated(" + id + ") encounter loop done (" +
+ allEncs.size() + " encs, " + allAnns.size() + " anns)", phaseStart);
+ // Single batched FK cleanup + delete for all annotations across the import.
+ phaseStart = System.currentTimeMillis();
+ myShepherd.throwAwayAnnotations(allAnns);
+ Util.mark("ImportTask.deleteWithRelated(" + id +
+ ") batched annotation cleanup done", phaseStart);
myShepherd.getPM().deletePersistent(itask);
- // myShepherd.commitDBTransaction();
} catch (Exception ex) {
throw new IOException("general exception on ImportTask delete: " + ex);
}
- Util.mark("ImportTask.deleteWithRelated(" + id + ") completed");
+ Util.mark("ImportTask.deleteWithRelated(" + id + ") completed", startedAt);
}
// this is hobbled together from some complex code in import.jsp
diff --git a/src/main/java/org/ecocean/shepherd/core/Shepherd.java b/src/main/java/org/ecocean/shepherd/core/Shepherd.java
index 52b5a1fef6..d2481a5553 100644
--- a/src/main/java/org/ecocean/shepherd/core/Shepherd.java
+++ b/src/main/java/org/ecocean/shepherd/core/Shepherd.java
@@ -17,6 +17,7 @@
import org.ecocean.grid.ScanTask;
import org.ecocean.grid.ScanWorkItem;
import org.ecocean.ia.MatchResult;
+import org.ecocean.ia.MatchResultProspect;
import org.ecocean.ia.Task;
import org.ecocean.media.*;
import org.ecocean.movement.Path;
@@ -467,8 +468,129 @@ public void throwAwayMicrosatelliteMarkersAnalysis(MicrosatelliteMarkersAnalysis
pm.deletePersistent(analysis);
}
+ // Caller must detach this Annotation from its owning Encounter (Encounter.removeAnnotation)
+ // before calling — that's a business decision about which encounter the annotation
+ // belongs to. This method handles the FK-constrained dependents that JDO mapping cascades
+ // do not reach: MatchResult, MatchResultProspect, Embedding, and the Task.objectAnnotations
+ // join collection. Without this cleanup, the JDO commit fails with constraints like
+ // MATCHRESULT_FK1 (MatchResult.queryAnnotation), the FKs on MatchResultProspect.annotation,
+ // EMBEDDING.ANNOTATION_ID, and the Task↔Annotation join table.
public void throwAwayAnnotation(Annotation ad) {
- pm.deletePersistent(ad);
+ if (ad == null) return;
+ throwAwayAnnotations(java.util.Collections.singletonList(ad));
+ }
+
+ // Batch version: collects all FK-bound dependents in a single pass per dependent table
+ // (one JDOQL query per table instead of N). Used by ImportTask.deleteWithRelated for
+ // bulk-import deletion which can otherwise be O(N) queries × seq scans of large IA tables.
+ public void throwAwayAnnotations(Collection anns) {
+ if (anns == null || anns.isEmpty()) return;
+ List annIds = new ArrayList();
+ for (Annotation a : anns) {
+ if (a != null && a.getId() != null) annIds.add(a.getId());
+ }
+ if (annIds.isEmpty()) return;
+ cleanUpAnnotationReferencesBatch(annIds);
+ pm.flush(); // ensure dependent deletes hit the DB before the annotation rows go
+ for (Annotation a : anns) {
+ if (a != null) pm.deletePersistent(a);
+ }
+ }
+
+ private void cleanUpAnnotationReferencesBatch(Collection annIds) {
+ // 1. Delete every MatchResultProspect whose annotation OR whose parent MR's
+ // queryAnnotation is in the doomed set. Single query covers both cases.
+ Query qProspects = pm.newQuery(MatchResultProspect.class);
+ qProspects.setFilter(
+ ":ids.contains(annotation.id) || :ids.contains(matchResult.queryAnnotation.id)");
+ try {
+ @SuppressWarnings("unchecked")
+ Collection prospects =
+ (Collection)qProspects.execute(annIds);
+ if (prospects != null) {
+ for (MatchResultProspect p : new ArrayList(prospects)) {
+ pm.deletePersistent(p);
+ }
+ }
+ } finally {
+ qProspects.closeAll();
+ }
+ pm.flush(); // commit prospect deletes before MR FK check
+
+ // 2. Delete MatchResults whose queryAnnotation is in the doomed set.
+ Query qMRs = pm.newQuery(MatchResult.class);
+ qMRs.setFilter(":ids.contains(queryAnnotation.id)");
+ try {
+ @SuppressWarnings("unchecked")
+ Collection mrs = (Collection)qMRs.execute(annIds);
+ if (mrs != null) {
+ for (MatchResult mr : new ArrayList(mrs)) {
+ pm.deletePersistent(mr);
+ }
+ }
+ } finally {
+ qMRs.closeAll();
+ }
+ pm.flush();
+
+ // 3. Remove all doomed annotations from any surviving MatchResult.candidates collection.
+ // Defensive — the field is rarely populated (see MatchResult.java comment) but the
+ // join-table FK would still block deletion if any row referenced these annotations.
+ Query qCand = pm.newQuery(MatchResult.class);
+ qCand.setFilter("candidates.contains(c) && :ids.contains(c.id)");
+ qCand.declareVariables("org.ecocean.Annotation c");
+ try {
+ @SuppressWarnings("unchecked")
+ Collection mrsWithCand = (Collection)qCand.execute(annIds);
+ if (mrsWithCand != null) {
+ for (MatchResult mr : new ArrayList(mrsWithCand)) {
+ if (mr.getCandidates() == null) continue;
+ Iterator it = mr.getCandidates().iterator();
+ while (it.hasNext()) {
+ Annotation cAnn = it.next();
+ if (cAnn != null && annIds.contains(cAnn.getId())) it.remove();
+ }
+ }
+ }
+ } finally {
+ qCand.closeAll();
+ }
+
+ // 4. Delete Embeddings whose annotation is in the doomed set.
+ Query qEmb = pm.newQuery(Embedding.class);
+ qEmb.setFilter(":ids.contains(annotation.id)");
+ try {
+ @SuppressWarnings("unchecked")
+ Collection embs = (Collection)qEmb.execute(annIds);
+ if (embs != null) {
+ for (Embedding e : new ArrayList(embs)) {
+ pm.deletePersistent(e);
+ }
+ }
+ } finally {
+ qEmb.closeAll();
+ }
+
+ // 5. Remove all doomed annotations from any Task.objectAnnotations join collection.
+ Query qTasks = pm.newQuery(Task.class);
+ qTasks.setFilter("objectAnnotations.contains(a) && :ids.contains(a.id)");
+ qTasks.declareVariables("org.ecocean.Annotation a");
+ try {
+ @SuppressWarnings("unchecked")
+ Collection tasks = (Collection)qTasks.execute(annIds);
+ if (tasks != null) {
+ for (Task t : new ArrayList(tasks)) {
+ if (t.getObjectAnnotations() == null) continue;
+ List toRemove = new ArrayList();
+ for (Annotation a : t.getObjectAnnotations()) {
+ if (a != null && annIds.contains(a.getId())) toRemove.add(a);
+ }
+ for (Annotation a : toRemove) t.removeObject(a);
+ }
+ }
+ } finally {
+ qTasks.closeAll();
+ }
}
public void throwAwayOccurrence(Occurrence occ) {
diff --git a/src/main/resources/org/ecocean/ia/package.jdo b/src/main/resources/org/ecocean/ia/package.jdo
index b47b49b153..9f75bf2c43 100755
--- a/src/main/resources/org/ecocean/ia/package.jdo
+++ b/src/main/resources/org/ecocean/ia/package.jdo
@@ -86,11 +86,12 @@ alter table "TASK" alter column "PARAMETERS" type text;
-
+
+
@@ -102,6 +103,7 @@ alter table "TASK" alter column "PARAMETERS" type text;
+
diff --git a/src/main/resources/org/ecocean/package.jdo b/src/main/resources/org/ecocean/package.jdo
index 74519b8018..3e3fd08d94 100755
--- a/src/main/resources/org/ecocean/package.jdo
+++ b/src/main/resources/org/ecocean/package.jdo
@@ -1023,6 +1023,7 @@
+