Skip to content

Commit 01b4921

Browse files
committed
[NRT-771] Guard the batched AH-DB reads; trim restated comments
The diff threading and the media batching were both perf claims resting on inspection alone, so pin the two that a future edit could silently undo: - `test_ah_db_note_readers_agree_about_deleted_notes` pins the equivalence the batched read rides on: `note_data` and `notes_data_for_anki_nids` must keep agreeing about deleted rows, or the outgoing media-name set changes silently. - `test_bulk_submit_does_not_read_the_ah_db_per_note` asserts the AnkiHub-DB read count for a bulk submit does not grow with the note count. Asserting non-scaling rather than exact numbers keeps it robust to unrelated changes in the submit path. Checked against the pre-batching implementation, where it fails, so it is not a vacuous guard. A wall-clock gate would not catch either regression: re-adding thousands of queries still fits inside the existing 2s ceiling on CI hardware. Also: "DELETE carries no field/tag content" was stated five times in this file; drop the one the `if change_type != DELETE` below it already says. And correct the perf test's claim that 500 notes "matches the per-bulk-suggestion cap" — the cap is 2000, and the ceiling is calibrated to 500.
1 parent 098432d commit 01b4921

3 files changed

Lines changed: 79 additions & 8 deletions

File tree

ankihub/main/suggestions.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,7 @@ class BulkSuggestionFilters:
151151

152152
@classmethod
153153
def none_selected(cls) -> "BulkSuggestionFilters":
154-
"""Select nothing, for a submit that carries no field or tag choices (DELETE).
155-
Distinct from passing no filters at all, which ships everything.
156-
"""
154+
"""Distinct from passing no filters at all, which ships everything."""
157155
return cls(fields_to_include_by_mid={})
158156

159157
def for_mid(self, mid: NotetypeId) -> PerNoteFilters:
@@ -215,8 +213,8 @@ def compute_note_diffs(notes: Sequence[Note]) -> Dict[NoteId, NoteDiff]:
215213
)
216214

