Skip to content

Commit c48d7be

Browse files
committed
fix: don't lock completely during catalog import
1 parent 70b3b23 commit c48d7be

3 files changed

Lines changed: 121 additions & 15 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
id: TASK-21
3+
title: Allow writes during catalog imports
4+
status: Done
5+
assignee:
6+
- '@cfb'
7+
created_date: '2026-07-28 18:00'
8+
updated_date: '2026-07-28 18:14'
9+
labels: []
10+
dependencies: []
11+
type: bug
12+
ordinal: 21000
13+
---
14+
15+
## Description
16+
17+
<!-- SECTION:DESCRIPTION:BEGIN -->
18+
A production deck-card write returned HTTP 500 with SQLite database busy while the Scryfall catalog import held the database-wide write lock. Keep catalog batches atomic while releasing the lock between batches so interactive writes can proceed.
19+
<!-- SECTION:DESCRIPTION:END -->
20+
21+
## Acceptance Criteria
22+
<!-- AC:BEGIN -->
23+
- [x] #1 Interactive deck-card writes can complete while a multi-batch catalog import is running
24+
- [x] #2 Each catalog batch stores its cards, printings, and search rows atomically
25+
- [x] #3 A completed catalog import reports the same counts and searchable card data as before
26+
<!-- AC:END -->
27+
28+
## Implementation Plan
29+
30+
<!-- SECTION:PLAN:BEGIN -->
31+
1. Replace the catalog-wide SQLite transaction with one immediate transaction per 200-card source batch, keeping each batch's card, printing, and search-row writes atomic while releasing the write lock between batches. 2. Preserve aggregate counts, logging, cache invalidation, and error propagation across the batch loop. 3. Add focused multi-batch regression coverage and exercise a concurrent deck-card write against a real SQLite repository with a catalog import in progress.
32+
<!-- SECTION:PLAN:END -->
33+
34+
## Implementation Notes
35+
36+
<!-- SECTION:NOTES:BEGIN -->
37+
Reporter clarified the failure coincided with a Scryfall catalog import and the production exception was SQLite database busy. The import now commits each 200-card source batch independently instead of holding one multi-minute write transaction. A failed later batch can leave earlier, internally consistent upserts committed; imports are idempotent and a failed sync remains stale for retry. Validation: 32 focused import/sync/deck GraphQL tests passed; strict compilation passed; a real temporary SQLite repo imported 20,000 cards while a maybeboard write completed in 7ms and the importer was still active.
38+
<!-- SECTION:NOTES:END -->
39+
40+
## Final Summary
41+
42+
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
43+
Released SQLite's write lock between atomic Scryfall catalog batches so interactive writes no longer wait behind the full import. Preserved import counts and searchable rows, added transaction-boundary and failed-batch atomicity tests, and verified a concurrent maybeboard add during a 20,000-card import.
44+
<!-- SECTION:FINAL_SUMMARY:END -->

‎lib/manavault/catalog/scryfall/import.ex‎

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,18 @@ defmodule Manavault.Catalog.Scryfall.Import do
2525
log_import_started(log_progress?, source_count)
2626

2727
result =
28-
Repo.transact(
29-
fn ->
30-
counts = import_card_batches(cards, now, oracle_tag_index, source_count, log_progress?)
31-
28+
case import_card_batches(cards, now, oracle_tag_index, source_count, log_progress?) do
29+
{:ok, counts} ->
3230
{:ok,
3331
%{
3432
cards_count: counts.cards_count,
3533
printings_count: counts.printings_count,
3634
bulk_uri: bulk_uri
3735
}}
38-
end,
39-
timeout: :infinity
40-
)
36+
37+
{:error, reason} ->
38+
{:error, reason}
39+
end
4140

