Open simonw opened 2 months ago
Most of the writes to _enrichment_jobs
happen in this task in datasette-enrichments
core:
I'm going to move this to the datasette-enrichments
repository. I think it's a bug there.
My best guess is that this is happening because we are running db.execute_write()
calls from inside a task that was kicked off like this:
That's not a pattern I have much experience with, especially within the Datasette ecosystem.
How could that result in a sqlite3.OperationalError: database table is locked
error though?
A few options:
asyncio
protecting lock around these operations, to ensure that no concurrent asyncio
task ever attempts to access the database when it should be lockeddb.execute_write_fn()
? That should ensure the operation runs on the write thread.I'll try option 3 first.
I'm trying to reliably replicate this. Here's my first attempt, but the test passed just fine:
@pytest.mark.asyncio
async def test_async_tasks_do_not_crash():
ds = Datasette()
db = ds.add_memory_database("test_async_tasks_do_not_crash")
await db.execute_write("create table counter (id integer primary key, n integer)")
await db.execute_write("insert into counter (n) values (0)")
# We are going to spin off a bunch of tasks that all write to the counter
async def increment():
for i in range(10):
await db.execute_write("update counter set n = n + 1 where id = 1")
loop = asyncio.get_event_loop()
for i in range(20):
loop.create_task(increment())
# Wait 1s
await asyncio.sleep(1)
# Check the counter
counter = await db.execute("select n from counter where id = 1")
assert counter.first()[0] == 200
Tried it against an on-disk database too, still passed on my laptop:
@pytest.mark.asyncio
async def test_async_tasks_do_not_crash(tmp_path_factory):
tmpdir = tmp_path_factory.mktemp("test_async_tasks_do_not_crash")
db_path = str(tmpdir / "test.db")
ds = Datasette([db_path])
db = ds.get_database("test")
await db.execute_write("create table counter (id integer primary key, n integer)")
await db.execute_write("insert into counter (n) values (0)")
# We are going to spin off a bunch of tasks that all write to the counter
async def increment():
for i in range(10):
await db.execute_write("update counter set n = n + 1 where id = 1")
loop = asyncio.get_event_loop()
for i in range(100):
loop.create_task(increment())
# Wait 1s
await asyncio.sleep(1)
# Check the counter
counter = await db.execute("select n from counter where id = 1")
assert counter.first()[0] == 1000
But maybe it would fail in CI?
That failed with errors like this:
counter = await db.execute("select n from counter where id = 1")
> assert counter.first()[0] == 1000
E assert 862 == 1000
So clearly that 1s wasn't long enough for the tasks to all complete.
I've failed to replicate the error I'm seeing.
I'm going to stick that new test in the Datasette test suite anyway, as an illustration of loop.create_task()
.
Getting this intermittent test failure in CI: https://github.com/datasette/datasette-enrichments-gpt/actions/runs/8863259601/job/24337099138
Looks like it happens in the code that polls to see if the enrichment has finished: https://github.com/datasette/datasette-enrichments-gpt/blob/e5f5da1ccc97ade51f3c0b3f6958dcf2998cf772/tests/test_enrichments_gpt.py#L38-L47
Which calls this: https://github.com/datasette/datasette-enrichments/blob/ad61b35b541272cda10e25fcc7b2c4b5e39d2e72/datasette_enrichments/utils.py#L18-L57
Most relevant code snippet: