Files
EvoScientist-Multi/EvoScientist/llm/history_initialization.py
T

112 lines
5.3 KiB
Python

"""Local durable initialization gate. All graph access must use this gate.
The file lock covers graph writes as well as owner changes; a row CAS alone
cannot fence an old writer while it is suspended in a graph await.
"""
import asyncio
import fcntl
import sqlite3
from contextlib import asynccontextmanager, closing
from pathlib import Path
from langgraph.graph import START, END
class SqliteInitializationStore:
def __init__(self, path):
if str(path) == ":memory:":
raise ValueError("initialization requires a durable file")
self.path = str(Path(path).resolve())
with closing(self.connect()) as db, db:
db.execute("""CREATE TABLE IF NOT EXISTS history_initialization (
key TEXT PRIMARY KEY, attempt TEXT NOT NULL, digest TEXT NOT NULL,
owner TEXT NOT NULL, fence INTEGER NOT NULL, status TEXT NOT NULL)""")
def connect(self):
db = sqlite3.connect(self.path)
db.row_factory = sqlite3.Row
return db
def get(self, key):
with closing(self.connect()) as db:
row = db.execute("SELECT * FROM history_initialization WHERE key=?", (key,)).fetchone()
return dict(row) if row else None
@asynccontextmanager
async def locked(self, key):
with open(self.path + "." + key.split(":")[-1] + ".lock", "a") as lock:
while True:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
break
except BlockingIOError:
await asyncio.sleep(0.01)
try:
yield
finally:
fcntl.flock(lock, fcntl.LOCK_UN)
def claim(self, key, attempt, digest, owner, fence):
if not isinstance(attempt, str) or not attempt.strip() or not isinstance(owner, str) or not owner.strip():
raise ValueError("attempt and owner required")
if type(fence) is not int or fence < 1:
raise ValueError("positive owner fence required")
with closing(self.connect()) as db, db:
db.execute("BEGIN IMMEDIATE")
row = db.execute("SELECT * FROM history_initialization WHERE key=?", (key,)).fetchone()
if row:
if row["attempt"] != attempt or row["digest"] != digest:
raise ValueError("history attempt/content conflict")
if fence < row["fence"] or (fence == row["fence"] and owner != row["owner"]):
raise ValueError("stale history owner fence")
if row["status"] not in ("INITIALIZING", "READY"):
raise ValueError("history checkpoint already consumed")
db.execute("UPDATE history_initialization SET owner=?, fence=? WHERE key=?", (owner, fence, key))
else:
db.execute("INSERT INTO history_initialization VALUES (?,?,?,?,?,?)",
(key, attempt, digest, owner, fence, "INITIALIZING"))
def status(self, key, attempt, owner, fence, before, after):
with closing(self.connect()) as db, db:
changed = db.execute("""UPDATE history_initialization SET status=?
WHERE key=? AND attempt=? AND owner=? AND fence=? AND status=?""",
(after, key, attempt, owner, fence, before)).rowcount
if changed != 1:
raise ValueError("history requires READY and current owner fence")
async def initialize(graph, key, messages, digest, store, attempt, owner, fence):
config = {"configurable": {"thread_id": key}}
marker = {"history_attempt": attempt, "history_digest": digest}
async with store.locked(key):
current = await graph.aget_state(config)
if store.get(key) is None and current.created_at is not None:
raise ValueError("unowned history checkpoint already exists")
store.claim(key, attempt, digest, owner, fence)
if current.created_at is None:
await graph.aupdate_state(dict(config, metadata=dict(marker, history_stage="START")),
{"messages": list(messages)}, as_node=START)
current = await graph.aget_state(config)
metadata = current.metadata or {}
if any(metadata.get(k) != v for k, v in marker.items()):
raise ValueError("unowned history checkpoint")
if current.values.get("messages", []) != list(messages):
raise ValueError("history checkpoint content changed")
stage = metadata.get("history_stage")
if stage == "START":
await graph.aupdate_state(dict(config, metadata=dict(marker, history_stage="END")),
None, as_node=END)
current = await graph.aget_state(config)
elif stage != "END":
raise ValueError("unknown history initialization stage")
if current.next or current.tasks:
raise ValueError("history checkpoint is not READY")
store.status(key, attempt, owner, fence, store.get(key)["status"], "READY")
return config
async def invoke(graph, key, store, attempt, owner, fence, input):
async with store.locked(key):
# Consume before execution: uncertain invocation must not be replayed.
store.status(key, attempt, owner, fence, "READY", "CONSUMED")
return await graph.ainvoke(input, {"configurable": {"thread_id": key}})