4241
case result do
4342
{:ok, counts} ->
@@ -54,19 +53,36 @@ defmodule Manavault.Catalog.Scryfall.Import do
5453
defp import_card_batches(cards, now, oracle_tag_index, source_count, log_progress?) do
5554
cards
5655
|> Enum.chunk_every(@batch_size)
57-
|> Enum.reduce(initial_import_counts(), fn batch, counts ->
56+
|> Enum.reduce_while({:ok, initial_import_counts()}, fn batch, {:ok, counts} ->
5857
rows = ImportRows.rows(batch, now, oracle_tag_index)
5958

60-
insert_card_rows(rows.cards)
61-
insert_printing_rows(rows.printings)
62-
refresh_printing_search_rows(rows.search_rows)
59+
case import_batch(rows) do
60+
{:ok, :imported} ->
61+
counts =
62+
counts
63+
|> advance_import_counts(length(batch), rows)
64+
|> maybe_log_import_progress(log_progress?, source_count)
6365

64-
counts
65-
|> advance_import_counts(length(batch), rows)
66-
|> maybe_log_import_progress(log_progress?, source_count)
66+
{:cont, {:ok, counts}}
67+
68+
{:error, reason} ->
69+
{:halt, {:error, reason}}
70+
end
6771
end)
6872
end
6973

74+
defp import_batch(rows) do
75+
Repo.transact(
76+
fn ->
77+
insert_card_rows(rows.cards)
78+
insert_printing_rows(rows.printings)
79+
refresh_printing_search_rows(rows.search_rows)
80+
{:ok, :imported}
81+
end,
82+
timeout: :infinity
83+
)
84+
end
85+
7086
defp initial_import_counts do
7187
%{
7288
source_count: 0,

‎test/manavault/catalog/import_test.exs‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,26 @@ defmodule Manavault.Catalog.ImportTest do
5555
assert Jason.decode!(prices) == %{"usd" => "1.00"}
5656
end
5757

58-
test "import_cards processes batches without dropping search rows" do
58+
test "import_cards releases the write lock between batches without dropping search rows" do
59+
test_pid = self()
60+
handler_id = {__MODULE__, make_ref()}
61+
62+
:ok =
63+
:telemetry.attach(
64+
handler_id,
65+
[:manavault, :repo, :query],
66+
fn _event, _measurements, metadata, pid ->
67+
query = metadata |> Map.get(:query, "") |> to_string() |> String.downcase()
68+
69+
if query == "commit" or String.starts_with?(query, "release savepoint") do
70+
send(pid, :catalog_import_batch_committed)
71+
end
72+
end,
73+
test_pid
74+
)
75+
76+
on_exit(fn -> :telemetry.detach(handler_id) end)
77+
5978
cards =
6079
Enum.map(1..205, fn index ->
6180
%{
@@ -68,6 +87,8 @@ defmodule Manavault.Catalog.ImportTest do
6887
end)
6988

7089
assert {:ok, %{cards_count: 205, printings_count: 205}} = Catalog.import_cards(cards)
90+
assert_receive :catalog_import_batch_committed
91+
assert_receive :catalog_import_batch_committed
7192
assert Repo.aggregate(Card, :count) == 205
7293
assert Repo.aggregate(Printing, :count) == 205
7394

@@ -79,6 +100,31 @@ defmodule Manavault.Catalog.ImportTest do
79100
)
80101
end
81102

103+
test "import_cards rolls back every write in a failed batch" do
104+
Repo.query!("""
105+
CREATE TEMP TRIGGER fail_catalog_batch
106+
BEFORE INSERT ON scryfall_printings
107+
WHEN NEW.scryfall_id = 'scryfall-atomic-batch'
108+
BEGIN
109+
SELECT RAISE(ABORT, 'catalog batch test failure');
110+
END
111+
""")
112+
113+
card = %{
114+
@time_walk
115+
| "id" => "scryfall-atomic-batch",
116+
"oracle_id" => "oracle-atomic-batch",
117+
"name" => "Atomic Batch Card"
118+
}
119+
120+
assert_raise Exqlite.Error, ~r/catalog batch test failure/, fn ->
121+
Catalog.import_cards([card])
122+
end
123+
124+
refute Repo.get(Card, "oracle-atomic-batch")
125+
refute Repo.get(Printing, "scryfall-atomic-batch")
126+
end
127+
82128
test "import_cards stores selected oracle tags and derives deck grouping fields" do
83129
oracle_tags = [
84130
scryfall_tag(%{

0 commit comments

Comments
 (0)