Skip to content

Commit aaefddc

Browse files
feat: add transactional quota reservations
1 parent 127a4bc commit aaefddc

2 files changed

Lines changed: 234 additions & 0 deletions

File tree

scripts/bazi_engine/runtime_store.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import hashlib
33
import json
44
import sqlite3
5+
import uuid
56
from dataclasses import dataclass
7+
from datetime import UTC, datetime, timedelta
68
from pathlib import Path
79

810

@@ -25,6 +27,12 @@ def total_imported(self) -> int:
2527
))
2628

2729

30+
@dataclass(frozen=True)
31+
class QuotaReservation:
32+
reservation_id: str
33+
remaining: int
34+
35+
2836
class RuntimeStore:
2937
"""Owns durable runtime state without touching calibration data."""
3038

@@ -60,6 +68,22 @@ def initialize(self) -> None:
6068
usage_date TEXT NOT NULL,
6169
count INTEGER NOT NULL CHECK (count >= 0)
6270
);
71+
CREATE TABLE IF NOT EXISTS quota_reservations (
72+
reservation_id TEXT PRIMARY KEY,
73+
kind TEXT NOT NULL CHECK (kind IN ('activation', 'free')),
74+
code TEXT,
75+
client_hash TEXT,
76+
usage_date TEXT,
77+
state TEXT NOT NULL CHECK (state IN ('reserved', 'settled', 'released')),
78+
expires_at TEXT NOT NULL,
79+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
80+
CHECK (
81+
(kind = 'activation' AND code IS NOT NULL AND client_hash IS NULL)
82+
OR (kind = 'free' AND client_hash IS NOT NULL AND code IS NULL)
83+
)
84+
);
85+
CREATE INDEX IF NOT EXISTS quota_reservations_expiry
86+
ON quota_reservations(state, expires_at);
6387
CREATE TABLE IF NOT EXISTS fusion_generations (
6488
generation_id TEXT PRIMARY KEY,
6589
timestamp TEXT NOT NULL,
@@ -107,6 +131,184 @@ def initialize(self) -> None:
107131
"""
108132
)
109133

134+
def seed_activation_codes(self, codes: dict) -> int:
135+
"""Insert environment-provided codes once without resetting consumed balances."""
136+
self.initialize()
137+
inserted = 0
138+
with self.connect() as connection:
139+
connection.execute("BEGIN IMMEDIATE")
140+
for code, entry in codes.items():
141+
if not isinstance(entry, dict):
142+
continue
143+
remaining = entry.get("剩余", 0)
144+
if not isinstance(remaining, int) or remaining < 0:
145+
continue
146+
result = connection.execute(
147+
"INSERT OR IGNORE INTO activation_codes(code, remaining, note) VALUES (?, ?, ?)",
148+
(str(code).upper(), remaining, str(entry.get("备注", ""))),
149+
)
150+
inserted += result.rowcount
151+
return inserted
152+
153+
def activation_remaining(self, code: str) -> int | None:
154+
self.initialize()
155+
with self.connect() as connection:
156+
row = connection.execute(
157+
"SELECT remaining FROM activation_codes WHERE code = ?", (code.strip().upper(),)
158+
).fetchone()
159+
return int(row["remaining"]) if row else None
160+
161+
def free_remaining(self, client_hash: str, usage_date: str, limit: int) -> int:
162+
self.initialize()
163+
with self.connect() as connection:
164+
row = connection.execute(
165+
"SELECT usage_date, count FROM free_usage WHERE client_hash = ?", (client_hash,)
166+
).fetchone()
167+
used = int(row["count"]) if row and row["usage_date"] == usage_date else 0
168+
return max(0, limit - used)
169+
170+
def reserve_activation(self, code: str, *, expiry_seconds: int = 300) -> QuotaReservation | None:
171+
self.initialize()
172+
normalized_code = code.strip().upper()
173+
now = self._now()
174+
with self.connect() as connection:
175+
connection.execute("BEGIN IMMEDIATE")
176+
self._release_expired(connection, now)
177+
updated = connection.execute(
178+
"""
179+
UPDATE activation_codes
180+
SET remaining = remaining - 1, updated_at = CURRENT_TIMESTAMP
181+
WHERE code = ? AND remaining > 0
182+
""",
183+
(normalized_code,),
184+
).rowcount
185+
if not updated:
186+
return None
187+
remaining = connection.execute(
188+
"SELECT remaining FROM activation_codes WHERE code = ?", (normalized_code,)
189+
).fetchone()["remaining"]
190+
reservation_id = uuid.uuid4().hex
191+
connection.execute(
192+
"""
193+
INSERT INTO quota_reservations(reservation_id, kind, code, state, expires_at)
194+
VALUES (?, 'activation', ?, 'reserved', ?)
195+
""",
196+
(reservation_id, normalized_code, self._expires_at(now, expiry_seconds)),
197+
)
198+
return QuotaReservation(reservation_id, int(remaining))
199+
200+
def reserve_free(
201+
self,
202+
client_hash: str,
203+
usage_date: str,
204+
limit: int,
205+
*,
206+
expiry_seconds: int = 300,
207+
) -> QuotaReservation | None:
208+
self.initialize()
209+
now = self._now()
210+
with self.connect() as connection:
211+
connection.execute("BEGIN IMMEDIATE")
212+
self._release_expired(connection, now)
213+
row = connection.execute(
214+
"SELECT usage_date, count FROM free_usage WHERE client_hash = ?", (client_hash,)
215+
).fetchone()
216+
used = int(row["count"]) if row and row["usage_date"] == usage_date else 0
217+
if used >= limit:
218+
return None
219+
next_count = used + 1
220+
connection.execute(
221+
"""
222+
INSERT INTO free_usage(client_hash, usage_date, count) VALUES (?, ?, ?)
223+
ON CONFLICT(client_hash) DO UPDATE SET usage_date = excluded.usage_date, count = excluded.count
224+
""",
225+
(client_hash, usage_date, next_count),
226+
)
227+
reservation_id = uuid.uuid4().hex
228+
connection.execute(
229+
"""
230+
INSERT INTO quota_reservations(
231+
reservation_id, kind, client_hash, usage_date, state, expires_at
232+
) VALUES (?, 'free', ?, ?, 'reserved', ?)
233+
""",
234+
(reservation_id, client_hash, usage_date, self._expires_at(now, expiry_seconds)),
235+
)
236+
return QuotaReservation(reservation_id, limit - next_count)
237+
238+
def settle_reservation(self, reservation_id: str) -> bool:
239+
"""Mark a delivered response as consumed. Repeated settlement is harmless."""
240+
return self._finish_reservation(reservation_id, consume=True)
241+
242+
def release_reservation(self, reservation_id: str) -> bool:
243+
"""Return a reservation that failed before delivering a response."""
244+
return self._finish_reservation(reservation_id, consume=False)
245+
246+
def _finish_reservation(self, reservation_id: str, *, consume: bool) -> bool:
247+
self.initialize()
248+
with self.connect() as connection:
249+
connection.execute("BEGIN IMMEDIATE")
250+
row = connection.execute(
251+
"SELECT * FROM quota_reservations WHERE reservation_id = ?", (reservation_id,)
252+
).fetchone()
253+
if row is None or row["state"] != "reserved":
254+
return False
255+
if consume:
256+
connection.execute(
257+
"UPDATE quota_reservations SET state = 'settled' WHERE reservation_id = ?",
258+
(reservation_id,),
259+
)
260+
return True
261+
self._restore_reservation(connection, row)
262+
connection.execute(
263+
"UPDATE quota_reservations SET state = 'released' WHERE reservation_id = ?",
264+
(reservation_id,),
265+
)
266+
return True
267+
268+
@staticmethod
269+
def _now() -> datetime:
270+
return datetime.now(UTC)
271+
272+
@staticmethod
273+
def _expires_at(now: datetime, expiry_seconds: int) -> str:
274+
return (now + timedelta(seconds=max(1, expiry_seconds))).isoformat()
275+
276+
def _release_expired(self, connection: sqlite3.Connection, now: datetime) -> None:
277+
rows = connection.execute(
278+
"""
279+
SELECT * FROM quota_reservations
280+
WHERE state = 'reserved' AND expires_at <= ?
281+
""",
282+
(now.isoformat(),),
283+
).fetchall()
284+
for row in rows:
285+
self._restore_reservation(connection, row)
286+
connection.execute(
287+
"UPDATE quota_reservations SET state = 'released' WHERE reservation_id = ?",
288+
(row["reservation_id"],),
289+
)
290+
291+
@staticmethod
292+
def _restore_reservation(connection: sqlite3.Connection, row: sqlite3.Row) -> None:
293+
if row["kind"] == "activation":
294+
connection.execute(
295+
"""
296+
UPDATE activation_codes
297+
SET remaining = remaining + 1, updated_at = CURRENT_TIMESTAMP
298+
WHERE code = ?
299+
""",
300+
(row["code"],),
301+
)
302+
else:
303+
connection.execute(
304+
"""
305+
UPDATE free_usage
306+
SET count = CASE WHEN count > 0 THEN count - 1 ELSE 0 END
307+
WHERE client_hash = ? AND usage_date = ?
308+
""",
309+
(row["client_hash"], row["usage_date"]),
310+
)
311+
110312
def summary_counts(self) -> dict[str, int]:
111313
self.initialize()
112314
tables = (

scripts/tests/test_runtime_store.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Runtime SQLite schema and legacy importer tests."""
22
import json
3+
from concurrent.futures import ThreadPoolExecutor
34

45
from bazi_engine.runtime_store import RuntimeStore
56

@@ -70,3 +71,34 @@ def test_legacy_runtime_data_import_is_idempotent(tmp_path):
7071
"fusion_feedback": 1,
7172
"family_feedback": 1,
7273
}
74+
75+
76+
def test_activation_reservations_are_transactional_under_concurrency(tmp_path):
77+
store = RuntimeStore(tmp_path / "runtime.sqlite3")
78+
store.seed_activation_codes({"PAID001": {"剩余": 3, "备注": "test"}})
79+
80+
with ThreadPoolExecutor(max_workers=12) as executor:
81+
reservations = list(executor.map(lambda _: store.reserve_activation("PAID001"), range(12)))
82+
83+
successful = [reservation for reservation in reservations if reservation]
84+
assert len(successful) == 3
85+
assert store.activation_remaining("PAID001") == 0
86+
assert store.release_reservation(successful[0].reservation_id) is True
87+
assert store.release_reservation(successful[0].reservation_id) is False
88+
assert store.activation_remaining("PAID001") == 1
89+
assert store.settle_reservation(successful[1].reservation_id) is True
90+
assert store.activation_remaining("PAID001") == 1
91+
92+
93+
def test_free_reservations_release_on_failure(tmp_path):
94+
store = RuntimeStore(tmp_path / "runtime.sqlite3")
95+
first = store.reserve_free("hash", "2026-07-16", 2)
96+
second = store.reserve_free("hash", "2026-07-16", 2)
97+
98+
assert first and second
99+
assert store.reserve_free("hash", "2026-07-16", 2) is None
100+
assert store.release_reservation(first.reservation_id) is True
101+
third = store.reserve_free("hash", "2026-07-16", 2)
102+
assert third is not None
103+
assert store.settle_reservation(second.reservation_id) is True
104+
assert store.free_remaining("hash", "2026-07-16", 2) == 0

0 commit comments

Comments
 (0)