refactor(timeline): resolve each letter's primary tag once

mapDocument re-ran the alphabetical min() scan over the letter's tag set to
look up its already-resolved root, duplicating the work resolveLetterRootTags
had just done and leaving two independent definitions of "primary tag" that
could silently diverge. Key the resolved-root map by document id and compute
the primary tag exactly once per letter; drop the redundant resolvePrimaryRoot
helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 17:58:20 +02:00
parent 4859c77964
commit cf6a262a7a

View File

@@ -19,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -105,9 +106,9 @@ public class TimelineService {
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue; if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
letters.add(doc); letters.add(doc);
} }
Map<UUID, RootTag> rootByPrimaryTagId = resolveLetterRootTags(letters); Map<UUID, RootTag> rootByDocId = resolveLetterRootTags(letters);
for (Document doc : letters) { for (Document doc : letters) {
entries.add(mapDocument(doc, rootByPrimaryTagId)); entries.add(mapDocument(doc, rootByDocId));
} }
return bucket(entries); return bucket(entries);
@@ -232,8 +233,8 @@ public class TimelineService {
); );
} }
private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByPrimaryTagId) { private TimelineEntryDTO mapDocument(Document doc, Map<UUID, RootTag> rootByDocId) {
RootTag root = resolvePrimaryRoot(doc, rootByPrimaryTagId); RootTag root = rootByDocId.get(doc.getId());
return new TimelineEntryDTO( return new TimelineEntryDTO(
Kind.LETTER, Kind.LETTER,
doc.getMetaDatePrecision(), doc.getMetaDatePrecision(),
@@ -255,22 +256,27 @@ public class TimelineService {
} }
/** /**
* Resolves the root tags for the letters' primary tags in one batched pass — no per-letter * Resolves each letter's primary root tag in one batched pass, keyed by document id — no
* N+1: each letter contributes only its alphabetically-first assigned tag (#835), and * per-letter N+1: each letter contributes only its alphabetically-first assigned tag (#835),
* {@link TagService#resolveRootTags} memoizes the ancestry walk per distinct tag. * so the {@code min()} scan over a letter's tag set runs exactly once here (not again at map
* time), and {@link TagService#resolveRootTags} memoizes the ancestry walk per distinct tag.
*/ */
private Map<UUID, RootTag> resolveLetterRootTags(List<Document> letters) { private Map<UUID, RootTag> resolveLetterRootTags(List<Document> letters) {
List<Tag> primaryTags = letters.stream() Map<UUID, Tag> primaryByDocId = new LinkedHashMap<>();
.map(TimelineService::primaryTag) for (Document doc : letters) {
.filter(t -> t != null) Tag primary = primaryTag(doc);
.toList(); if (primary != null) primaryByDocId.put(doc.getId(), primary);
if (primaryTags.isEmpty()) return Map.of(); }
return tagService.resolveRootTags(primaryTags); if (primaryByDocId.isEmpty()) return Map.of();
}
private RootTag resolvePrimaryRoot(Document doc, Map<UUID, RootTag> rootByPrimaryTagId) { Map<UUID, RootTag> rootByTagId =
Tag primary = primaryTag(doc); tagService.resolveRootTags(new ArrayList<>(primaryByDocId.values()));
return primary == null ? null : rootByPrimaryTagId.get(primary.getId()); Map<UUID, RootTag> rootByDocId = new HashMap<>();
primaryByDocId.forEach((docId, primary) -> {
RootTag root = rootByTagId.get(primary.getId());
if (root != null) rootByDocId.put(docId, root);
});
return rootByDocId;
} }
/** A letter's primary tag: the alphabetically-first of its assigned tags by name (#835). */ /** A letter's primary tag: the alphabetically-first of its assigned tags by name (#835). */