Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/codex_usage_tracker/kernel/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,15 +354,23 @@ def _initialize_content_database(path: Path) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.with_name(f".{target.name}.building-{os.getpid()}")
try:
with sqlite3.connect(staging) as connection:
connection = sqlite3.connect(staging)
try:
connection.execute(f"PRAGMA application_id = {CONTENT_APPLICATION_ID}")
connection.execute(f"PRAGMA user_version = {CONTENT_SCHEMA_VERSION}")
connection.execute("PRAGMA foreign_keys = ON")
connection.executescript(_SCHEMA_SQL)
connection.commit()
finally:
connection.close()
staging.chmod(0o600)
os.replace(staging, target)
with sqlite3.connect(target) as connection:
connection = sqlite3.connect(target)
try:
connection.execute("PRAGMA journal_mode = WAL")
connection.commit()
finally:
connection.close()
target.chmod(0o600)
finally:
staging.unlink(missing_ok=True)
Expand Down
11 changes: 9 additions & 2 deletions src/codex_usage_tracker/kernel/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@ def initialize_analytical_database(
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.with_name(f".{target.name}.building-{uuid.uuid4().hex}")
try:
with sqlite3.connect(staging) as connection:
connection = sqlite3.connect(staging)
try:
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA journal_mode = DELETE")
create_schema(connection)
connection.commit()
finally:
connection.close()
_owner_only(staging)
failures = validate_analytical_database(staging)
if failures:
Expand Down Expand Up @@ -148,7 +152,8 @@ def validate_analytical_database(path: Path) -> list[str]:
return [f"analytical database does not exist: {path.name}"]
failures: list[str] = []
try:
with sqlite3.connect(path) as connection:
connection = sqlite3.connect(path)
try:
connection.execute("PRAGMA foreign_keys = ON")
if connection.execute("PRAGMA user_version").fetchone()[0] != SCHEMA_VERSION:
failures.append(
Expand Down Expand Up @@ -185,6 +190,8 @@ def validate_analytical_database(path: Path) -> list[str]:
failures.append(f"analytical quick_check failed: {integrity}")
if connection.execute("PRAGMA foreign_key_check").fetchone() is not None:
failures.append("analytical foreign-key check failed")
finally:
connection.close()
except sqlite3.DatabaseError as exc:
failures.append(f"analytical database is unreadable: {exc}")
return failures
Expand Down
6 changes: 5 additions & 1 deletion src/codex_usage_tracker/kernel/operational.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,11 +192,15 @@ def initialize_operational_database(path: Path) -> Path:
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.with_name(f".{target.name}.building-{uuid.uuid4().hex}")
try:
with sqlite3.connect(staging) as connection:
connection = sqlite3.connect(staging)
try:
connection.execute("PRAGMA foreign_keys = ON")
connection.execute(f"PRAGMA user_version = {OPERATIONAL_SCHEMA_VERSION}")
connection.executescript(_OPERATIONAL_SQL)
connection.execute("INSERT INTO cutover_control(singleton, state) VALUES (1, 'absent')")
connection.commit()
finally:
connection.close()
staging.chmod(0o600)
_validate_operational(staging)
os.replace(staging, target)
Expand Down
51 changes: 50 additions & 1 deletion tests/kernel/test_database_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@

import os
import sqlite3
from collections.abc import Callable
from contextlib import contextmanager
from pathlib import Path
from typing import Any

import pytest

from codex_usage_tracker.kernel import database
from codex_usage_tracker.kernel import content, database, operational
from codex_usage_tracker.kernel.database import (
initialize_analytical_database,
open_read_snapshot,
Expand Down Expand Up @@ -132,6 +133,54 @@ def fail_before_replace(_source: Path, _target: Path) -> None:
assert validate_analytical_database(path) == []


@pytest.mark.parametrize(
("initializer", "name"),
(
(initialize_analytical_database, "analytical.sqlite3"),
(operational.initialize_operational_database, "operational.sqlite3"),
(content._initialize_content_database, "content.sqlite3"),
),
)
def test_database_initializers_close_connections_before_atomic_replace(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
initializer: Callable[[Path], Any],
name: str,
) -> None:
real_connect = sqlite3.connect
real_replace = os.replace
connections: list[Any] = []

class TrackingConnection:
def __init__(self, connection: sqlite3.Connection) -> None:
self._connection = connection
self.closed = False

def close(self) -> None:
self.closed = True
self._connection.close()

def __getattr__(self, attribute: str) -> Any:
return getattr(self._connection, attribute)

def connect(*args: Any, **kwargs: Any) -> TrackingConnection:
connection = TrackingConnection(real_connect(*args, **kwargs))
connections.append(connection)
return connection

def replace(source: Path, target: Path) -> None:
assert connections
assert all(connection.closed for connection in connections)
real_replace(source, target)

monkeypatch.setattr(sqlite3, "connect", connect)
monkeypatch.setattr(os, "replace", replace)

initializer(tmp_path / name)

assert all(connection.closed for connection in connections)


def test_kernel_creation_never_opens_legacy_database(tmp_path: Path) -> None:
legacy = tmp_path / "codex-usage.sqlite3"
legacy.write_bytes(b"legacy-schema-39-sentinel")
Expand Down