mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
139 lines
5.2 KiB
Python
139 lines
5.2 KiB
Python
"""Bounded SQLite spool for offline operational submissions.
|
|
|
|
When the desktop is offline (laptop closed, no internet, cloud unreachable),
|
|
the service-sync layer can't reach `api.openswarm.com`. Rather than drop
|
|
data on the floor, we spool submissions to a small SQLite file and replay
|
|
them on the next online tick. The spool is bounded; when full, the oldest
|
|
entries are dropped; so it can never balloon to a problem.
|
|
|
|
Single file, single table, single thread guarded by a sqlite3 connection's
|
|
implicit lock. No concurrency model beyond "don't write from two processes
|
|
at once."
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
from contextlib import contextmanager
|
|
from typing import Iterator, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Cap the spool at 50 MB on disk. SQLite's overhead means the actual ceiling on retained payloads is somewhat smaller, which is fine; this is a best-effort cushion, not a guaranteed retention window.
|
|
P_MAX_BYTES = 50 * 1024 * 1024
|
|
|
|
# Trim 25% when we cross the cap so we don't trim on every insert.
|
|
P_TRIM_TARGET_FRACTION = 0.75
|
|
|
|
p_lock = threading.Lock()
|
|
|
|
|
|
@contextmanager
|
|
def conn(spool_path: str) -> Iterator[sqlite3.Connection]:
|
|
"""Open a connection that auto-commits and ensures the table exists.
|
|
Caller holds `p_lock` for the duration of the context."""
|
|
os.makedirs(os.path.dirname(spool_path), exist_ok=True)
|
|
c = sqlite3.connect(spool_path, isolation_level=None, timeout=5.0)
|
|
try:
|
|
c.execute(
|
|
"CREATE TABLE IF NOT EXISTS spool ("
|
|
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
" kind TEXT NOT NULL,"
|
|
" payload TEXT NOT NULL,"
|
|
" created_at REAL NOT NULL"
|
|
")"
|
|
)
|
|
yield c
|
|
finally:
|
|
c.close()
|
|
|
|
|
|
def enqueue(spool_path: str, kind: str, payload: dict, *, now: float) -> None:
|
|
"""Append a submission to the spool. Drops the oldest if the spool is
|
|
over the byte cap."""
|
|
body = json.dumps(payload, separators=(",", ":"), default=str)
|
|
with p_lock, conn(spool_path) as c:
|
|
c.execute(
|
|
"INSERT INTO spool (kind, payload, created_at) VALUES (?, ?, ?)",
|
|
(kind, body, now),
|
|
)
|
|
# Cheap size check; only run trim when stat says we're over.
|
|
try:
|
|
size = os.path.getsize(spool_path)
|
|
except OSError:
|
|
size = 0
|
|
if size > P_MAX_BYTES:
|
|
target = int(P_MAX_BYTES * P_TRIM_TARGET_FRACTION)
|
|
# Delete oldest rows until we're back under target. Use a reasonable batch size so we don't block forever.
|
|
dropped = 0
|
|
for _ in range(64):
|
|
row = c.execute("SELECT id FROM spool ORDER BY id ASC LIMIT 1").fetchone()
|
|
if not row:
|
|
break
|
|
c.execute("DELETE FROM spool WHERE id = ?", (row[0],))
|
|
dropped += 1
|
|
try:
|
|
new_size = os.path.getsize(spool_path)
|
|
except OSError:
|
|
new_size = 0
|
|
if new_size <= target:
|
|
break
|
|
if dropped:
|
|
logger.warning("Spool over %d MB cap; dropped %d oldest entries", P_MAX_BYTES // (1024 * 1024), dropped)
|
|
# VACUUM is expensive; only run if we still appear oversized after trimming, otherwise free pages get reused on next insert.
|
|
try:
|
|
if os.path.getsize(spool_path) > P_MAX_BYTES:
|
|
c.execute("VACUUM")
|
|
except (OSError, sqlite3.DatabaseError):
|
|
pass
|
|
|
|
|
|
def drain(spool_path: str, batch_size: int = 50) -> list[tuple[int, str, dict]]:
|
|
"""Read up to `batch_size` oldest entries. Returns (id, kind, payload)
|
|
triples; caller is responsible for calling `acknowledge(ids)` once the
|
|
cloud accepts them."""
|
|
if not os.path.exists(spool_path):
|
|
return []
|
|
with p_lock, conn(spool_path) as c:
|
|
rows = c.execute(
|
|
"SELECT id, kind, payload FROM spool ORDER BY id ASC LIMIT ?",
|
|
(batch_size,),
|
|
).fetchall()
|
|
out: list[tuple[int, str, dict]] = []
|
|
for rid, kind, body in rows:
|
|
try:
|
|
out.append((rid, kind, json.loads(body)))
|
|
except json.JSONDecodeError:
|
|
# Corrupt row; discard so it doesn't block draining behind it.
|
|
with p_lock, conn(spool_path) as c:
|
|
c.execute("DELETE FROM spool WHERE id = ?", (rid,))
|
|
logger.warning("Dropped corrupt spool row id=%s", rid)
|
|
return out
|
|
|
|
|
|
def acknowledge(spool_path: str, ids: list[int]) -> None:
|
|
"""Remove rows the cloud has accepted."""
|
|
if not ids:
|
|
return
|
|
with p_lock, conn(spool_path) as c:
|
|
c.executemany("DELETE FROM spool WHERE id = ?", [(i,) for i in ids])
|
|
|
|
|
|
def count(spool_path: str) -> int:
|
|
"""Return the number of pending entries. Used for tests + debug UI."""
|
|
if not os.path.exists(spool_path):
|
|
return 0
|
|
with p_lock, conn(spool_path) as c:
|
|
row = c.execute("SELECT COUNT(*) FROM spool").fetchone()
|
|
return int(row[0]) if row else 0
|
|
|
|
|
|
def clear(spool_path: str) -> None:
|
|
"""Delete all pending entries. Tests + manual reset only."""
|
|
with p_lock, conn(spool_path) as c:
|
|
c.execute("DELETE FROM spool")
|