Files
Stephen M AbbottandClaude Fable 5 9e995a4dcc Tests + CI, rate limiting, karma v1, owner tools, instant realtime, deploy guide
Foundation:
- 52-test pytest suite: API flows, queue protocol, model matching, recovery
  paths, karma ordering, node revocation, plus integration tests that run a
  real uvicorn process for the OpenAI-compatible round-trip (incl. SSE
  streaming) and drive the actual node_connector.py script end to end
- GitHub Actions CI on ubuntu 3.11/3.12 + windows 3.12; front-end syntax
  check and byte-compile included; README badge
- The suite immediately caught a real bug: empty `messages` on
  /v1/chat/completions slipped past the prompt guard and hung the request
  for the full 180s timeout instead of returning 400 - fixed

Security & fairness:
- Sliding-window rate limits on /api/register and /api/recover (recovery
  codes were brute-forceable at wire speed before); X-Forwarded-For aware,
  SPARECYCLES_RATELIMIT=off escape hatch
- Karma v1: projects whose owners have completed donations are claimed
  before non-donors at equal tier - boolean, never starves newcomers.
  Donor badge on marketplace cards.

Operability:
- Owner tools: PATCH /api/projects/<slug> (edit everything), rotate
  inference key, cancel queued jobs from the feed
- Node revocation (DELETE /api/nodes/<id>) - token dies instantly, donation
  attribution is preserved via soft delete
- /api/health with real DB round-trip for uptime monitors
- DEPLOY.md: droplet + systemd + Caddy/nginx (long-poll timeouts), backups,
  tunnel alternative, connector-as-a-service
- MIT LICENSE (swap if you prefer another)

Latency & robustness:
- Realtime pipeline now wakes on asyncio.Events when jobs arrive/finish
  instead of 0.4-1s poll ticks; DB remains source of truth with poll
  fallback - job pickup and answer delivery are effectively instant
- Connector: clean SIGTERM shutdown (systemd/docker), UTF-8 stdout on
  Windows pipes, model select keeps unlisted current values when editing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 12:40:55 -07:00

169 lines
5.0 KiB
Python

"""SQLite layer for SpareCycles. One file, WAL mode, short-lived connections."""
import hashlib
import os
import secrets
import sqlite3
import time
from contextlib import contextmanager
DATA_DIR = os.environ.get(
"SPARECYCLES_DATA",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data"),
)
DB_PATH = os.path.join(DATA_DIR, "sparecycles.db")
SCHEMA = """
CREATE TABLE IF NOT EXISTS accounts(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
key_hash TEXT UNIQUE NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS projects(
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES accounts(id),
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
tagline TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
repo_url TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL,
fallback_model TEXT NOT NULL DEFAULT '',
temperature REAL,
max_tokens INTEGER,
inference_key_hash TEXT UNIQUE NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS nodes(
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
name TEXT NOT NULL,
token_hash TEXT UNIQUE NOT NULL,
runners TEXT NOT NULL DEFAULT '[]',
models TEXT NOT NULL DEFAULT '[]',
last_seen REAL,
jobs_done INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL,
revoked_at REAL
);
CREATE TABLE IF NOT EXISTS supports(
account_id INTEGER NOT NULL REFERENCES accounts(id),
project_id INTEGER NOT NULL REFERENCES projects(id),
created_at REAL NOT NULL,
PRIMARY KEY(account_id, project_id)
);
CREATE TABLE IF NOT EXISTS jobs(
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
kind TEXT NOT NULL DEFAULT 'batch',
title TEXT NOT NULL DEFAULT '',
prompt TEXT NOT NULL,
model TEXT NOT NULL,
fallback_model TEXT NOT NULL DEFAULT '',
temperature REAL,
max_tokens INTEGER,
status TEXT NOT NULL DEFAULT 'queued',
attempts INTEGER NOT NULL DEFAULT 0,
node_id INTEGER REFERENCES nodes(id),
use_model TEXT,
output TEXT,
error TEXT,
runner TEXT,
model_used TEXT,
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL,
claimed_at REAL,
deadline REAL,
finished_at REAL
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status, project_id);
CREATE TABLE IF NOT EXISTS pair_codes(
code TEXT PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id),
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS recovery_codes(
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
code_hash TEXT UNIQUE NOT NULL,
created_at REAL NOT NULL,
used_at REAL
);
"""
MAX_ATTEMPTS = 3
LEASE_REALTIME = 240 # seconds a node has to finish a realtime job
LEASE_BATCH = 900
@contextmanager
def connect():
"""Commit-and-close connection context (plain `with sqlite3.connect(...)`
commits but never closes, leaking handles in a long-running server)."""
conn = sqlite3.connect(DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA foreign_keys=ON")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init() -> None:
os.makedirs(DATA_DIR, exist_ok=True)
with connect() as conn:
conn.executescript(SCHEMA)
# Lightweight migrations for databases created before a column existed.
for stmt in ("ALTER TABLE nodes ADD COLUMN revoked_at REAL",):
try:
conn.execute(stmt)
except sqlite3.OperationalError:
pass # column already there
def new_key(prefix: str) -> tuple[str, str]:
"""Return (plaintext, sha256hash). Plaintext is shown once, never stored."""
plain = f"{prefix}_{secrets.token_urlsafe(24)}"
return plain, hash_key(plain)
def hash_key(plain: str) -> str:
return hashlib.sha256(plain.encode()).hexdigest()
def now() -> float:
return time.time()
def requeue_expired(conn: sqlite3.Connection) -> None:
"""Return timed-out running jobs to the queue; fail them after MAX_ATTEMPTS."""
t = now()
conn.execute(
"UPDATE jobs SET status='queued', node_id=NULL, use_model=NULL, "
"claimed_at=NULL, deadline=NULL "
"WHERE status='running' AND deadline < ? AND attempts < ?",
(t, MAX_ATTEMPTS),
)
conn.execute(
"UPDATE jobs SET status='failed', finished_at=?, "
"error='no node completed this job in time' "
"WHERE status='running' AND deadline < ? AND attempts >= ?",
(t, t, MAX_ATTEMPTS),
)
# Realtime jobs nobody ever claimed go stale after 10 minutes.
conn.execute(
"UPDATE jobs SET status='expired', finished_at=?, "
"error='no node picked this up in time' "
"WHERE status='queued' AND kind='realtime' AND created_at < ?",
(t, t - 600),
)