annoscript 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- annoscript/__init__.py +1 -0
- annoscript/_backend_profiles.py +17 -0
- annoscript/_client_common.py +287 -0
- annoscript/_data.py +37 -0
- annoscript/_dispatch_pool.py +47 -0
- annoscript/_env.py +19 -0
- annoscript/_examples.py +34 -0
- annoscript/_expressions.py +93 -0
- annoscript/_plan_viewer.html +953 -0
- annoscript/_remote_jobs.py +211 -0
- annoscript/_request_adapters.py +71 -0
- annoscript/_speculation.py +321 -0
- annoscript/_test_artifacts.py +33 -0
- annoscript/_test_evaluation_http.py +117 -0
- annoscript/_test_ir_runtime.py +138 -0
- annoscript/_test_job_process.py +166 -0
- annoscript/_test_plan_runtime.py +331 -0
- annoscript/_test_remote_client.py +46 -0
- annoscript/_test_remote_server.py +75 -0
- annoscript/_test_synthetic.py +67 -0
- annoscript/anno.py +270 -0
- annoscript/backends/__init__.py +13 -0
- annoscript/backends/_batch_wire.py +26 -0
- annoscript/backends/_bounded_grammar.py +97 -0
- annoscript/backends/_example_layout.py +23 -0
- annoscript/backends/_generation_policy.py +116 -0
- annoscript/backends/_grammar.py +83 -0
- annoscript/backends/_preview.py +13 -0
- annoscript/backends/_prompt.py +115 -0
- annoscript/backends/_shared_rules.py +38 -0
- annoscript/backends/adapters/__init__.py +17 -0
- annoscript/backends/adapters/chat.py +39 -0
- annoscript/backends/adapters/decision.py +119 -0
- annoscript/backends/base.py +427 -0
- annoscript/backends/config.py +67 -0
- annoscript/backends/decision.py +150 -0
- annoscript/backends/llm.py +338 -0
- annoscript/client.py +247 -0
- annoscript/client_tests.py +17 -0
- annoscript/compiler.py +708 -0
- annoscript/cross_version_tests.py +68 -0
- annoscript/fixtures/request-adapters-v1.json +4741 -0
- annoscript/formatter.py +171 -0
- annoscript/grammar_tests.py +176 -0
- annoscript/inspect.py +77 -0
- annoscript/ir.py +458 -0
- annoscript/job_cross_version_tests.py +118 -0
- annoscript/job_protocol.py +180 -0
- annoscript/jobs.py +255 -0
- annoscript/metrics.py +153 -0
- annoscript/p0_grammar_tests.py +147 -0
- annoscript/passes/__init__.py +40 -0
- annoscript/passes/_analysis.py +178 -0
- annoscript/passes/control_flow.py +68 -0
- annoscript/passes/dead_assignments.py +44 -0
- annoscript/passes/folding.py +104 -0
- annoscript/passes/propagation.py +53 -0
- annoscript/passes/temporaries.py +45 -0
- annoscript/plan.py +542 -0
- annoscript/plan_formatter.py +452 -0
- annoscript/plan_lowering.py +571 -0
- annoscript/preview.py +115 -0
- annoscript/remote.py +304 -0
- annoscript/runtime.py +861 -0
- annoscript/server/__init__.py +1 -0
- annoscript/server/__main__.py +49 -0
- annoscript/server/_plan_cache.py +79 -0
- annoscript/server/api.py +146 -0
- annoscript/server/metrics.py +140 -0
- annoscript/server/migrate.py +61 -0
- annoscript/server/releases.py +143 -0
- annoscript/server/store.py +429 -0
- annoscript/server/worker.py +290 -0
- annoscript/substring_tests.py +79 -0
- annoscript/test_advisory_lengths.py +115 -0
- annoscript/test_backends.py +181 -0
- annoscript/test_client.py +349 -0
- annoscript/test_compact_batch.py +95 -0
- annoscript/test_compiler.py +444 -0
- annoscript/test_decision_adapters.py +410 -0
- annoscript/test_decision_wire.py +190 -0
- annoscript/test_diagnostics.py +97 -0
- annoscript/test_engineering_p0.py +191 -0
- annoscript/test_environment.py +65 -0
- annoscript/test_example_backends.py +251 -0
- annoscript/test_examples.py +149 -0
- annoscript/test_field_names.py +221 -0
- annoscript/test_formatter.py +153 -0
- annoscript/test_fusion_scheduling.py +109 -0
- annoscript/test_generation_policy.py +180 -0
- annoscript/test_ir.py +148 -0
- annoscript/test_jobs.py +371 -0
- annoscript/test_jobs_server.py +511 -0
- annoscript/test_llm_batch.py +428 -0
- annoscript/test_metrics.py +59 -0
- annoscript/test_metrics_server.py +261 -0
- annoscript/test_minimal_fusion.py +59 -0
- annoscript/test_nullable_generate.py +169 -0
- annoscript/test_passes.py +408 -0
- annoscript/test_plan.py +522 -0
- annoscript/test_plan_constraints.py +305 -0
- annoscript/test_plan_formatter.py +272 -0
- annoscript/test_prompt_layout.py +140 -0
- annoscript/test_qwen_backend.py +181 -0
- annoscript/test_qwen_pilot_regression.py +180 -0
- annoscript/test_release_client.py +20 -0
- annoscript/test_remote.py +308 -0
- annoscript/test_request_adapters.py +295 -0
- annoscript/test_requirements.py +228 -0
- annoscript/test_rule_dedup.py +67 -0
- annoscript/test_runtime.py +314 -0
- annoscript/test_runtime_client.py +133 -0
- annoscript/test_source_fields.py +111 -0
- annoscript/test_speculation.py +458 -0
- annoscript/test_substring_backend.py +81 -0
- annoscript/test_synthetic_example.py +61 -0
- annoscript-0.1.0.dist-info/METADATA +145 -0
- annoscript-0.1.0.dist-info/RECORD +128 -0
- annoscript-0.1.0.dist-info/WHEEL +4 -0
- annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
- annoscript_examples/__init__.py +1 -0
- annoscript_examples/fixtures.py +3 -0
- annoscript_examples/request.execution_plan.json +3017 -0
- annoscript_examples/request.json +858 -0
- annoscript_examples/request.py +37 -0
- annoscript_examples/request_preview.decision.json +23 -0
- annoscript_examples/request_preview.generation.json +20 -0
- annoscript_examples/run.py +24 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Single-host durable execution service (Python 3.12+). Imported only explicitly."""
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""python -m annoscript.server {api,worker} --db jobs.sqlite --config service.json"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import signal
|
|
6
|
+
|
|
7
|
+
from annoscript.server.releases import load_config
|
|
8
|
+
from annoscript.server.store import Store
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
13
|
+
parser.add_argument("command", choices=("api", "worker", "releases"))
|
|
14
|
+
parser.add_argument("--db", default="anno-jobs.sqlite")
|
|
15
|
+
parser.add_argument("--config", required=True)
|
|
16
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
17
|
+
parser.add_argument("--port", type=int, default=8000)
|
|
18
|
+
parser.add_argument("--concurrency", type=int, default=8)
|
|
19
|
+
parser.add_argument("--max-generation-inflight", type=int)
|
|
20
|
+
parser.add_argument("--max-decision-inflight", type=int)
|
|
21
|
+
args = parser.parse_args()
|
|
22
|
+
releases, tokens = load_config(args.config)
|
|
23
|
+
if args.command == "releases":
|
|
24
|
+
from annoscript.job_protocol import encode
|
|
25
|
+
print(encode({"default_release": releases.default, "releases": releases.manifests}).decode())
|
|
26
|
+
return
|
|
27
|
+
store = Store(args.db)
|
|
28
|
+
if args.command == "api":
|
|
29
|
+
import uvicorn
|
|
30
|
+
from annoscript.server.api import create_app
|
|
31
|
+
uvicorn.run(create_app(store, releases, tokens), host=args.host, port=args.port, access_log=False)
|
|
32
|
+
else:
|
|
33
|
+
from annoscript.server.worker import Worker
|
|
34
|
+
limits = None
|
|
35
|
+
if args.max_generation_inflight is not None or args.max_decision_inflight is not None:
|
|
36
|
+
if args.max_generation_inflight is None or args.max_decision_inflight is None:
|
|
37
|
+
parser.error("both shared model limits must be provided")
|
|
38
|
+
limits = {"generation": args.max_generation_inflight, "decision": args.max_decision_inflight}
|
|
39
|
+
async def serve():
|
|
40
|
+
stop = asyncio.Event()
|
|
41
|
+
loop = asyncio.get_running_loop()
|
|
42
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
43
|
+
loop.add_signal_handler(sig, stop.set)
|
|
44
|
+
await Worker(store, releases, concurrency=args.concurrency, model_request_limits=limits).run(stop=stop)
|
|
45
|
+
asyncio.run(serve())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
main()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Worker-local bounded plan cache. Never contains input or inference state."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
import hashlib
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from annoscript.job_protocol import encode
|
|
10
|
+
|
|
11
|
+
MAX_ENTRIES = 32
|
|
12
|
+
MAX_BYTES = 64 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PlanCache:
|
|
16
|
+
def __init__(self, *, max_entries=MAX_ENTRIES, max_bytes=MAX_BYTES):
|
|
17
|
+
self.max_entries, self.max_bytes = max_entries, max_bytes
|
|
18
|
+
self.entries, self.pending = OrderedDict(), {}
|
|
19
|
+
self.bytes = 0
|
|
20
|
+
self.stats = dict(hits=0, misses=0, waits=0, compiles=0, failures=0, evictions=0)
|
|
21
|
+
self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="anno-plan")
|
|
22
|
+
self.closed = False
|
|
23
|
+
|
|
24
|
+
async def get(self, release_id, ir, compile_plan):
|
|
25
|
+
if self.closed:
|
|
26
|
+
raise RuntimeError("plan cache is closed")
|
|
27
|
+
started = time.monotonic()
|
|
28
|
+
encoded = encode(ir, canonical=True)
|
|
29
|
+
key = (release_id, hashlib.sha256(encoded).hexdigest())
|
|
30
|
+
status = "hit"
|
|
31
|
+
if key in self.entries:
|
|
32
|
+
self.stats["hits"] += 1
|
|
33
|
+
entry = self.entries[key]
|
|
34
|
+
self.entries.move_to_end(key)
|
|
35
|
+
else:
|
|
36
|
+
status = "wait" if key in self.pending else "miss"
|
|
37
|
+
self.stats["waits" if status == "wait" else "misses"] += 1
|
|
38
|
+
if status == "miss":
|
|
39
|
+
self.pending[key] = asyncio.create_task(self._compile(key, encoded, compile_plan))
|
|
40
|
+
# Retrieve exceptions even if every input waiting for this plan was cancelled.
|
|
41
|
+
self.pending[key].add_done_callback(lambda t: None if t.cancelled() else t.exception())
|
|
42
|
+
entry = await asyncio.shield(self.pending[key])
|
|
43
|
+
plan, size, compile_seconds = entry
|
|
44
|
+
return plan, {"event": "plan_cache", "status": status, "ir_sha256": key[1],
|
|
45
|
+
"seconds": time.monotonic() - started,
|
|
46
|
+
"compile_seconds": compile_seconds if status != "hit" else 0.0,
|
|
47
|
+
"plan_bytes": size, "cached_bytes": self.bytes,
|
|
48
|
+
"cached_entries": len(self.entries), "counters": dict(self.stats)}
|
|
49
|
+
|
|
50
|
+
async def _compile(self, key, encoded, compile_plan):
|
|
51
|
+
def build():
|
|
52
|
+
started = time.monotonic()
|
|
53
|
+
plan = compile_plan(encoded)
|
|
54
|
+
size = len(plan.to_json(indent=None).encode("utf-8"))
|
|
55
|
+
return plan, size, time.monotonic() - started
|
|
56
|
+
try:
|
|
57
|
+
entry = await asyncio.get_running_loop().run_in_executor(self.executor, build)
|
|
58
|
+
self.stats["compiles"] += 1
|
|
59
|
+
size = entry[1]
|
|
60
|
+
if self.max_entries > 0 and size <= self.max_bytes:
|
|
61
|
+
while self.entries and (len(self.entries) >= self.max_entries or self.bytes + size > self.max_bytes):
|
|
62
|
+
_, old = self.entries.popitem(last=False)
|
|
63
|
+
self.bytes -= old[1]
|
|
64
|
+
self.stats["evictions"] += 1
|
|
65
|
+
self.entries[key] = entry
|
|
66
|
+
self.bytes += size
|
|
67
|
+
return entry
|
|
68
|
+
except Exception:
|
|
69
|
+
self.stats["failures"] += 1
|
|
70
|
+
raise
|
|
71
|
+
finally:
|
|
72
|
+
self.pending.pop(key, None)
|
|
73
|
+
|
|
74
|
+
async def aclose(self):
|
|
75
|
+
self.closed = True
|
|
76
|
+
await asyncio.gather(*self.pending.values(), return_exceptions=True)
|
|
77
|
+
self.executor.shutdown(wait=True)
|
|
78
|
+
self.entries.clear()
|
|
79
|
+
self.bytes = 0
|
annoscript/server/api.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Short-request JSON API. No model backend is constructed by this process."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from contextlib import asynccontextmanager, suppress
|
|
5
|
+
import hmac
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, FastAPI, Request
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
|
|
10
|
+
from annoscript.job_protocol import decode, integer, nonempty
|
|
11
|
+
from annoscript.remote import RemoteProtocolError
|
|
12
|
+
from annoscript.server.store import ServiceError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_app(store, releases, tokens):
|
|
16
|
+
if not tokens or any(not nonempty(k) or not nonempty(v) for k, v in tokens.items()):
|
|
17
|
+
raise ValueError("service tokens must map to nonempty principals")
|
|
18
|
+
|
|
19
|
+
@asynccontextmanager
|
|
20
|
+
async def lifespan(app):
|
|
21
|
+
async def maintain():
|
|
22
|
+
while True:
|
|
23
|
+
await asyncio.to_thread(store.maintain)
|
|
24
|
+
await asyncio.sleep(1)
|
|
25
|
+
await asyncio.to_thread(store.maintain)
|
|
26
|
+
task = asyncio.create_task(maintain())
|
|
27
|
+
try:
|
|
28
|
+
yield
|
|
29
|
+
finally:
|
|
30
|
+
task.cancel()
|
|
31
|
+
with suppress(asyncio.CancelledError):
|
|
32
|
+
await task
|
|
33
|
+
|
|
34
|
+
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None,
|
|
35
|
+
redirect_slashes=False)
|
|
36
|
+
|
|
37
|
+
@app.exception_handler(ServiceError)
|
|
38
|
+
async def service_error(request, error):
|
|
39
|
+
return JSONResponse({"error": {"code": error.code, "message": str(error)}}, status_code=error.status)
|
|
40
|
+
|
|
41
|
+
def principal(request: Request):
|
|
42
|
+
auth = request.headers.get("Authorization", "")
|
|
43
|
+
if not auth.lower().startswith("bearer "):
|
|
44
|
+
raise ServiceError("authentication_failed", "service authentication required", 401)
|
|
45
|
+
supplied = auth[7:]
|
|
46
|
+
for token, identity in tokens.items():
|
|
47
|
+
if hmac.compare_digest(supplied.encode(), token.encode()):
|
|
48
|
+
return identity
|
|
49
|
+
raise ServiceError("authentication_failed", "invalid service token", 401)
|
|
50
|
+
|
|
51
|
+
def key(request):
|
|
52
|
+
value = request.headers.get("Idempotency-Key")
|
|
53
|
+
if not nonempty(value) or len(value) > 256:
|
|
54
|
+
raise ServiceError("invalid_idempotency_key", "a nonempty Idempotency-Key of at most 256 characters is required")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
async def body(request, *, empty=False):
|
|
58
|
+
limit = max(m["policy"]["max_request_bytes"] for m in releases.manifests.values())
|
|
59
|
+
data = bytearray()
|
|
60
|
+
async for chunk in request.stream():
|
|
61
|
+
data.extend(chunk)
|
|
62
|
+
if len(data) > limit:
|
|
63
|
+
raise ServiceError("request_too_large", "request exceeds service body limit", 413)
|
|
64
|
+
if not data and empty:
|
|
65
|
+
return {}
|
|
66
|
+
if request.headers.get("content-type", "").split(";", 1)[0].lower() != "application/json":
|
|
67
|
+
raise ServiceError("invalid_content_type", "expected application/json", 415)
|
|
68
|
+
try:
|
|
69
|
+
value = decode(bytes(data))
|
|
70
|
+
except RemoteProtocolError as exc:
|
|
71
|
+
raise ServiceError("invalid_json", str(exc)) from None
|
|
72
|
+
if type(value) is not dict:
|
|
73
|
+
raise ServiceError("invalid_request", "request must be an object")
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
def envelope(job):
|
|
77
|
+
return {"protocol_version": 1, "job": job}
|
|
78
|
+
|
|
79
|
+
@app.post("/v1/jobs", status_code=202)
|
|
80
|
+
async def submit(request: Request, identity=Depends(principal)):
|
|
81
|
+
idem = key(request)
|
|
82
|
+
payload = await body(request)
|
|
83
|
+
try:
|
|
84
|
+
job = await asyncio.to_thread(store.submit, identity, idem, payload, releases)
|
|
85
|
+
except (ValueError, TypeError, RecursionError) as exc:
|
|
86
|
+
raise ServiceError("invalid_request", "invalid job request or IR") from exc
|
|
87
|
+
return envelope(job)
|
|
88
|
+
|
|
89
|
+
@app.get("/v1/jobs:lookup")
|
|
90
|
+
def lookup(request: Request, identity=Depends(principal)):
|
|
91
|
+
return envelope(store.lookup(identity, key(request)))
|
|
92
|
+
|
|
93
|
+
@app.get("/v1/jobs/{job_id}")
|
|
94
|
+
def status(job_id: str, identity=Depends(principal)):
|
|
95
|
+
return envelope(store.status(identity, job_id))
|
|
96
|
+
|
|
97
|
+
@app.get("/v1/jobs/{job_id}/results")
|
|
98
|
+
def results(job_id: str, request: Request, identity=Depends(principal)):
|
|
99
|
+
try:
|
|
100
|
+
limit = int(request.query_params.get("limit", "100"))
|
|
101
|
+
except ValueError:
|
|
102
|
+
raise ServiceError("invalid_limit", "invalid page limit") from None
|
|
103
|
+
return store.results(identity, job_id, request.query_params.get("cursor"), limit)
|
|
104
|
+
|
|
105
|
+
@app.get("/v1/jobs/{job_id}/metrics")
|
|
106
|
+
def metrics(job_id: str, identity=Depends(principal)):
|
|
107
|
+
return store.metrics(identity, job_id)
|
|
108
|
+
|
|
109
|
+
@app.post("/v1/jobs/{job_id}:resume")
|
|
110
|
+
async def resume(job_id: str, request: Request, identity=Depends(principal)):
|
|
111
|
+
idem = key(request)
|
|
112
|
+
payload = await body(request)
|
|
113
|
+
if set(payload) != {"pause_generation"} or not integer(payload["pause_generation"], 1):
|
|
114
|
+
raise ServiceError("invalid_request", "resume requires a positive pause_generation")
|
|
115
|
+
return envelope(await asyncio.to_thread(store.resume, identity, job_id, idem, payload["pause_generation"], releases))
|
|
116
|
+
|
|
117
|
+
@app.post("/v1/jobs/{job_id}:cancel")
|
|
118
|
+
async def cancel(job_id: str, request: Request, identity=Depends(principal)):
|
|
119
|
+
if await body(request, empty=True):
|
|
120
|
+
raise ServiceError("invalid_request", "cancel body must be empty")
|
|
121
|
+
return envelope(await asyncio.to_thread(store.cancel, identity, job_id))
|
|
122
|
+
|
|
123
|
+
@app.get("/v1/releases/{release_id}")
|
|
124
|
+
def release(release_id: str, identity=Depends(principal)):
|
|
125
|
+
return {"protocol_version": 1, **store.release(release_id, releases)}
|
|
126
|
+
|
|
127
|
+
@app.post("/v1/sessions", status_code=201)
|
|
128
|
+
async def create_session(request: Request, identity=Depends(principal)):
|
|
129
|
+
idem = key(request)
|
|
130
|
+
if await body(request, empty=True):
|
|
131
|
+
raise ServiceError("invalid_request", "session body must be empty")
|
|
132
|
+
return {"protocol_version": 1, **await asyncio.to_thread(store.create_session, identity, idem)}
|
|
133
|
+
|
|
134
|
+
@app.post("/v1/sessions/{session_id}:heartbeat")
|
|
135
|
+
async def heartbeat(session_id: str, request: Request, identity=Depends(principal)):
|
|
136
|
+
if await body(request, empty=True):
|
|
137
|
+
raise ServiceError("invalid_request", "heartbeat body must be empty")
|
|
138
|
+
return {"protocol_version": 1, **await asyncio.to_thread(store.heartbeat_session, identity, session_id)}
|
|
139
|
+
|
|
140
|
+
@app.post("/v1/sessions/{session_id}:close")
|
|
141
|
+
async def close_session(session_id: str, request: Request, identity=Depends(principal)):
|
|
142
|
+
if await body(request, empty=True):
|
|
143
|
+
raise ServiceError("invalid_request", "close body must be empty")
|
|
144
|
+
return {"protocol_version": 1, **await asyncio.to_thread(store.close_session, identity, session_id)}
|
|
145
|
+
|
|
146
|
+
return app
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Fenced metric persistence. No provider bodies or client-side journal."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from annoscript.job_protocol import TERMINAL, decode, encode
|
|
8
|
+
from annoscript.metrics import aggregate, normalize_usage
|
|
9
|
+
from annoscript.backends.base import BackendFatalError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
UPGRADE = """
|
|
13
|
+
ALTER TABLE jobs ADD COLUMN metrics_available INTEGER NOT NULL DEFAULT 0;
|
|
14
|
+
ALTER TABLE jobs ADD COLUMN queued_monotonic REAL;
|
|
15
|
+
ALTER TABLE jobs ADD COLUMN clock_epoch TEXT;
|
|
16
|
+
ALTER TABLE attempts ADD COLUMN metered INTEGER NOT NULL DEFAULT 0;
|
|
17
|
+
ALTER TABLE attempts ADD COLUMN duration_seconds REAL;
|
|
18
|
+
ALTER TABLE attempts ADD COLUMN queue_wait_seconds REAL;
|
|
19
|
+
CREATE TABLE http_requests (
|
|
20
|
+
id TEXT PRIMARY KEY, job_id TEXT NOT NULL, idx INTEGER NOT NULL, attempt INTEGER NOT NULL,
|
|
21
|
+
backend TEXT NOT NULL, questions INTEGER NOT NULL, http_status INTEGER, seconds REAL, usage BLOB,
|
|
22
|
+
FOREIGN KEY(job_id,idx,attempt) REFERENCES attempts(job_id,idx,number));
|
|
23
|
+
CREATE INDEX http_requests_job ON http_requests(job_id,idx,attempt);
|
|
24
|
+
PRAGMA user_version=2;
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MetricStore:
|
|
29
|
+
def meter_attempt(self, claim, supported):
|
|
30
|
+
with self._tx() as db:
|
|
31
|
+
if self._owned(db, claim) is None:
|
|
32
|
+
return False
|
|
33
|
+
db.execute("UPDATE attempts SET metered=? WHERE job_id=? AND idx=? AND number=?",
|
|
34
|
+
(int(supported), claim["job_id"], claim["index"], claim["attempt"]))
|
|
35
|
+
return True
|
|
36
|
+
|
|
37
|
+
def request_started(self, claim, request_id, backend, questions):
|
|
38
|
+
if backend not in ("decision", "generation") or type(questions) is not int or questions < 1:
|
|
39
|
+
raise ValueError("invalid request metrics")
|
|
40
|
+
with self._tx() as db:
|
|
41
|
+
owned = self._owned(db, claim)
|
|
42
|
+
if owned is None or owned[0]["state"] not in ("queued", "running"):
|
|
43
|
+
return False
|
|
44
|
+
args = (request_id, claim["job_id"], claim["index"], claim["attempt"], backend, questions)
|
|
45
|
+
old = db.execute("SELECT id,job_id,idx,attempt,backend,questions FROM http_requests WHERE id=?", (request_id,)).fetchone()
|
|
46
|
+
if old is not None and tuple(old) != args:
|
|
47
|
+
raise ValueError("request metric identity conflict")
|
|
48
|
+
db.execute("INSERT OR IGNORE INTO http_requests(id,job_id,idx,attempt,backend,questions) VALUES (?,?,?,?,?,?)", args)
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
def request_finished(self, claim, request_id, *, seconds, http_status, usage):
|
|
52
|
+
if type(seconds) not in (int, float) or not 0 <= seconds < float("inf"):
|
|
53
|
+
raise ValueError("invalid HTTP duration")
|
|
54
|
+
if http_status is not None and (type(http_status) is not int or not 100 <= http_status <= 599):
|
|
55
|
+
raise ValueError("invalid HTTP status")
|
|
56
|
+
with self._tx() as db:
|
|
57
|
+
if self._owned(db, claim) is None:
|
|
58
|
+
return False
|
|
59
|
+
db.execute("""UPDATE http_requests SET seconds=?,http_status=?,usage=?
|
|
60
|
+
WHERE id=? AND job_id=? AND idx=? AND attempt=? AND seconds IS NULL""",
|
|
61
|
+
(seconds, http_status, encode(normalize_usage(usage)), request_id,
|
|
62
|
+
claim["job_id"], claim["index"], claim["attempt"]))
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
def _attempt_duration(self, db, claim, seconds):
|
|
66
|
+
if seconds is not None and (type(seconds) not in (int, float) or not 0 <= seconds < float("inf")):
|
|
67
|
+
raise ValueError("invalid attempt duration")
|
|
68
|
+
db.execute("UPDATE attempts SET duration_seconds=? WHERE job_id=? AND idx=? AND number=?",
|
|
69
|
+
(seconds, claim["job_id"], claim["index"], claim["attempt"]))
|
|
70
|
+
|
|
71
|
+
def metrics(self, principal, job_id):
|
|
72
|
+
with self._tx() as db:
|
|
73
|
+
job = self._job(db, job_id, principal)
|
|
74
|
+
status = self._status(db, job)
|
|
75
|
+
attempts = [dict(r) for r in db.execute("SELECT * FROM attempts WHERE job_id=? ORDER BY idx,number", (job_id,))]
|
|
76
|
+
requests = [dict(r) for r in db.execute("SELECT * FROM http_requests WHERE job_id=? ORDER BY id", (job_id,))]
|
|
77
|
+
for row in requests:
|
|
78
|
+
row["usage"] = decode(row["usage"]) if row["usage"] is not None else normalize_usage(None)
|
|
79
|
+
items, waits = [], []
|
|
80
|
+
for index in status["input_indices"]:
|
|
81
|
+
aa = [a for a in attempts if a["idx"] == index]
|
|
82
|
+
rr = [r for r in requests if r["idx"] == index]
|
|
83
|
+
# Queue intervals are valid only within the same monotonic
|
|
84
|
+
# clock epoch (boot); never subtract wall timestamps.
|
|
85
|
+
wait = aa[0]["queue_wait_seconds"] if aa else None
|
|
86
|
+
waits.append(wait)
|
|
87
|
+
items.append({"index": index, "metrics": aggregate(aa, rr, history=bool(job["metrics_available"]), waits=[wait])})
|
|
88
|
+
return {"protocol_version": 1, "metrics_version": 1, "job": status,
|
|
89
|
+
"provisional": status["state"] not in TERMINAL,
|
|
90
|
+
"summary": aggregate(attempts, requests, history=bool(job["metrics_available"]), waits=waits), "inputs": items}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RequestMeter:
|
|
94
|
+
"""One meter per owned attempt, invoked only at the actual HTTP boundary."""
|
|
95
|
+
def __init__(self, store, claim, backend, *, clock=time.monotonic, observer=None, wall_clock=time.time):
|
|
96
|
+
self.store, self.claim, self.backend, self.clock = store, claim, backend, clock
|
|
97
|
+
self.observer, self.wall_clock = observer, wall_clock
|
|
98
|
+
|
|
99
|
+
def _observe(self, event):
|
|
100
|
+
if self.observer is not None:
|
|
101
|
+
try:
|
|
102
|
+
self.observer({"job_id": self.claim["job_id"], "index": self.claim["index"],
|
|
103
|
+
"attempt": self.claim["attempt"], "stage": self.backend, **event})
|
|
104
|
+
except Exception:
|
|
105
|
+
raise BackendFatalError("request audit persistence failed", code="metrics_failed") from None
|
|
106
|
+
|
|
107
|
+
async def start(self, questions):
|
|
108
|
+
identity = uuid.uuid4().hex
|
|
109
|
+
try:
|
|
110
|
+
committed = await asyncio.to_thread(self.store.request_started, self.claim, identity, self.backend, questions)
|
|
111
|
+
except Exception:
|
|
112
|
+
raise BackendFatalError("request intent persistence failed", code="metrics_failed") from None
|
|
113
|
+
if not committed:
|
|
114
|
+
if self.store.renew(self.claim):
|
|
115
|
+
raise BackendFatalError("job is paused; no new model request was sent", code="job_paused")
|
|
116
|
+
raise asyncio.CancelledError()
|
|
117
|
+
self._observe({"event": "intent", "request_id": identity, "questions": questions,
|
|
118
|
+
"started_unix": self.wall_clock()})
|
|
119
|
+
return identity
|
|
120
|
+
|
|
121
|
+
async def finish(self, identity, elapsed, status, usage):
|
|
122
|
+
outcome = {"event": "outcome", "request_id": identity, "seconds": elapsed,
|
|
123
|
+
"http_status": status, "usage": usage, "finished_unix": self.wall_clock()}
|
|
124
|
+
committed = False
|
|
125
|
+
pending = asyncio.create_task(asyncio.to_thread(self.store.request_finished, self.claim, identity,
|
|
126
|
+
seconds=elapsed, http_status=status, usage=usage))
|
|
127
|
+
try:
|
|
128
|
+
try:
|
|
129
|
+
committed = await asyncio.shield(pending)
|
|
130
|
+
except asyncio.CancelledError:
|
|
131
|
+
# Drain persistence and audit before propagating cancellation.
|
|
132
|
+
# Ownership fencing still applies to stale attempts.
|
|
133
|
+
committed = await pending
|
|
134
|
+
raise
|
|
135
|
+
except Exception:
|
|
136
|
+
raise BackendFatalError("request outcome persistence failed", code="metrics_failed") from None
|
|
137
|
+
finally:
|
|
138
|
+
self._observe({**outcome, "storage_committed": committed})
|
|
139
|
+
if not committed:
|
|
140
|
+
raise asyncio.CancelledError()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Explicit offline v1 -> v2 migration; stop API and workers before invoking."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from contextlib import closing
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sqlite3
|
|
8
|
+
|
|
9
|
+
from annoscript.server.metrics import UPGRADE
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def migrate(path, backup, *, _after_statement=None):
|
|
13
|
+
path, backup = Path(path), Path(backup)
|
|
14
|
+
if not path.is_file() or path.resolve() == backup.resolve():
|
|
15
|
+
raise ValueError("an existing database and a separate new backup path are required")
|
|
16
|
+
with closing(sqlite3.connect(path, isolation_level=None)) as db:
|
|
17
|
+
if db.execute("PRAGMA user_version").fetchone()[0] != 1:
|
|
18
|
+
raise RuntimeError("migration requires schema v1")
|
|
19
|
+
# O_EXCL protects an earlier backup. SQLite backup includes committed WAL.
|
|
20
|
+
fd = os.open(backup, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
21
|
+
os.close(fd)
|
|
22
|
+
with closing(sqlite3.connect(backup)) as target:
|
|
23
|
+
db.backup(target)
|
|
24
|
+
if target.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
|
|
25
|
+
raise RuntimeError("backup integrity check failed")
|
|
26
|
+
with open(backup, "rb") as file:
|
|
27
|
+
os.fsync(file.fileno())
|
|
28
|
+
directory = os.open(backup.parent, os.O_RDONLY)
|
|
29
|
+
try:
|
|
30
|
+
os.fsync(directory)
|
|
31
|
+
finally:
|
|
32
|
+
os.close(directory)
|
|
33
|
+
db.execute("PRAGMA foreign_keys=ON")
|
|
34
|
+
db.execute("PRAGMA synchronous=FULL")
|
|
35
|
+
db.execute("BEGIN EXCLUSIVE")
|
|
36
|
+
try:
|
|
37
|
+
if db.execute("PRAGMA user_version").fetchone()[0] != 1:
|
|
38
|
+
raise RuntimeError("database changed during backup")
|
|
39
|
+
for statement in UPGRADE.split(";"):
|
|
40
|
+
if statement.strip():
|
|
41
|
+
db.execute(statement)
|
|
42
|
+
if _after_statement is not None:
|
|
43
|
+
_after_statement(statement)
|
|
44
|
+
if db.execute("PRAGMA foreign_key_check").fetchone() is not None:
|
|
45
|
+
raise RuntimeError("foreign key check failed")
|
|
46
|
+
db.commit()
|
|
47
|
+
except BaseException:
|
|
48
|
+
db.rollback()
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main():
|
|
53
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
54
|
+
parser.add_argument("--db", required=True)
|
|
55
|
+
parser.add_argument("--backup", required=True)
|
|
56
|
+
args = parser.parse_args()
|
|
57
|
+
migrate(args.db, args.backup)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Immutable execution manifests; credentials stay outside manifests and SQLite."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
from annoscript._env import env_value
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from annoscript.job_protocol import decode, encode
|
|
12
|
+
from annoscript._backend_profiles import validate_generation_profile, validate_extract_mode
|
|
13
|
+
from annoscript._request_adapters import (DEFAULT_DECISION_ADAPTER, DEFAULT_GENERATION_ADAPTER,
|
|
14
|
+
validate_request_adapter, normalize_decision_options)
|
|
15
|
+
from annoscript.backends.base import LLM_BATCH_DEFAULTS, validate_batch_options
|
|
16
|
+
from annoscript.backends._generation_policy import validate_policy, validate_token_ratio
|
|
17
|
+
from annoscript.server._plan_cache import MAX_ENTRIES, MAX_BYTES
|
|
18
|
+
from annoscript.backends._shared_rules import validate_rule_dedup
|
|
19
|
+
|
|
20
|
+
RUNTIME = {"max_active_runs": 1, "max_decision_inflight": 4, "max_llm_inflight": 4,
|
|
21
|
+
"max_batch_questions": 32, "loop_window": 32, "local_task_budget": 1024, **LLM_BATCH_DEFAULTS}
|
|
22
|
+
POLICY = {"max_attempts": 3, "input_timeout": 300.0, "model_timeout": 60.0,
|
|
23
|
+
"max_inputs": 500, "max_request_bytes": 32 * 1024 * 1024,
|
|
24
|
+
"retry_unknown_outcomes": True}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_sha256():
|
|
28
|
+
root = Path(__file__).resolve().parents[1]
|
|
29
|
+
digest = hashlib.sha256()
|
|
30
|
+
for path in sorted(root.rglob("*.py")):
|
|
31
|
+
if path.name.startswith(("test_", "_test_")):
|
|
32
|
+
continue
|
|
33
|
+
digest.update(str(path.relative_to(root)).encode() + b"\0" + path.read_bytes() + b"\0")
|
|
34
|
+
return digest.hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def manifest(config):
|
|
38
|
+
allowed = {"decision", "generation", "server_optimize", "runtime", "policy", "disable_thinking"}
|
|
39
|
+
if type(config) is not dict or set(config) - allowed:
|
|
40
|
+
raise ValueError("invalid release configuration")
|
|
41
|
+
models = {}
|
|
42
|
+
for name in ("decision", "generation"):
|
|
43
|
+
model = config.get(name)
|
|
44
|
+
if name == "generation" and isinstance(model, dict) and "source_layout" in model:
|
|
45
|
+
raise ValueError("source_layout was removed; extract now uses source_field")
|
|
46
|
+
required = {"base_url", "model", "revision"}
|
|
47
|
+
allowed_model = required | {"request_adapter"} | ({"profile", "extract_mode", "batch_rule_dedup", "structured_output_revision", "request_policy", "assumed_avg_num_tokens_per_char"} if name == "generation" else {"request_adapter_options"})
|
|
48
|
+
if (type(model) is not dict or not required <= set(model) or set(model) - allowed_model
|
|
49
|
+
or any(type(v) is not str or not v.strip() for k, v in model.items() if k not in ("request_policy", "assumed_avg_num_tokens_per_char", "batch_rule_dedup", "request_adapter_options"))):
|
|
50
|
+
raise ValueError(f"{name} needs base_url, model and immutable deployment revision")
|
|
51
|
+
url = urlsplit(model["base_url"])
|
|
52
|
+
if url.scheme not in ("http", "https") or not url.hostname or url.username or url.password or url.query or url.fragment:
|
|
53
|
+
raise ValueError("model URL must be HTTP(S), without credentials, query or fragment")
|
|
54
|
+
models[name] = model.copy()
|
|
55
|
+
models[name]["request_adapter"] = validate_request_adapter(
|
|
56
|
+
model.get("request_adapter", DEFAULT_DECISION_ADAPTER if name == "decision" else DEFAULT_GENERATION_ADAPTER), name)
|
|
57
|
+
if name == "decision":
|
|
58
|
+
if "request_adapter_options" in model and type(model["request_adapter_options"]) is not dict:
|
|
59
|
+
raise ValueError("request_adapter_options must be an object")
|
|
60
|
+
options = normalize_decision_options(models[name]["request_adapter"], model.get("request_adapter_options"))
|
|
61
|
+
if options is not None:
|
|
62
|
+
models[name]["request_adapter_options"] = options
|
|
63
|
+
else:
|
|
64
|
+
models[name].pop("request_adapter_options", None)
|
|
65
|
+
if name == "generation":
|
|
66
|
+
models[name]["assumed_avg_num_tokens_per_char"] = validate_token_ratio(model.get("assumed_avg_num_tokens_per_char", 2))
|
|
67
|
+
models[name]["profile"] = validate_generation_profile(model.get("profile", "legacy"))
|
|
68
|
+
if "batch_rule_dedup" in model:
|
|
69
|
+
models[name]["batch_rule_dedup"] = validate_rule_dedup(model["batch_rule_dedup"], models[name]["profile"])
|
|
70
|
+
# Omitted mode keeps existing release hashes stable for old manifests.
|
|
71
|
+
mode = validate_extract_mode(model.get("extract_mode", "validated"), models[name]["profile"])
|
|
72
|
+
if "extract_mode" in model:
|
|
73
|
+
models[name]["extract_mode"] = mode
|
|
74
|
+
if mode == "substring_chars" and not model.get("structured_output_revision"):
|
|
75
|
+
raise ValueError("substring_chars requires immutable structured_output_revision")
|
|
76
|
+
if "request_policy" in model:
|
|
77
|
+
request_policy = validate_policy(model["request_policy"])
|
|
78
|
+
if request_policy is None or models[name]["profile"] != "qwen_vllm":
|
|
79
|
+
raise ValueError("request_policy requires qwen_vllm")
|
|
80
|
+
if request_policy["tokenizer"]["revision"] != model["revision"]:
|
|
81
|
+
raise ValueError("tokenizer revision differs from generation revision")
|
|
82
|
+
if not model.get("structured_output_revision"):
|
|
83
|
+
raise ValueError("bounded outputs require immutable decoder build identity")
|
|
84
|
+
models[name]["request_policy"] = request_policy
|
|
85
|
+
runtime, policy = {**RUNTIME, **config.get("runtime", {})}, {**POLICY, **config.get("policy", {})}
|
|
86
|
+
validate_batch_options({k: runtime[k] for k in LLM_BATCH_DEFAULTS})
|
|
87
|
+
if (set(runtime) != set(RUNTIME) or any(type(v) is not int or v < 1 for k, v in runtime.items() if k != "llm_batch_repair_attempts")
|
|
88
|
+
or runtime["max_active_runs"] != 1 or set(policy) != set(POLICY)
|
|
89
|
+
or type(policy["retry_unknown_outcomes"]) is not bool
|
|
90
|
+
or any(type(v) not in (int, float) or not 0 < v < float("inf")
|
|
91
|
+
for k, v in policy.items() if k != "retry_unknown_outcomes")
|
|
92
|
+
or any(type(policy[k]) is not int for k in ("max_attempts", "max_inputs", "max_request_bytes"))):
|
|
93
|
+
raise ValueError("invalid release runtime or resource policy")
|
|
94
|
+
generation_policy = models["generation"].get("request_policy")
|
|
95
|
+
optimize, thinking = config.get("server_optimize", True), config.get("disable_thinking", True)
|
|
96
|
+
if type(optimize) is not bool or type(thinking) is not bool:
|
|
97
|
+
raise ValueError("release switches must be booleans")
|
|
98
|
+
return {"format": "lev-release", "version": 1, "build_sha256": build_sha256(),
|
|
99
|
+
"engineering": {"plan_cache": {"max_entries": MAX_ENTRIES, "max_serialized_bytes": MAX_BYTES,
|
|
100
|
+
"compiler_threads": 1, "key": "release_id+canonical_ir_sha256"},
|
|
101
|
+
"qwen_source_layout": "source_field_v1", "request_preparation": "immutable_v1",
|
|
102
|
+
"worker_refill": "completion_event_v1", "shared_model_slots": "ready_at_admission_fifo_v1",
|
|
103
|
+
"shared_slot_scope": "single_worker_process; limits bound by execution manifest"},
|
|
104
|
+
"python": f"{sys.version_info.major}.{sys.version_info.minor}", "ir_version": 1,
|
|
105
|
+
"dependencies": {name: version(name) for name in ("httpx", "httpcore", "fastapi", "starlette", "uvicorn")},
|
|
106
|
+
**models, "server_optimize": optimize, "disable_thinking": thinking,
|
|
107
|
+
"runtime": runtime, "policy": policy}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Releases:
|
|
111
|
+
def __init__(self, configs, default):
|
|
112
|
+
self.manifests, self.aliases = {}, {}
|
|
113
|
+
for alias, config in configs.items():
|
|
114
|
+
value = manifest(config)
|
|
115
|
+
release_id = hashlib.sha256(encode(value, canonical=True)).hexdigest()
|
|
116
|
+
self.manifests[release_id] = value
|
|
117
|
+
self.aliases[alias] = release_id
|
|
118
|
+
if default not in self.aliases:
|
|
119
|
+
raise ValueError("default_release must name a configured release")
|
|
120
|
+
self.default = self.aliases[default]
|
|
121
|
+
|
|
122
|
+
def resolve(self, release_id=None):
|
|
123
|
+
release_id = release_id or self.default
|
|
124
|
+
if release_id not in self.manifests:
|
|
125
|
+
raise KeyError(release_id)
|
|
126
|
+
return release_id, self.manifests[release_id]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_config(path):
|
|
130
|
+
if sys.version_info < (3, 12):
|
|
131
|
+
raise RuntimeError("the AnnoScript job service requires Python 3.12+")
|
|
132
|
+
config = decode(Path(path).read_bytes())
|
|
133
|
+
if type(config) is not dict or set(config) != {"releases", "default_release", "auth"}:
|
|
134
|
+
raise ValueError("config requires releases, default_release and auth")
|
|
135
|
+
if type(config["auth"]) is not dict or not config["auth"]:
|
|
136
|
+
raise ValueError("auth must map principals to token environment variable names")
|
|
137
|
+
tokens = {}
|
|
138
|
+
for principal, variable in config["auth"].items():
|
|
139
|
+
token = env_value(variable) if variable.startswith("ANNO_") else os.environ.get(variable)
|
|
140
|
+
if not principal or not token or token in tokens:
|
|
141
|
+
raise ValueError("service authentication tokens must be present and unique")
|
|
142
|
+
tokens[token] = principal
|
|
143
|
+
return Releases(config["releases"], config["default_release"]), tokens
|