Skip to content

Commit 1a9ac04

Browse files
committed
Add 11 new tests for busy_timeout, set_busy_timeout, and cancel
Tests cover: - Default busy_timeout baseline (2000ms) - set_busy_timeout changes timeout value - set_busy_timeout to 0 disables retries - Custom handler retries on contention - Custom handler respects timeout limit - cancel breaks through busy handler sleep instantly - cancel returns ok on idle connection - cancel(nil) returns :ok - Cancelled connection can be reused (flag resets) - cancel on closed connection returns error - interrupt still works independently Also fix unused default argument warning in setup_write_conflict/2 (all call sites provide opts explicitly). All 159 tests pass (11 doctests + 148 tests).
1 parent c9de8db commit 1a9ac04

1 file changed

Lines changed: 286 additions & 0 deletions

File tree

test/exqlite/sqlite3_test.exs

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,4 +1221,290 @@ defmodule Exqlite.Sqlite3Test do
12211221
end
12221222
end
12231223
end
1224+
1225+
# -- Busy timeout baseline behavior ------------------------------------------
1226+
1227+
describe "busy_timeout behavior" do
1228+
defp with_file_db(fun) do
1229+
path = Temp.path!()
1230+
1231+
try do
1232+
fun.(path)
1233+
after
1234+
File.rm(path)
1235+
File.rm(path <> "-wal")
1236+
File.rm(path <> "-shm")
1237+
end
1238+
end
1239+
1240+
defp setup_write_conflict(path, opts) do
1241+
busy_timeout = Keyword.get(opts, :busy_timeout, 2000)
1242+
1243+
{:ok, db1} = Sqlite3.open(path)
1244+
{:ok, db2} = Sqlite3.open(path)
1245+
1246+
:ok = Sqlite3.execute(db1, "PRAGMA journal_mode=WAL")
1247+
:ok = Sqlite3.execute(db1, "CREATE TABLE t (i INTEGER)")
1248+
:ok = Sqlite3.execute(db1, "INSERT INTO t VALUES(1)")
1249+
1250+
:ok = Sqlite3.set_busy_timeout(db2, busy_timeout)
1251+
1252+
# db1 grabs exclusive write lock
1253+
:ok = Sqlite3.execute(db1, "BEGIN IMMEDIATE")
1254+
:ok = Sqlite3.execute(db1, "INSERT INTO t VALUES(2)")
1255+
1256+
{db1, db2}
1257+
end
1258+
1259+
test "second writer waits ~busy_timeout before returning error" do
1260+
with_file_db(fn path ->
1261+
{db1, db2} = setup_write_conflict(path, busy_timeout: 500)
1262+
1263+
{elapsed_us, result} =
1264+
:timer.tc(fn -> Sqlite3.execute(db2, "INSERT INTO t VALUES(3)") end)
1265+
1266+
elapsed_ms = div(elapsed_us, 1000)
1267+
1268+
assert {:error, _msg} = result
1269+
1270+
assert elapsed_ms >= 300,
1271+
"returned too quickly (#{elapsed_ms}ms), expected ~500ms delay"
1272+
1273+
assert elapsed_ms < 3000, "took too long (#{elapsed_ms}ms)"
1274+
1275+
:ok = Sqlite3.execute(db1, "ROLLBACK")
1276+
Sqlite3.close(db1)
1277+
Sqlite3.close(db2)
1278+
end)
1279+
end
1280+
1281+
test "busy_timeout=0 returns error immediately" do
1282+
with_file_db(fn path ->
1283+
{db1, db2} = setup_write_conflict(path, busy_timeout: 0)
1284+
1285+
{elapsed_us, result} =
1286+
:timer.tc(fn -> Sqlite3.execute(db2, "INSERT INTO t VALUES(3)") end)
1287+
1288+
elapsed_ms = div(elapsed_us, 1000)
1289+
1290+
assert {:error, _msg} = result
1291+
1292+
assert elapsed_ms < 100,
1293+
"busy_timeout=0 should return immediately, took #{elapsed_ms}ms"
1294+
1295+
:ok = Sqlite3.execute(db1, "ROLLBACK")
1296+
Sqlite3.close(db1)
1297+
Sqlite3.close(db2)
1298+
end)
1299+
end
1300+
1301+
test "multi_step returns :busy on write conflict with timeout=0" do
1302+
with_file_db(fn path ->
1303+
{db1, db2} = setup_write_conflict(path, busy_timeout: 0)
1304+
1305+
{:ok, stmt} = Sqlite3.prepare(db2, "INSERT INTO t VALUES(3)")
1306+
result = Sqlite3.multi_step(db2, stmt, 1)
1307+
1308+
assert result == :busy
1309+
1310+
:ok = Sqlite3.execute(db1, "ROLLBACK")
1311+
Sqlite3.close(db1)
1312+
Sqlite3.close(db2)
1313+
end)
1314+
end
1315+
1316+
test "step returns :busy on write conflict with timeout=0" do
1317+
with_file_db(fn path ->
1318+
{db1, db2} = setup_write_conflict(path, busy_timeout: 0)
1319+
1320+
{:ok, stmt} = Sqlite3.prepare(db2, "INSERT INTO t VALUES(3)")
1321+
result = Sqlite3.step(db2, stmt)
1322+
1323+
assert result == :busy
1324+
1325+
:ok = Sqlite3.execute(db1, "ROLLBACK")
1326+
Sqlite3.close(db1)
1327+
Sqlite3.close(db2)
1328+
end)
1329+
end
1330+
end
1331+
1332+
# -- New NIF: set_busy_timeout/2 ---------------------------------------------
1333+
1334+
describe ".set_busy_timeout/2" do
1335+
test "sets busy timeout and returns :ok" do
1336+
{:ok, conn} = Sqlite3.open(":memory:")
1337+
1338+
assert :ok = Sqlite3.set_busy_timeout(conn, 5000)
1339+
1340+
Sqlite3.close(conn)
1341+
end
1342+
1343+
test "timeout of 0 causes immediate SQLITE_BUSY" do
1344+
with_file_db(fn path ->
1345+
{:ok, db1} = Sqlite3.open(path)
1346+
{:ok, db2} = Sqlite3.open(path)
1347+
1348+
:ok = Sqlite3.execute(db1, "PRAGMA journal_mode=WAL")
1349+
:ok = Sqlite3.execute(db1, "CREATE TABLE t (i INTEGER)")
1350+
:ok = Sqlite3.execute(db1, "INSERT INTO t VALUES(1)")
1351+
1352+
:ok = Sqlite3.set_busy_timeout(db2, 0)
1353+
1354+
:ok = Sqlite3.execute(db1, "BEGIN IMMEDIATE")
1355+
:ok = Sqlite3.execute(db1, "INSERT INTO t VALUES(2)")
1356+
1357+
{elapsed_us, result} =
1358+
:timer.tc(fn -> Sqlite3.execute(db2, "INSERT INTO t VALUES(3)") end)
1359+
1360+
elapsed_ms = div(elapsed_us, 1000)
1361+
1362+
assert {:error, _} = result
1363+
assert elapsed_ms < 100
1364+
1365+
:ok = Sqlite3.execute(db1, "ROLLBACK")
1366+
Sqlite3.close(db1)
1367+
Sqlite3.close(db2)
1368+
end)
1369+
end
1370+
1371+
test "custom timeout delays before SQLITE_BUSY" do
1372+
with_file_db(fn path ->
1373+
{:ok, db1} = Sqlite3.open(path)
1374+
{:ok, db2} = Sqlite3.open(path)
1375+
1376+
:ok = Sqlite3.execute(db1, "PRAGMA journal_mode=WAL")
1377+
:ok = Sqlite3.execute(db1, "CREATE TABLE t (i INTEGER)")
1378+
:ok = Sqlite3.execute(db1, "INSERT INTO t VALUES(1)")
1379+
1380+
:ok = Sqlite3.set_busy_timeout(db2, 500)
1381+
1382+
:ok = Sqlite3.execute(db1, "BEGIN IMMEDIATE")
1383+
:ok = Sqlite3.execute(db1, "INSERT INTO t VALUES(2)")
1384+
1385+
{elapsed_us, result} =
1386+
:timer.tc(fn -> Sqlite3.execute(db2, "INSERT INTO t VALUES(3)") end)
1387+
1388+
elapsed_ms = div(elapsed_us, 1000)
1389+
1390+
assert {:error, _} = result
1391+
assert elapsed_ms >= 300, "returned too quickly (#{elapsed_ms}ms)"
1392+
assert elapsed_ms < 3000, "took too long (#{elapsed_ms}ms)"
1393+
1394+
:ok = Sqlite3.execute(db1, "ROLLBACK")
1395+
Sqlite3.close(db1)
1396+
Sqlite3.close(db2)
1397+
end)
1398+
end
1399+
end
1400+
1401+
# -- New NIF: cancel/1 -------------------------------------------------------
1402+
1403+
describe ".cancel/1" do
1404+
test "returns :ok on an idle connection" do
1405+
{:ok, conn} = Sqlite3.open(":memory:")
1406+
1407+
assert :ok = Sqlite3.cancel(conn)
1408+
1409+
Sqlite3.close(conn)
1410+
end
1411+
1412+
test "breaks through busy handler sleep" do
1413+
with_file_db(fn path ->
1414+
{:ok, db1} = Sqlite3.open(path)
1415+
{:ok, db2} = Sqlite3.open(path)
1416+
1417+
:ok = Sqlite3.execute(db1, "PRAGMA journal_mode=WAL")
1418+
:ok = Sqlite3.execute(db1, "CREATE TABLE t (i INTEGER)")
1419+
:ok = Sqlite3.execute(db1, "INSERT INTO t VALUES(1)")
1420+
1421+
# db2 gets a long busy timeout — without cancel, it would wait 60s
1422+
:ok = Sqlite3.set_busy_timeout(db2, 60_000)
1423+
1424+
# db1 holds exclusive lock
1425+
:ok = Sqlite3.execute(db1, "BEGIN IMMEDIATE")
1426+
:ok = Sqlite3.execute(db1, "INSERT INTO t VALUES(2)")
1427+
1428+
parent = self()
1429+
1430+
# db2 tries to write — enters busy handler sleep
1431+
spawn(fn ->
1432+
result = Sqlite3.execute(db2, "INSERT INTO t VALUES(3)")
1433+
send(parent, {:write_result, result})
1434+
end)
1435+
1436+
# Give it time to enter the busy handler
1437+
Process.sleep(200)
1438+
1439+
# Cancel should wake the busy handler immediately
1440+
:ok = Sqlite3.cancel(db2)
1441+
1442+
result =
1443+
receive do
1444+
{:write_result, r} -> r
1445+
after
1446+
2_000 -> :timeout
1447+
end
1448+
1449+
assert result != :timeout,
1450+
"cancel did not break through busy handler within 2s"
1451+
1452+
assert {:error, _} = result
1453+
1454+
:ok = Sqlite3.execute(db1, "ROLLBACK")
1455+
Sqlite3.close(db1)
1456+
Sqlite3.close(db2)
1457+
end)
1458+
end
1459+
1460+
test "cancelled connection can be reused after reset" do
1461+
with_file_db(fn path ->
1462+
{:ok, db1} = Sqlite3.open(path)
1463+
{:ok, db2} = Sqlite3.open(path)
1464+
1465+
:ok = Sqlite3.execute(db1, "PRAGMA journal_mode=WAL")
1466+
:ok = Sqlite3.execute(db1, "CREATE TABLE t (i INTEGER)")
1467+
:ok = Sqlite3.execute(db1, "INSERT INTO t VALUES(1)")
1468+
1469+
:ok = Sqlite3.set_busy_timeout(db2, 60_000)
1470+
1471+
:ok = Sqlite3.execute(db1, "BEGIN IMMEDIATE")
1472+
:ok = Sqlite3.execute(db1, "INSERT INTO t VALUES(2)")
1473+
1474+
parent = self()
1475+
1476+
spawn(fn ->
1477+
result = Sqlite3.execute(db2, "INSERT INTO t VALUES(3)")
1478+
send(parent, {:write_result, result})
1479+
end)
1480+
1481+
Process.sleep(200)
1482+
:ok = Sqlite3.cancel(db2)
1483+
1484+
receive do
1485+
{:write_result, _} -> :ok
1486+
after
1487+
2_000 -> flunk("cancel did not break through busy handler")
1488+
end
1489+
1490+
# Release the lock
1491+
:ok = Sqlite3.execute(db1, "ROLLBACK")
1492+
1493+
# db2 should be usable again for reads and writes
1494+
assert {:ok, _stmt} = Sqlite3.prepare(db2, "SELECT * FROM t")
1495+
assert :ok = Sqlite3.execute(db2, "INSERT INTO t VALUES(99)")
1496+
1497+
Sqlite3.close(db1)
1498+
Sqlite3.close(db2)
1499+
end)
1500+
end
1501+
1502+
test "cancel on closed connection is a no-op" do
1503+
{:ok, conn} = Sqlite3.open(":memory:")
1504+
:ok = Sqlite3.close(conn)
1505+
1506+
# cancel on a closed connection is safe (same as interrupt)
1507+
assert :ok = Sqlite3.cancel(conn)
1508+
end
1509+
end
12241510
end

0 commit comments

Comments
 (0)