datasette/datasette/scratch.py
Claude d7a2ce91cc
Fix concurrency, lifecycle and contract problems from the second review
Schema watcher:
- Scan coalescing is loop-agnostic: in-flight scans are concurrent.futures
  futures any event loop can wait for, taken over only when their loop has
  closed or stopped; each refresh() resolves only its own scans. A write on
  one loop no longer hangs forever when another loop's scan is in flight.
- At most one polling task across event loops; replaced pollers exit.
- A scan or catalog write that fails transiently (internal database locked,
  shared-cache lock, cancellation) re-marks the database and retries it from
  later requests and sweeps, with back-off; requests are never failed by it.
  Write threads mark schema changes directly (under a lock) instead of
  relying on a notification reaching a loop that may have closed.
- add_database() file databases and named in-memory databases use the
  default_schema_watch mode (external unless configured). Named in-memory
  databases can be external: polled with PRAGMA schema_version on their
  write connection. They are not scanned at registration time, which raced
  plugins filling them (datasette-copy-to-memory).
- close() no longer persists a fingerprint for an owned database whose file
  header shows a schema the catalog has not seen.
- The derived-table permission rule (external-content FTS) trusts the
  catalog only when a stat and header read prove it is current; otherwise
  it asks the database, and schedules a rescan if the catalog is behind.

Connections and write threads:
- A BaseException from a write or isolated callback is delivered to the
  caller as a RuntimeError instead of killing the thread silently.
- conn.close() on a write lease raises ConnectionLeaseError, and the write
  thread reopens a connection something else closed.
- Write connections pin PRAGMA recursive_triggers=on (sqlite-utils FTS
  triggers rely on it; idle reopen lost the old drift).
- execute_fn() accepts generators that no longer hold the connection or a
  cursor.
- --crossdb _memory connections count against max_open_connections, are
  reaped, and are discarded when databases are added or removed.

Lifecycle:
- The temp internal database file uses weakref.finalize, not atexit, so
  closed or abandoned instances can be garbage collected.
- The read-pool reaper no longer keeps the last pool alive in its frame.
- The scratch directory is loaded (and locked) at the end of __init__, so a
  failed construction leaves nothing locked.
- config_dir/scratch is used only if empty or created by Datasette; a second
  instance on the same config dir starts without it; sidecar files are never
  deleted the first time a directory is opened.

