← backstore.py
1"""Shared state for the public game: leaderboards, tour tags, run tokens, share counts.
2 
3Two interchangeable backends behind one small interface:
4 
5- ``RedisStore`` when a Redis URL is configured (the Vercel deploy: the Vercel
6 Redis / Upstash integration injects ``REDIS_URL`` or a ``<name>_REDIS_URL``
7 variable). Every serverless instance sees the same data.
8- ``FileStore`` otherwise (the workspace app): one JSON file per key under
9 ``DATA_DIR/store``, guarded by a process-wide lock. Good for one server
10 process, which is how the workspace runs it.
11 
12Only the operations the app needs are exposed, so the two stay easy to keep in step.
13"""
14 
15import json
16import os
17import re
18import threading
19import time
20from pathlib import Path
21from typing import cast
22 
23import redis
24 
25_REDIS_ENV_NAMES = ("REDIS_URL", "KV_URL", "STORAGE_REDIS_URL", "REDIS_CONNECTION_STRING")
26# How often the file store clears out expired keys (run tickets, rate-limit counters,
27# old bug reports): Redis expires them itself, a folder of files does not.
28SWEEP_INTERVAL_S = 3600
29 
30 
31def redis_url_from_env() -> str | None:
32 for name in _REDIS_ENV_NAMES:
33 if os.environ.get(name):
34 return os.environ[name]
35 # Vercel's Redis integration prefixes the variable with the store's name.
36 for name, value in os.environ.items():
37 if name.endswith("_REDIS_URL") and value:
38 return value
39 return None
40 
41 
42class FileStore:
43 """JSON-file store for a single server process (the workspace app)."""
44 
45 def __init__(self, root: Path) -> None:
46 self.root = root
47 self._lock = threading.Lock()
48 self._swept = 0.0
49 
50 def _path(self, key: str) -> Path:
51 return self.root / (re.sub(r"[^A-Za-z0-9._-]", "_", key) + ".json")
52 
53 def _read(self, key: str):
54 p = self._path(key)
55 if not p.exists():
56 return None
57 try:
58 rec = json.loads(p.read_text())
59 except ValueError:
60 return None
61 exp = rec.get("exp")
62 if exp and exp < time.time():
63 p.unlink(missing_ok=True)
64 return None
65 return rec.get("v")
66 
67 def _sweep(self) -> None:
68 """Delete every expired key, at most once per ``SWEEP_INTERVAL_S``. A key that is
69 never read again (a run ticket nobody finished, a visitor's counter) would
70 otherwise stay on disk forever."""
71 now = time.time()
72 if now - self._swept < SWEEP_INTERVAL_S:
73 return
74 self._swept = now
75 for p in self.root.glob("*.json"):
76 try:
77 exp = json.loads(p.read_text()).get("exp")
78 except (OSError, ValueError, AttributeError):
79 continue
80 if exp and exp < now:
81 p.unlink(missing_ok=True)
82 
83 def _write(self, key: str, value, ttl: int | None = None) -> None:
84 self.root.mkdir(parents=True, exist_ok=True)
85 self._sweep()
86 p = self._path(key)
87 tmp = p.with_suffix(".tmp")
88 tmp.write_text(json.dumps({"v": value, "exp": time.time() + ttl if ttl else None}))
89 tmp.replace(p)
90 
91 def get(self, key: str):
92 with self._lock:
93 return self._read(key)
94 
95 def set(self, key: str, value, ttl: int | None = None) -> None:
96 with self._lock:
97 self._write(key, value, ttl)
98 
99 def set_if_absent(self, key: str, value, ttl: int | None = None) -> bool:
100 with self._lock:
101 if self._read(key) is not None:
102 return False
103 self._write(key, value, ttl)
104 return True
105 
106 def delete(self, key: str) -> None:
107 with self._lock:
108 self._path(key).unlink(missing_ok=True)
109 
110 def incr(self, key: str, ttl: int | None = None) -> int:
111 with self._lock:
112 n = int(self._read(key) or 0) + 1
113 self._write(key, n, ttl)
114 return n
115 
116 def hincr(self, key: str, field: str, by: int = 1) -> None:
117 with self._lock:
118 h = self._read(key) or {}
119 h[field] = int(h.get(field, 0)) + by
120 self._write(key, h)
121 
122 def hgetall(self, key: str) -> dict:
123 with self._lock:
124 return {k: int(v) for k, v in (self._read(key) or {}).items()}
125 
126 def board_add(self, key: str, entry: dict, score: int, keep: int = 500) -> int:
127 """Add an entry to a leaderboard; return its 1-based rank."""
128 with self._lock:
129 rows = self._read(key) or []
130 rows.append({"score": score, "entry": entry})
131 rows.sort(key=lambda r: -r["score"])
132 rank = next(i for i, r in enumerate(rows) if r["entry"] is entry) + 1
133 self._write(key, rows[:keep])
134 return rank
135 
136 def board_top(self, key: str, n: int = 50) -> list[dict]:
137 with self._lock:
138 return [r["entry"] for r in (self._read(key) or [])[:n]]
139 
140 def board_count(self, key: str) -> int:
141 with self._lock:
142 return len(self._read(key) or [])
143 
144 def zincr(self, key: str, member: str, by: int = 1) -> None:
145 self.hincr(key, member, by)
146 
147 def list_push(self, key: str, value: dict, keep: int) -> list[dict]:
148 """Put a record first in a list, keep the newest ``keep``, return the list."""
149 with self._lock:
150 rows = [value] + (self._read(key) or [])
151 self._write(key, rows[:keep])
152 return rows[:keep]
153 
154 def list_all(self, key: str) -> list[dict]:
155 with self._lock:
156 return list(self._read(key) or [])
157 
158 def ztop(self, key: str, n: int = 20) -> list[tuple[str, int]]:
159 h = self.hgetall(key)
160 return sorted(h.items(), key=lambda kv: -kv[1])[:n]
161 
162 
163class RedisStore:
164 """Redis-backed store shared by every serverless instance (the Vercel deploy)."""
165 
166 def __init__(self, url: str) -> None:
167 self.r = redis.Redis.from_url(url, socket_timeout=8, socket_connect_timeout=8, decode_responses=True)
168 
169 def get(self, key: str):
170 raw = cast("str | None", self.r.get(key))
171 return json.loads(raw) if raw is not None else None
172 
173 def set(self, key: str, value, ttl: int | None = None) -> None:
174 self.r.set(key, json.dumps(value), ex=ttl)
175 
176 def set_if_absent(self, key: str, value, ttl: int | None = None) -> bool:
177 return bool(self.r.set(key, json.dumps(value), ex=ttl, nx=True))
178 
179 def delete(self, key: str) -> None:
180 self.r.delete(key)
181 
182 def incr(self, key: str, ttl: int | None = None) -> int:
183 n = self.r.incr(key)
184 if n == 1 and ttl:
185 self.r.expire(key, ttl)
186 return int(n)
187 
188 def hincr(self, key: str, field: str, by: int = 1) -> None:
189 self.r.hincrby(key, field, by)
190 
191 def hgetall(self, key: str) -> dict:
192 return {k: int(v) for k, v in cast("dict[str, str]", self.r.hgetall(key)).items()}
193 
194 def board_add(self, key: str, entry: dict, score: int, keep: int = 500) -> int:
195 # The member carries a unique id so two identical runs don't collapse.
196 member = json.dumps(entry, sort_keys=True)
197 self.r.zadd(key, {member: score})
198 self.r.zremrangebyrank(key, 0, -(keep + 1))
199 rank = cast("int | None", self.r.zrevrank(key, member))
200 return (rank if rank is not None else keep) + 1
201 
202 def board_top(self, key: str, n: int = 50) -> list[dict]:
203 return [json.loads(m) for m in cast("list[str]", self.r.zrevrange(key, 0, n - 1))]
204 
205 def board_count(self, key: str) -> int:
206 return self.r.zcard(key)
207 
208 def zincr(self, key: str, member: str, by: int = 1) -> None:
209 self.r.zincrby(key, by, member)
210 
211 def list_push(self, key: str, value: dict, keep: int) -> list[dict]:
212 # LPUSH of a JSON string: the same shape the original site's Upstash client wrote.
213 self.r.lpush(key, json.dumps(value))
214 self.r.ltrim(key, 0, keep - 1)
215 return self.list_all(key)
216 
217 def list_all(self, key: str) -> list[dict]:
218 out = []
219 for raw in cast("list[str]", self.r.lrange(key, 0, -1)):
220 try:
221 out.append(json.loads(raw))
222 except ValueError:
223 continue
224 return out
225 
226 def ztop(self, key: str, n: int = 20) -> list[tuple[str, int]]:
227 rows = cast("list[tuple[str, float]]", self.r.zrevrange(key, 0, n - 1, withscores=True))
228 return [(m, int(s)) for m, s in rows]
229 
230 
231def make_store(data_dir: Path):
232 url = redis_url_from_env()
233 if url:
234 return RedisStore(url)
235 return FileStore(data_dir / "store")
236