217215
if ah_note is None:
218-
# New-note candidate: no AH baseline, so "what would ship" is every non-empty
219-
# field (new-note suggestions never carry empty fields) and all current tags.
216+
# New-note suggestions never carry empty fields, so for a note with no AH
217+
# baseline "what would ship" is exactly its non-empty fields.
220218
changed_fields = [f for f in cur.fields if f.value]
221219
added_tags = list(cur.tags or [])
222220
removed_tags: List[str] = []
@@ -794,7 +792,6 @@ def _change_note_suggestion(
794792
removed_tags: List[str] = []
795793
fields_that_changed: List[Field] = []
796794

797-
# DELETE carries no field/tag content, so it ships an empty suggestion.
798795
if change_type != SuggestionType.DELETE:
799796
fields_that_changed = _apply_field_allowlist(diff.changed_fields, filters.fields_to_include)
800797
added_tags = _apply_tag_allowlist(diff.added_tags, filters.tags_to_add)

tests/addon/performance/test_suggestion_dialog.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,17 @@ def test_bulk_suggestion_dialog_open_diff_pipeline(
3030
profile: Profile,
3131
):
3232
"""Measures the per-note work that runs synchronously on the UI thread
33-
when the user opens the bulk Suggest-a-change dialog at the 500-note cap.
33+
when the user opens the bulk Suggest-a-change dialog.
3434
Exercises `compute_note_diffs` once and feeds its result through the
3535
bulk-suggestible gate and the media check. The widget's `_populate` is
3636
explicitly excluded — it's cheap per-note filtering off already-computed
3737
diffs — and isn't covered by this test.
3838
"""
3939
with anki_session_with_addon_data.profile_loaded():
4040
mw = anki_session_with_addon_data.mw
41-
notes_amount = 500 # matches the per-bulk-suggestion cap
41+
# A quarter of the 2000-note bulk cap (BULK_SUGGESTION_LIMIT), to keep the
42+
# test quick; the ceiling below is calibrated to this size, not to the cap.
43+
notes_amount = 500
4244

4345
ankihub_did = next_deterministic_uuid()
4446
importer = AnkiHubImporter()

tests/addon/test_integration.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2513,6 +2513,78 @@ def test_bulk_new_notes_dont_ship_field_empty_on_that_note(
25132513
# The note with an empty Back ships only Front — no empty field.
25142514
assert [f.name for f in sent_by_nid[note_without_back.id].fields] == ["Front"]
25152515

2516+
def test_ah_db_note_readers_agree_about_deleted_notes(
2517+
self,
2518+
anki_session_with_addon_data: AnkiSession,
2519+
install_ah_deck: InstallAHDeck,
2520+
import_ah_note: ImportAHNote,
2521+
):
2522+
"""The media step reads note data through the batched `notes_data_for_anki_nids`
2523+
rather than a per-note `note_data` loop. That swap is only sound while the two
2524+
agree about deleted rows; if they diverge the outgoing media-name set changes
2525+
silently, so pin the agreement here.
2526+
"""
2527+
with anki_session_with_addon_data.profile_loaded():
2528+
ah_did = install_ah_deck()
2529+
note_info = import_ah_note(ah_did=ah_did)
2530+
nid = ankihub_db.anki_nid_for_ankihub_nid(note_info.ah_nid)
2531+
2532+
assert ankihub_db.note_data(nid) is not None
2533+
assert [note.anki_nid for note in ankihub_db.notes_data_for_anki_nids([nid])] == [nid]
2534+
2535+
AnkiHubNote.update(last_update_type=SuggestionType.DELETE.value[0]).where(
2536+
AnkiHubNote.anki_note_id == nid
2537+
).execute()
2538+
2539+
assert ankihub_db.note_data(nid) is None
2540+
assert ankihub_db.notes_data_for_anki_nids([nid]) == []
2541+
2542+
def test_bulk_submit_does_not_read_the_ah_db_per_note(
2543+
self,
2544+
anki_session_with_addon_data: AnkiSession,
2545+
mocker: MockerFixture,
2546+
install_ah_deck: InstallAHDeck,
2547+
next_deterministic_uuid: Callable[[], uuid.UUID],
2548+
import_ah_note_type: ImportAHNoteType,
2549+
add_anki_note: AddAnkiNote,
2550+
):
2551+
"""Submitting reads the AnkiHub DB per batch and per note type, never per note.
2552+
Asserting the counts don't grow with the note count rather than pinning exact
2553+
numbers keeps this robust to unrelated changes in the submit path. At the
2554+
2000-note bulk cap a reintroduced per-note read costs thousands of queries, and
2555+
no other test would notice.
2556+
"""
2557+
with anki_session_with_addon_data.profile_loaded():
2558+
ah_did = install_ah_deck()
2559+
note_type = import_ah_note_type(ah_did=ah_did)
2560+
mocker.patch.object(AnkiHubClient, "create_suggestions_in_bulk", return_value={})
2561+
note_data_spy = mocker.spy(ankihub_db, "note_data")
2562+
note_type_dict_spy = mocker.spy(ankihub_db, "note_type_dict")
2563+
2564+
def ah_db_reads_for_submitting(note_count: int) -> Tuple[int, int]:
2565+
notes = []
2566+
for i in range(note_count):
2567+
note = add_anki_note(note_type=note_type)
2568+
note["Front"] = f"front_{len(notes)}_{i}"
2569+
aqt.mw.col.update_note(note)
2570+
notes.append(note)
2571+
2572+
mocker.patch("uuid.uuid4", side_effect=[next_deterministic_uuid() for _ in range(note_count)])
2573+
note_data_spy.reset_mock()
2574+
note_type_dict_spy.reset_mock()
2575+
2576+
suggest_notes_in_bulk(
2577+
ankihub_did=ah_did,
2578+
notes=notes,
2579+
auto_accept=False,
2580+
change_type=SuggestionType.NEW_CONTENT,
2581+
comment="test",
2582+
media_upload_cb=mocker.stub(),
2583+
)
2584+
return note_data_spy.call_count, note_type_dict_spy.call_count
2585+
2586+
assert ah_db_reads_for_submitting(2) == ah_db_reads_for_submitting(6)
2587+
25162588
@pytest.mark.parametrize(
25172589
"note_has_changes, note_is_marked_as_deleted",
25182590
[

0 commit comments

Comments
 (0)