Regression tests: tests/test_connection_review_fixes.py (24 of 27 fail on
a509b81; the other 3 guard behaviour that must not change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwkLo2rJWidpkDVT3HkvNv
2026-10-04 08:32:09 +00:00

1051 lines
39 KiB
Python

"""Scratch databases: SQLite files that plugins create, fill, change and
throw away, and that survive a restart.
The scratch directory looks like this::
<scratch dir>/
.datasette-scratch.db the registry: one row per scratch database
.datasette-scratch.lock flock()ed by the Datasette instance using it
<name>.db one SQLite file per scratch database
<name>.db-wal, -shm while the database is open (WAL mode)
Why a registry file next to the databases (and not metadata inside each
file, or rows in the internal database): see "Scratch databases" in
docs/internals.rst. In short:
* Each registry change is one SQLite transaction. create, delete and rename
first record an intent (``state`` = creating / deleting / renaming), then
change the files, then finish the row. ``_reconcile()`` at startup rolls
back an interrupted create and finishes an interrupted delete or rename,
so a crash at any point leaves either the old or the new state, never a
database without its metadata or a stray ``-wal`` file.
* Startup reads one small file and lists one directory. No scratch database
is opened, and no thread is started, until something uses it.
* The registry lives with the files, so it survives a temporary internal
database, and moving or copying the directory keeps the metadata.
* ``last_used`` is tracked in memory on every read and write and written to
the registry at most once per ``LAST_USED_FLUSH_INTERVAL_S`` (and on
close, and with any other registry change) - never a write per read.
The directory is the source of truth for which databases exist: a ``.db``
file copied in by hand is adopted at the next startup (without owner or
metadata), and a row whose file was deleted by hand is dropped.
"""
import asyncio
import dataclasses
import errno
import json
import logging
import os
import re
import secrets
import shutil
import tempfile
import threading
import time
import weakref
from pathlib import Path
from .database import Database, DatasetteClosedError
from .utils import StartupError, sqlite3
logger = logging.getLogger("datasette.scratch")
REGISTRY_FILENAME = ".datasette-scratch.db"
LOCK_FILENAME = ".datasette-scratch.lock"
SUFFIX = ".db"
# Removal order: -wal first. A -wal left next to a missing main file would be
# replayed into the next database file of that name (SQLite says "database
# disk image is malformed")
SIDECAR_SUFFIXES = ("-wal", "-journal", "-shm")
NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}")
# Characters that would change the meaning of the file: URI Datasette opens
# databases with
UNSAFE_PATH_CHARS = ("?", "#", "%")
LAST_USED_FLUSH_INTERVAL_S = 60.0
SQLITE_HEADER = b"SQLite format 3\x00"
REGISTRY_SCHEMA = """
CREATE TABLE IF NOT EXISTS scratch_databases (
name TEXT PRIMARY KEY,
created REAL NOT NULL,
last_used REAL,
owner TEXT,
metadata TEXT,
-- ready | creating | deleting | renaming
state TEXT NOT NULL DEFAULT 'ready',
rename_to TEXT
)
"""
# Registry statements. create, delete and rename record their intent first
# (state = creating / deleting / renaming), then change the files, then
# finish the row - see _reconcile()
SQL_INSERT_CREATING = (
"insert into scratch_databases "
"(name, created, last_used, owner, metadata, state) "
"values (?, ?, ?, ?, ?, 'creating')"
)
SQL_MARK_READY = "update scratch_databases set state = 'ready' where name = ?"
SQL_MARK_DELETING = "update scratch_databases set state = 'deleting' where name = ?"
SQL_MARK_RENAMING = (
"update scratch_databases set state = 'renaming', rename_to = ? where name = ?"
)
SQL_FINISH_RENAME = (
"update scratch_databases set name = ?, state = 'ready', rename_to = null "
"where name = ?"
)
SQL_CANCEL_RENAME = (
"update scratch_databases set state = 'ready', rename_to = null where name = ?"
)
SQL_DELETE = "delete from scratch_databases where name = ?"
SQL_INSERT_ADOPTED = "insert into scratch_databases (name, created) values (?, ?)"
class ScratchDatabaseError(Exception):
"""A scratch database operation could not be carried out."""
class ScratchDirectoryInUse(StartupError):
"""Another Datasette instance holds the scratch directory's lock."""
class ScratchDatabaseExists(ScratchDatabaseError):
"""A database with that name already exists."""
class ScratchDatabaseNotFound(ScratchDatabaseError):
"""There is no scratch database with that name."""
class ScratchDatabaseDeleted(DatasetteClosedError):
"""Raised to callers using a scratch database that has been deleted or
renamed, including calls that were queued when that happened."""
@dataclasses.dataclass(frozen=True)
class ScratchDatabaseInfo:
name: str
path: str
created: float
last_used: float | None
size: int
owner: str | None
metadata: dict
attached: bool
class _Entry:
__slots__ = (
"created",
"db",
"dirty",
"last_used",
"metadata",
"name",
"owner",
"state",
)
def __init__(self, name, created, owner=None, metadata=None, state="ready"):
self.name = name
self.created = created
self.last_used = None
self.owner = owner
self.metadata = metadata or {}
self.state = state
self.db = None
self.dirty = False
class ScratchDatabase(Database):
"""A database in the scratch directory, created with
``datasette.create_scratch_database()``.
Behaves like any other mutable file database. Its catalog is kept current
by its own writes (schema watch mode ``owned``). Once it has been deleted
or renamed every call - including reads and writes that were already
queued - raises :class:`ScratchDatabaseDeleted`.
"""
is_scratch = True
def __init__(self, ds, path, *, entry, manager):
super().__init__(ds, path=str(path), is_mutable=True)
self._scratch_entry = entry
self._scratch_manager = manager
# None while usable, otherwise why it is not ("was deleted")
self._scratch_gone = None
# Datasette created this file or found it in the scratch directory:
# writes use mode=rw and never recreate it if it is deleted by hand
self._file_seen = True
@property
def owner(self):
return self._scratch_entry.owner
@property
def created(self):
return self._scratch_entry.created
@property
def last_used(self):
return self._scratch_entry.last_used
@property
def scratch_metadata(self):
return dict(self._scratch_entry.metadata)
@property
def size(self):
# 0 once deleted, rather than FileNotFoundError (repr() uses this)
try:
return super().size
except FileNotFoundError:
return 0
def _gone_error(self):
return ScratchDatabaseDeleted(
f"Scratch database {self.name!r} {self._scratch_gone}"
)
def _check_not_closed(self):
if self._scratch_gone is not None:
raise self._gone_error()
super()._check_not_closed()
def _guard(self, fn):
# Runs when the queued call is picked up by a thread: anything queued
# before a delete fails here instead of touching the file
def guarded(conn):
if self._scratch_gone is not None:
raise self._gone_error()
return fn(conn)
return guarded
def _translate(self, e):
if self._scratch_gone is not None and not isinstance(e, ScratchDatabaseDeleted):
return self._gone_error()
return None
async def _execute_fn(self, fn):
self._check_not_closed()
self._scratch_manager._touch(self)
try:
return await super()._execute_fn(self._guard(fn))
except DatasetteClosedError as e:
raise (self._translate(e) or e) from None
async def _execute_write_fn(self, fn, block=True, transaction=True, request=None):
self._check_not_closed()
self._scratch_manager._touch(self)
return await super()._execute_write_fn(
fn, block=block, transaction=transaction, request=request
)
async def execute_isolated_fn(self, fn):
self._check_not_closed()
self._scratch_manager._touch(self)
return await super().execute_isolated_fn(fn)
async def _send_to_write_thread(self, fn, **kwargs):
try:
return await super()._send_to_write_thread(self._guard(fn), **kwargs)
except DatasetteClosedError as e:
raise (self._translate(e) or e) from None
def _owner_id(actor):
if actor is None:
return None
if isinstance(actor, dict):
actor_id = actor.get("id")
return None if actor_id is None else str(actor_id)
return str(actor)
def _fsync_dir(directory):
try:
fd = os.open(directory, os.O_RDONLY)
except OSError:
return
try:
os.fsync(fd)
except OSError:
pass
finally:
os.close(fd)
def _looks_like_sqlite(path):
try:
with open(path, "rb") as fp:
header = fp.read(16)
except OSError:
return False
# An empty file is a valid, empty SQLite database
return header == b"" or header == SQLITE_HEADER
class ScratchDatabases:
"""Owns the scratch directory and its registry. One per Datasette,
available as ``datasette._scratch``; plugins use the Datasette methods
(``create_scratch_database()`` etc.)."""
def __init__(self, ds, directory=None, *, inferred=False, fallback_dir=None):
self.ds = ds
self.configured_dir = (
Path(directory).expanduser().resolve() if directory else None
)
# Configuration directory mode: config_dir/scratch, used only if
# Datasette created it (it holds the registry) or it is empty - a
# user's own "scratch" folder is never taken over
self._inferred_dir = (
Path(fallback_dir) if inferred and fallback_dir is not None else None
)
# Whether the registry already existed when the directory was opened
self._registry_existed = False
# The directory in use: configured_dir, or a temporary directory
# created by the first create_scratch_database() call
self.directory = None
self._entries = {}
# Protects _entries and _dirty. Never held across a blocking call
self._lock = threading.Lock()
# Serializes registry transactions (they run on worker threads)
self._registry_lock = threading.Lock()
self._registry_initialized = False
self._dirty = set()
self._last_flush = time.monotonic()
self._flush_scheduled = False
self._closed = False
self._finalizers = []
self.counters = {
"registry_transactions": 0,
"last_used_flushes": 0,
"adopted": 0,
"dropped_missing": 0,
"rolled_back_creates": 0,
"finished_deletes": 0,
"finished_renames": 0,
"stray_files_removed": 0,
}
@property
def persistent(self):
return self.configured_dir is not None
# ------------------------------------------------------------------
# startup
# ------------------------------------------------------------------
def load(self):
"""Called from Datasette.__init__. Attaches every scratch database
in the configured directory without opening any of them."""
if self.configured_dir is None and self._inferred_dir is not None:
directory = self._accept_inferred_dir(self._inferred_dir)
if directory is None:
return
try:
self._open_directory(directory)
except ScratchDirectoryInUse as e:
# Another Datasette (a server) is using the configuration
# directory's scratch databases: this one (datasette ...
# --get, a second process) runs without them rather than
# failing to start
logger.warning("%s; not attaching its scratch databases", e)
return
self.configured_dir = directory
elif self.configured_dir is None:
return
else:
self._open_directory(self.configured_dir)
for entry in self._reconcile():
self._attach(entry)
@staticmethod
def _accept_inferred_dir(path):
if not path.is_dir():
return None
if (path / REGISTRY_FILENAME).exists():
return path.resolve()
try:
empty = not os.listdir(path)
except OSError:
return None
if empty:
return path.resolve()
logger.warning(
"Not using %s for scratch databases: Datasette did not create it "
"and it is not empty. Use --scratch-dir to use it anyway",
path,
)
return None
def _open_directory(self, directory):
directory = Path(directory)
if any(c in str(directory) for c in UNSAFE_PATH_CHARS):
raise StartupError(
f"Scratch directory path {str(directory)!r} must not contain any of "
f"{' '.join(UNSAFE_PATH_CHARS)}"
)
try:
directory.mkdir(parents=True, exist_ok=True)
except OSError as e:
raise StartupError(f"Cannot create scratch directory {directory}: {e}")
self._acquire_directory_lock(directory)
self._registry_existed = (directory / REGISTRY_FILENAME).exists()
self.directory = str(directory)
def _acquire_directory_lock(self, directory):
try:
import fcntl
except ImportError: # Windows, Pyodide: no advisory lock
return
fd = os.open(os.path.join(directory, LOCK_FILENAME), os.O_RDWR | os.O_CREAT)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as e:
os.close(fd)
if e.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES):
raise ScratchDirectoryInUse(
f"Scratch directory {directory} is in use by another Datasette instance"
)
# flock() not supported here (some network file systems,
# Emscripten): carry on without the lock
logger.warning("Could not lock scratch directory %s: %s", directory, e)
return
# Released by close(), or when this object is garbage collected
self._finalizers.append(weakref.finalize(self, os.close, fd))
def _ensure_directory(self):
if self.directory is not None:
return
with self._lock:
if self.directory is not None:
return
path = tempfile.mkdtemp(prefix="datasette_scratch_")
# Removed by close(), or at exit. Holds only the path, so it
# does not keep this Datasette alive (unlike atexit.register of a
# bound method)
self._finalizers.append(
weakref.finalize(self, shutil.rmtree, path, ignore_errors=True)
)
self.directory = path
def _reconcile(self):
"""Bring the registry and the directory into agreement. Returns the
entries to attach. Runs once, at startup, before anything else can
use the directory (the directory lock is held)."""
directory = self.directory
files = set(os.listdir(directory))
on_disk = set()
for filename in files:
if not filename.endswith(SUFFIX) or filename.startswith("."):
continue
name = filename[: -len(SUFFIX)]
if NAME_RE.fullmatch(name):
on_disk.add(name)
else:
logger.warning(
"Ignoring %s in scratch directory %s: not a valid scratch database name",
filename,
directory,
)
statements = []
entries = {}
with self._registry_lock:
conn = self._registry_connection()
try:
rows = conn.execute(
"select name, created, last_used, owner, metadata, state, rename_to "
"from scratch_databases"
).fetchall()
finally:
conn.close()
for name, created, last_used, owner, metadata, state, rename_to in rows:
if state == "creating":
# The create never returned to its caller: roll it back
self._unlink_files(name)
on_disk.discard(name)
statements.append((SQL_DELETE, [name]))
self.counters["rolled_back_creates"] += 1
continue
if state == "deleting":
self._unlink_files(name)
on_disk.discard(name)
statements.append((SQL_DELETE, [name]))
self.counters["finished_deletes"] += 1
continue
final_name = name
if state == "renaming":
old_exists, new_exists = name in on_disk, rename_to in on_disk
if old_exists and new_exists and self._same_file(name, rename_to):
# Crashed between link() and unlink()
self._unlink_files(name)
old_exists = False
on_disk.discard(name)
if new_exists and not old_exists:
final_name = rename_to
statements.append((SQL_FINISH_RENAME, [rename_to, name]))
self.counters["finished_renames"] += 1
else:
statements.append((SQL_CANCEL_RENAME, [name]))
if final_name not in on_disk:
# Deleted by hand
statements.append((SQL_DELETE, [name]))
self.counters["dropped_missing"] += 1
continue
on_disk.discard(final_name)
entry = _Entry(
final_name, created, owner, json.loads(metadata) if metadata else {}
)
entry.last_used = last_used
entries[final_name] = entry
for name in sorted(on_disk):
path = self._path(name)
if not _looks_like_sqlite(path):
logger.warning("Ignoring %s: not a SQLite database", path)
continue
# Copied in by hand: adopt it, with no owner or metadata
created = os.stat(path).st_mtime
statements.append((SQL_INSERT_ADOPTED, [name, created]))
entries[name] = _Entry(name, created)
self.counters["adopted"] += 1
# -wal/-shm/-journal files whose database is gone would be replayed
# into the next database created under that name. Only in a
# directory Datasette has used before: never delete files the first
# time a directory is opened
files = set(os.listdir(directory))
for filename in files:
for suffix in SIDECAR_SUFFIXES:
if (
filename.endswith(SUFFIX + suffix)
and not filename.startswith(".")
and filename[: -len(suffix)] not in files
):
if not self._registry_existed:
logger.warning(
"Scratch directory %s contains %s but not its "
"database file; remove it before creating a "
"scratch database with that name",
directory,
filename,
)
continue
try:
os.unlink(os.path.join(directory, filename))
self.counters["stray_files_removed"] += 1
except OSError:
pass
if statements:
self._registry_write(statements, flush_dirty=False)
with self._lock:
self._entries.update(entries)
return list(entries.values())
def _same_file(self, a, b):
try:
return os.path.samefile(self._path(a), self._path(b))
except OSError:
return False
# ------------------------------------------------------------------
# registry
# ------------------------------------------------------------------
def _registry_connection(self):
# Short-lived, plain connection: the registry is never held open
conn = sqlite3.connect(
os.path.join(self.directory, REGISTRY_FILENAME),
isolation_level=None,
check_same_thread=False,
)
if not self._registry_initialized:
conn.execute(REGISTRY_SCHEMA)
self._registry_initialized = True
return conn
def _registry_write(self, statements, flush_dirty=True):
"""Run statements in one transaction, plus any pending last_used
updates. Blocking: call from a worker thread (or inline when there
are no threads)."""
with self._registry_lock:
dirty = self._take_dirty() if flush_dirty else []
if not statements and not dirty:
return
conn = self._registry_connection()
try:
conn.execute("BEGIN IMMEDIATE")
for sql, params in statements:
conn.execute(sql, params)
if dirty:
conn.executemany(
"update scratch_databases set last_used = ? where name = ?",
[(entry.last_used, entry.name) for entry in dirty],
)
conn.execute("COMMIT")
except BaseException:
if conn.in_transaction:
conn.execute("ROLLBACK")
self._restore_dirty(dirty)
raise
finally:
conn.close()
self.counters["registry_transactions"] += 1
if dirty:
self.counters["last_used_flushes"] += 1
def _take_dirty(self):
with self._lock:
dirty = [e for e in self._dirty if e.last_used is not None]
for entry in self._dirty:
entry.dirty = False
self._dirty.clear()
self._last_flush = time.monotonic()
return dirty
def _restore_dirty(self, dirty):
with self._lock:
for entry in dirty:
entry.dirty = True
self._dirty.add(entry)
def flush(self):
"""Write in-memory last_used times to the registry (blocking)."""
if self.directory is None:
return
self._registry_write([])
def _touch(self, db):
# On every read and write: a clock read and two attribute stores.
# The registry is written at most once per LAST_USED_FLUSH_INTERVAL_S
entry = db._scratch_entry
entry.last_used = time.time()
if not entry.dirty:
with self._lock:
entry.dirty = True
self._dirty.add(entry)
if (
not self._flush_scheduled
and time.monotonic() - self._last_flush >= LAST_USED_FLUSH_INTERVAL_S
):
self._schedule_flush()
def _schedule_flush(self):
with self._lock:
if self._flush_scheduled or self._closed:
return
self._flush_scheduled = True
executor = self.ds.executor
if executor is None:
# No threads (Pyodide): one small transaction, inline
self._flush_quietly()
return
try:
executor.submit(self._flush_quietly)
except RuntimeError:
# Executor already shut down: close() flushes
self._flush_scheduled = False
def _flush_quietly(self):
try:
self.flush()
except Exception:
logger.exception("Could not record scratch database last_used times")
finally:
self._last_flush = time.monotonic()
self._flush_scheduled = False
# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------
def _path(self, name):
return os.path.join(self.directory, name + SUFFIX)
def _unlink_files(self, name, main=True):
path = self._path(name)
for suffix in SIDECAR_SUFFIXES + (("",) if main else ()):
try:
os.unlink(path + suffix)
except FileNotFoundError:
pass
async def _run_blocking(self, fn, *args):
"""Blocking file and registry work: on a worker thread - not
ds.executor, whose threads may be the very ones a close() waits for
- or inline when there are no threads (num_sql_threads=0)."""
if self.ds.executor is None:
return fn(*args)
return await asyncio.to_thread(fn, *args)
def _check_open(self):
if self._closed or self.ds._closed:
raise DatasetteClosedError("Datasette has been closed")
@staticmethod
def validate_name(name):
if not isinstance(name, str) or not NAME_RE.fullmatch(name):
raise ValueError(
f"Invalid scratch database name {name!r}: use 1-64 letters, digits, "
"'_' or '-', starting with a letter or digit"
)
def _check_available_locked(self, name, ignore=None):
if name in self.ds.databases and name != ignore:
raise ScratchDatabaseExists(f"A database called {name!r} already exists")
lowered = name.lower()
for other in self._entries:
# Case-insensitive: Foo.db and foo.db are one file on macOS and
# Windows
if other.lower() == lowered and other != ignore:
raise ScratchDatabaseExists(
f"A scratch database called {other!r} already exists"
)
def _generate_name_locked(self):
while True:
name = f"scratch_{secrets.token_hex(4)}"
try:
self._check_available_locked(name)
except ScratchDatabaseExists:
continue
return name
def _attach(self, entry):
db = ScratchDatabase(self.ds, self._path(entry.name), entry=entry, manager=self)
entry.db = db
if entry.name in self.ds.databases:
logger.warning(
"Scratch database %r not attached: a database with that name already exists",
entry.name,
)
return db
self.ds.add_database(db, name=entry.name, schema_watch="owned")
return db
def _is_attached(self, entry):
return entry.db is not None and self.ds.databases.get(entry.name) is entry.db
def _get_entry_locked(self, name):
entry = self._entries.get(name)
if entry is None or entry.state != "ready":
raise ScratchDatabaseNotFound(f"No scratch database called {name!r}")
return entry
# ------------------------------------------------------------------
# create
# ------------------------------------------------------------------
async def create(self, name=None, *, actor=None, metadata=None):
self._check_open()
if name is not None:
self.validate_name(name)
owner = _owner_id(actor)
metadata = dict(metadata or {})
metadata_json = json.dumps(metadata) # fail early if not JSON
if self.directory is None:
await self._run_blocking(self._ensure_directory)
with self._lock:
if name is None:
name = self._generate_name_locked()
else:
self._check_available_locked(name)
# Reserves the name until the create finishes or fails
entry = _Entry(name, time.time(), owner, metadata, state="creating")
self._entries[name] = entry
try:
await self._run_blocking(self._create_files, entry, metadata_json)
except BaseException:
with self._lock:
self._entries.pop(name, None)
raise
with self._lock:
# add_database() may have taken the name while we were creating
# the file - it does not know about scratch reservations
taken = name in self.ds.databases
if not taken:
entry.state = "ready"
entry.last_used = entry.created
if taken:
await self._run_blocking(self._delete_files, entry)
with self._lock:
self._entries.pop(name, None)
raise ScratchDatabaseExists(f"A database called {name!r} already exists")
db = self._attach(entry)
# Read-your-writes for the catalog: catalog_databases has a row for
# the new database when this returns
await db._after_write()
return db
def _create_files(self, entry, metadata_json):
name = entry.name
path = self._path(name)
# Sidecars left by a crash would be replayed into the new file
self._unlink_files(name, main=False)
self._registry_write(
[
(
SQL_INSERT_CREATING,
[name, entry.created, entry.created, entry.owner, metadata_json],
)
]
)
created_file = False
try:
# O_EXCL: never adopt (and later delete) a file that is
# already there
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
os.close(fd)
created_file = True
conn = sqlite3.connect(path)
try:
if self.ds.nolock:
# nolock=1 readers cannot open WAL databases
conn.execute("VACUUM")
else:
# Readers never wait for writers (copying data in while
# the database is being browsed)
conn.execute("PRAGMA journal_mode=WAL").fetchone()
finally:
conn.close()
_fsync_dir(self.directory)
self._registry_write([(SQL_MARK_READY, [name])])
except BaseException:
if created_file:
self._unlink_files(name)
try:
self._registry_write(
[(SQL_DELETE, [name])],
flush_dirty=False,
)
except Exception:
# Left as 'creating': rolled back at the next startup
logger.exception("Could not roll back scratch database %r", name)
raise
# ------------------------------------------------------------------
# delete
# ------------------------------------------------------------------
async def delete(self, name):
self._check_open()
with self._lock:
entry = self._get_entry_locked(name)
entry.state = "deleting"
try:
await self._run_blocking(
self._registry_write,
[(SQL_MARK_DELETING, [name])],
)
except BaseException:
with self._lock:
entry.state = "ready"
raise
# From here on the delete always completes: if this process dies,
# the 'deleting' row makes the next startup finish it
await self._detach_and_close(entry, "was deleted")
await self._run_blocking(self._delete_files, entry)
await self._delete_catalog(name)
with self._lock:
if self._entries.get(name) is entry:
del self._entries[name]
async def _detach_and_close(self, entry, reason):
db = entry.db
db._scratch_gone = reason
state = db._watch_state
if state is not None:
# A catalog scan in flight (on any event loop): let it finish,
# its rows are deleted below
await self.ds._schema_watcher.wait_for_scan(state)
if self.ds.databases.get(entry.name) is db:
self.ds._detach_database(entry.name)
await self._run_blocking(self._close_and_wait, db)
@staticmethod
def _close_and_wait(db):
"""Close db and wait until nothing has its files open: close() waits
for queued and running reads and writes, but gives up on a write
thread after 10s; the files must not be removed while it runs."""
db.close()
with db._write_thread_lock:
threads = [db._write_thread, *db._retiring_write_threads]
for thread in threads:
if thread is not None and thread is not threading.current_thread():
thread.join()
# The SchemaWatcher's short-lived scan connections
db._wait_for_untracked_connections()
def _delete_files(self, entry):
self._unlink_files(entry.name)
_fsync_dir(self.directory)
self._registry_write([(SQL_DELETE, [entry.name])])
async def _delete_catalog(self, name):
if self.ds.internal_db_created:
# unregister() already spawned this as a task; awaiting it here
# means the rows are gone when delete returns (it is idempotent)
await self.ds._schema_watcher._delete_catalog([name])
# ------------------------------------------------------------------
# rename
# ------------------------------------------------------------------
async def rename(self, name, new_name):
self._check_open()
self.validate_name(new_name)
with self._lock:
entry = self._get_entry_locked(name)
if new_name == name:
return entry.db
# ignore=name allows a case-only rename (foo -> Foo)
self._check_available_locked(new_name, ignore=name)
entry.state = "renaming"
# Reserve the new name
target = _Entry(
new_name, entry.created, entry.owner, entry.metadata, "creating"
)
target.last_used = entry.last_used
self._entries[new_name] = target
try:
await self._run_blocking(
self._registry_write,
[(SQL_MARK_RENAMING, [new_name, name])],
)
except BaseException:
with self._lock:
entry.state = "ready"
if self._entries.get(new_name) is target:
del self._entries[new_name]
raise
await self._detach_and_close(entry, f"was renamed to {new_name!r}")
try:
await self._run_blocking(self._rename_files, name, new_name)
except BaseException:
# Nothing was renamed: re-attach under the old name
with self._lock:
if self._entries.get(new_name) is target:
del self._entries[new_name]
entry.state = "ready"
await self._run_blocking(
self._registry_write,
[(SQL_CANCEL_RENAME, [name])],
)
self._attach(entry)
raise
await self._delete_catalog(name)
with self._lock:
if self._entries.get(name) is entry:
del self._entries[name]
target.state = "ready"
self._entries[new_name] = target
db = self._attach(target)
await db._after_write()
return db
def _rename_files(self, name, new_name):
old, new = self._path(name), self._path(new_name)
# Datasette's last connection to close is often a read-only pooled
# one, which cannot checkpoint: fold the -wal back into the main
# file so the rename moves a single, complete file
conn = sqlite3.connect(old)
try:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchall()
finally:
conn.close()
for suffix in ("-wal", "-journal"):
try:
if os.path.getsize(old + suffix):
# After a clean close there is none: another process has
# this database open, or it needs crash recovery
raise ScratchDatabaseError(
f"Cannot rename scratch database {name!r}: {old + suffix} "
"is not empty (is another process using it?)"
)
except FileNotFoundError:
pass
self._unlink_files(name, main=False)
self._unlink_files(new_name, main=False)
if name.lower() == new_name.lower() and name != new_name:
# Case-only change: link() would fail on case-insensitive file
# systems
os.rename(old, new)
else:
try:
# link + unlink never replaces an existing file (rename does)
os.link(old, new)
except FileExistsError:
raise ScratchDatabaseExists(f"{new} already exists")
except OSError:
# No hard links on this file system
if os.path.exists(new):
raise ScratchDatabaseExists(f"{new} already exists")
os.rename(old, new)
else:
os.unlink(old)
_fsync_dir(self.directory)
self._registry_write([(SQL_FINISH_RENAME, [new_name, name])])
# ------------------------------------------------------------------
# list
# ------------------------------------------------------------------
async def list(self):
with self._lock:
entries = sorted(
(e for e in self._entries.values() if e.state == "ready"),
key=lambda e: e.name,
)
if not entries:
return []
sizes = await self._run_blocking(self._sizes, [e.name for e in entries])
return [
ScratchDatabaseInfo(
name=entry.name,
path=self._path(entry.name),
created=entry.created,
last_used=entry.last_used,
size=size,
owner=entry.owner,
metadata=dict(entry.metadata),
attached=self._is_attached(entry),
)
for entry, size in zip(entries, sizes)
]
def _sizes(self, names):
sizes = []
for name in names:
size = 0
for suffix in ("", "-wal"):
try:
size += os.stat(self._path(name) + suffix).st_size
except OSError:
pass
sizes.append(size)
return sizes
# ------------------------------------------------------------------
# shutdown
# ------------------------------------------------------------------
def close(self):
"""Called by Datasette.close() after every database has been closed
and the executor shut down: records last_used, releases the
directory lock, removes a temporary directory."""
if self._closed:
return
self._closed = True
with self._lock:
entries = list(self._entries.values())
for entry in entries:
# Detached ones (name clash, remove_database()) are not closed
# by Datasette.close()
if entry.db is not None and not entry.db._closed:
try:
entry.db.close()
except Exception:
logger.exception("Closing scratch database %r failed", entry.name)
if self.directory is not None and self.persistent:
try:
self.flush()
except Exception:
logger.exception("Could not record scratch database last_used times")
for finalizer in self._finalizers:
finalizer()