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,511 @@
|
|
|
1
|
+
"""Offline SQLite, API and real Runtime integration; no external services."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from contextlib import closing
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import sqlite3
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from fastapi.testclient import TestClient
|
|
16
|
+
|
|
17
|
+
from annoscript._client_common import Result, ResultError
|
|
18
|
+
from annoscript.backends import BackendFatalError
|
|
19
|
+
from annoscript.backends.base import InferenceError
|
|
20
|
+
from annoscript.compiler import compile_program
|
|
21
|
+
from annoscript.job_protocol import cursor_for, validate_page
|
|
22
|
+
from annoscript.jobs import AnnoJobClient, RemoteSubmissionError
|
|
23
|
+
from annoscript.remote import ir_sha256
|
|
24
|
+
from annoscript.server.api import create_app
|
|
25
|
+
from annoscript.server.releases import Releases
|
|
26
|
+
from annoscript.server.store import ServiceError, Store
|
|
27
|
+
from annoscript.server.worker import Worker
|
|
28
|
+
from annoscript.test_jobs import Clock, generate
|
|
29
|
+
from annoscript.test_runtime import FakeBackend
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def configurations(**policy):
|
|
33
|
+
return {"test": {"decision": {"base_url": "http://offline.invalid", "model": "fixture", "revision": "fixed-1"},
|
|
34
|
+
"generation": {"base_url": "http://offline.invalid", "model": "fixture", "revision": "fixed-1"},
|
|
35
|
+
"policy": policy}}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def payload(states=("a", "b"), *, mode="eager", program=generate, **options):
|
|
39
|
+
ir = compile_program(program).to_dict()
|
|
40
|
+
return {"protocol_version": 1, "ir": ir, "ir_sha256": ir_sha256(ir), "mode": mode,
|
|
41
|
+
"inputs": [{"index": i * 2, "state": value} for i, value in enumerate(states)], **options}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def input_keys(anno):
|
|
45
|
+
result = []
|
|
46
|
+
for key in anno.state:
|
|
47
|
+
result = result + [key]
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class StoreTests(unittest.TestCase):
|
|
52
|
+
def setUp(self):
|
|
53
|
+
self.folder = tempfile.TemporaryDirectory()
|
|
54
|
+
self.addCleanup(self.folder.cleanup)
|
|
55
|
+
self.path = Path(self.folder.name, "jobs.sqlite")
|
|
56
|
+
self.clock = Clock()
|
|
57
|
+
self.store = Store(self.path, clock=self.clock)
|
|
58
|
+
self.releases = Releases(configurations(), "test")
|
|
59
|
+
|
|
60
|
+
def submit(self, states=("a", "b"), key="job", **options):
|
|
61
|
+
return self.store.submit("alice", key, payload(states, **options), self.releases)
|
|
62
|
+
|
|
63
|
+
def claim(self):
|
|
64
|
+
return self.store.claim("worker", self.releases)
|
|
65
|
+
|
|
66
|
+
def test_repeated_and_concurrent_idempotency_and_principal_isolation(self):
|
|
67
|
+
request = payload()
|
|
68
|
+
with ThreadPoolExecutor(max_workers=6) as pool:
|
|
69
|
+
jobs = list(pool.map(lambda _: self.store.submit("alice", "same", request, self.releases), range(12)))
|
|
70
|
+
self.assertEqual(len({j["job_id"] for j in jobs}), 1)
|
|
71
|
+
bob = self.store.submit("bob", "same", request, self.releases)
|
|
72
|
+
self.assertNotEqual(bob["job_id"], jobs[0]["job_id"])
|
|
73
|
+
with self.assertRaises(ServiceError) as caught:
|
|
74
|
+
self.store.status("bob", jobs[0]["job_id"])
|
|
75
|
+
self.assertEqual(caught.exception.status, 404)
|
|
76
|
+
with self.assertRaises(ServiceError) as caught:
|
|
77
|
+
self.store.submit("alice", "same", payload(("changed",)), self.releases)
|
|
78
|
+
self.assertEqual(caught.exception.code, "idempotency_conflict")
|
|
79
|
+
|
|
80
|
+
def test_new_default_does_not_rebind_repeated_submission(self):
|
|
81
|
+
original = self.submit()
|
|
82
|
+
config = configurations()
|
|
83
|
+
config["test"]["generation"]["revision"] = "fixed-2"
|
|
84
|
+
changed = Releases(config, "test")
|
|
85
|
+
old = self.store.submit("alice", "job", payload(), changed)
|
|
86
|
+
self.assertEqual(old["release_id"], original["release_id"])
|
|
87
|
+
self.clock.now += 1
|
|
88
|
+
fresh = self.store.submit("alice", "new", payload(), changed)
|
|
89
|
+
self.assertNotEqual(fresh["release_id"], original["release_id"])
|
|
90
|
+
self.store.claim("worker", changed)
|
|
91
|
+
self.assertEqual(self.store.status("alice", original["job_id"])["pause_reason"]["code"], "release_unavailable")
|
|
92
|
+
|
|
93
|
+
def test_profile_normalization_version_binding_and_unavailable_release(self):
|
|
94
|
+
from annoscript.server.releases import build_sha256
|
|
95
|
+
original = self.submit()
|
|
96
|
+
config = configurations()
|
|
97
|
+
config["test"]["generation"]["profile"] = "legacy"
|
|
98
|
+
self.assertEqual(Releases(config, "test").default, self.releases.default)
|
|
99
|
+
config["test"]["generation"]["profile"] = "qwen_vllm"
|
|
100
|
+
changed = Releases(config, "test")
|
|
101
|
+
self.assertNotEqual(changed.default, self.releases.default)
|
|
102
|
+
replay = self.store.submit("alice", "job", payload(), changed)
|
|
103
|
+
self.assertEqual(replay["release_id"], original["release_id"])
|
|
104
|
+
self.assertIsNone(self.store.claim("worker", changed))
|
|
105
|
+
self.assertEqual(self.store.status("alice", original["job_id"])["pause_reason"]["code"], "release_unavailable")
|
|
106
|
+
config["test"]["disable_thinking"] = False
|
|
107
|
+
self.assertNotEqual(Releases(config, "test").default, changed.default)
|
|
108
|
+
# Grammar lives in source included by the existing build digest.
|
|
109
|
+
before = build_sha256()
|
|
110
|
+
read_bytes = Path.read_bytes
|
|
111
|
+
def changed_grammar(path):
|
|
112
|
+
content = read_bytes(path)
|
|
113
|
+
return content + b"\n# changed grammar\n" if path.name == "_grammar.py" else content
|
|
114
|
+
with patch.object(Path, "read_bytes", changed_grammar):
|
|
115
|
+
self.assertNotEqual(before, build_sha256())
|
|
116
|
+
|
|
117
|
+
def test_invalid_release_profile_rejected(self):
|
|
118
|
+
for profile in (None, "", "unknown", 1, False, []):
|
|
119
|
+
config = configurations()
|
|
120
|
+
config["test"]["generation"]["profile"] = profile
|
|
121
|
+
with self.subTest(profile=profile), self.assertRaises(ValueError):
|
|
122
|
+
Releases(config, "test")
|
|
123
|
+
config = configurations()
|
|
124
|
+
config["test"]["decision"]["profile"] = "qwen_vllm"
|
|
125
|
+
with self.assertRaises(ValueError):
|
|
126
|
+
Releases(config, "test")
|
|
127
|
+
|
|
128
|
+
def test_batch_configuration_digest_validation_and_existing_job_binding(self):
|
|
129
|
+
from annoscript.backends.base import LLM_BATCH_DEFAULTS
|
|
130
|
+
original = self.submit()
|
|
131
|
+
for name, default in LLM_BATCH_DEFAULTS.items():
|
|
132
|
+
config = configurations()
|
|
133
|
+
config["test"]["runtime"] = {name: default}
|
|
134
|
+
self.assertEqual(Releases(config, "test").default, self.releases.default)
|
|
135
|
+
config["test"]["runtime"][name] = 0 if name == "llm_batch_repair_attempts" else default + 1
|
|
136
|
+
changed = Releases(config, "test")
|
|
137
|
+
self.assertNotEqual(changed.default, self.releases.default)
|
|
138
|
+
self.assertEqual(self.store.submit("alice", "job", payload(), changed)["release_id"], original["release_id"])
|
|
139
|
+
for invalid in (True, -1, 1.5, None, 2 if name == "llm_batch_repair_attempts" else 0):
|
|
140
|
+
config["test"]["runtime"][name] = invalid
|
|
141
|
+
with self.subTest(name=name, invalid=invalid), self.assertRaises(ValueError):
|
|
142
|
+
Releases(config, "test")
|
|
143
|
+
|
|
144
|
+
def test_restart_reclaim_and_stale_result_fencing(self):
|
|
145
|
+
job = self.submit(("a",))
|
|
146
|
+
first = self.claim()
|
|
147
|
+
self.clock.now += 61
|
|
148
|
+
restarted = Store(self.path, clock=self.clock)
|
|
149
|
+
second = restarted.claim("replacement", self.releases)
|
|
150
|
+
self.assertEqual(second["attempt"], 2)
|
|
151
|
+
self.assertNotEqual(first["token"], second["token"])
|
|
152
|
+
self.assertFalse(self.store.finish(first, Result(0, "success", "stale")))
|
|
153
|
+
self.assertTrue(restarted.finish(second, Result(0, "success", None)))
|
|
154
|
+
page = restarted.results("alice", job["job_id"])
|
|
155
|
+
self.assertTrue(page["done"])
|
|
156
|
+
self.assertIsNone(page["results"][0]["value"])
|
|
157
|
+
self.assertEqual(page["results"][0]["attempts"], 2)
|
|
158
|
+
|
|
159
|
+
def test_crash_after_result_commit_never_reexecutes(self):
|
|
160
|
+
job = self.submit(("a",))
|
|
161
|
+
claim = self.claim()
|
|
162
|
+
self.store.finish(claim, Result(0, "success", {"saved": True}))
|
|
163
|
+
self.clock.now += 61
|
|
164
|
+
self.assertIsNone(Store(self.path, clock=self.clock).claim("replacement", self.releases))
|
|
165
|
+
self.assertEqual(self.store.lookup("alice", "job")["state"], "completed")
|
|
166
|
+
|
|
167
|
+
def test_result_pages_completion_order_and_cursor_validation(self):
|
|
168
|
+
job = self.submit()
|
|
169
|
+
first, second = self.claim(), self.claim()
|
|
170
|
+
empty = self.store.results("alice", job["job_id"])
|
|
171
|
+
self.assertFalse(empty["done"])
|
|
172
|
+
self.store.finish(second, Result(2, "success", [None, "中文"]))
|
|
173
|
+
page = self.store.results("alice", job["job_id"], limit=1)
|
|
174
|
+
validate_page(page, job["job_id"])
|
|
175
|
+
self.assertEqual(page["results"][0]["index"], 2)
|
|
176
|
+
self.assertFalse(page["done"])
|
|
177
|
+
self.store.finish(first, Result(0, "error", error=ResultError("invalid_response", "invalid")))
|
|
178
|
+
final = self.store.results("alice", job["job_id"], page["next_cursor"])
|
|
179
|
+
validate_page(final, job["job_id"], page["next_cursor"])
|
|
180
|
+
self.assertTrue(final["done"])
|
|
181
|
+
self.assertEqual(final["results"][0]["index"], 0)
|
|
182
|
+
self.assertEqual(final, self.store.results("alice", job["job_id"], page["next_cursor"]))
|
|
183
|
+
for cursor in ("bad", cursor_for("another", 0), cursor_for(job["job_id"], 100)):
|
|
184
|
+
with self.assertRaises(ServiceError):
|
|
185
|
+
self.store.results("alice", job["job_id"], cursor)
|
|
186
|
+
|
|
187
|
+
def test_retry_exhaustion_pause_and_idempotent_resume(self):
|
|
188
|
+
job = self.submit()
|
|
189
|
+
first, second = self.claim(), self.claim()
|
|
190
|
+
self.store.finish(second, Result(2, "success", "kept"))
|
|
191
|
+
error = {"code": "model_timeout", "message": "temporary"}
|
|
192
|
+
for n in range(3):
|
|
193
|
+
self.store.attempt_failed(first, error)
|
|
194
|
+
if n < 2:
|
|
195
|
+
first = self.claim()
|
|
196
|
+
paused = self.store.status("alice", job["job_id"])
|
|
197
|
+
self.assertEqual(paused["state"], "paused")
|
|
198
|
+
self.assertEqual(paused["counts"]["completed"], 1)
|
|
199
|
+
self.assertIsNone(self.claim())
|
|
200
|
+
self.store.resume("alice", job["job_id"], "resume", paused["pause_generation"], self.releases)
|
|
201
|
+
final = self.claim()
|
|
202
|
+
self.assertEqual((final["index"], final["attempt"], final["round_attempt"]), (0, 4, 1))
|
|
203
|
+
# Repeating a lost resume response cannot grant another retry budget.
|
|
204
|
+
self.store.resume("alice", job["job_id"], "resume", paused["pause_generation"], self.releases)
|
|
205
|
+
self.store.finish(final, Result(0, "success", "fixed"))
|
|
206
|
+
self.assertEqual(self.store.status("alice", job["job_id"])["state"], "completed")
|
|
207
|
+
with self.assertRaises(ServiceError):
|
|
208
|
+
self.store.resume("alice", job["job_id"], "new-resume", paused["pause_generation"], self.releases)
|
|
209
|
+
|
|
210
|
+
def test_abandoned_attempts_consume_budget(self):
|
|
211
|
+
job = self.submit(("a",))
|
|
212
|
+
for n in range(3):
|
|
213
|
+
self.assertIsNotNone(self.claim())
|
|
214
|
+
self.clock.now += 61
|
|
215
|
+
self.store.maintain()
|
|
216
|
+
self.assertIsNone(self.claim())
|
|
217
|
+
self.assertEqual(self.store.status("alice", job["job_id"])["state"], "paused")
|
|
218
|
+
|
|
219
|
+
def test_cancellation_preserves_results_and_rejects_late_worker(self):
|
|
220
|
+
job = self.submit()
|
|
221
|
+
first, second = self.claim(), self.claim()
|
|
222
|
+
self.store.finish(first, Result(0, "success", "done"))
|
|
223
|
+
cancelled = self.store.cancel("alice", job["job_id"])
|
|
224
|
+
self.assertEqual(cancelled["state"], "cancelled")
|
|
225
|
+
self.assertFalse(self.store.finish(second, Result(2, "success", "late")))
|
|
226
|
+
rows = self.store.results("alice", job["job_id"])["results"]
|
|
227
|
+
self.assertEqual(rows[0]["value"], "done")
|
|
228
|
+
self.assertEqual(rows[1]["error"]["code"], "cancelled")
|
|
229
|
+
self.assertEqual(cancelled, self.store.cancel("alice", job["job_id"]))
|
|
230
|
+
|
|
231
|
+
def test_session_close_catches_unknown_job_and_prevents_late_submit(self):
|
|
232
|
+
session = self.store.create_session("alice", "session")
|
|
233
|
+
self.assertEqual(session, self.store.create_session("alice", "session"))
|
|
234
|
+
job = self.submit(session_id=session["session_id"])
|
|
235
|
+
claim = self.claim()
|
|
236
|
+
self.store.close_session("alice", session["session_id"])
|
|
237
|
+
self.assertEqual(self.store.lookup("alice", "job")["state"], "cancelled")
|
|
238
|
+
self.assertFalse(self.store.renew(claim))
|
|
239
|
+
with self.assertRaises(ServiceError):
|
|
240
|
+
self.submit(key="late", session_id=session["session_id"])
|
|
241
|
+
with self.assertRaises(ServiceError):
|
|
242
|
+
self.store.heartbeat_session("alice", session["session_id"])
|
|
243
|
+
self.assertEqual(self.store.create_session("alice", "session")["state"], "closed")
|
|
244
|
+
|
|
245
|
+
def test_session_expiry_cancels_even_after_process_restart(self):
|
|
246
|
+
session = self.store.create_session("alice", "session")
|
|
247
|
+
job = self.submit(session_id=session["session_id"])
|
|
248
|
+
self.clock.now += 200
|
|
249
|
+
self.store.heartbeat_session("alice", session["session_id"])
|
|
250
|
+
self.clock.now += 200
|
|
251
|
+
self.assertEqual(self.store.status("alice", job["job_id"])["state"], "queued")
|
|
252
|
+
self.clock.now += 101
|
|
253
|
+
restarted = Store(self.path, clock=self.clock)
|
|
254
|
+
self.assertIsNone(restarted.claim("worker", self.releases))
|
|
255
|
+
rows = restarted.results("alice", job["job_id"])["results"]
|
|
256
|
+
self.assertEqual({row["error"]["code"] for row in rows}, {"session_expired"})
|
|
257
|
+
|
|
258
|
+
def test_close_submit_race_leaves_no_runnable_orphan(self):
|
|
259
|
+
session = self.store.create_session("alice", "session")
|
|
260
|
+
def submit():
|
|
261
|
+
try:
|
|
262
|
+
return self.submit(session_id=session["session_id"])
|
|
263
|
+
except ServiceError as error:
|
|
264
|
+
self.assertEqual(error.code, "session_closed")
|
|
265
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
266
|
+
a = pool.submit(submit)
|
|
267
|
+
b = pool.submit(self.store.close_session, "alice", session["session_id"])
|
|
268
|
+
a.result(), b.result()
|
|
269
|
+
self.assertIsNone(self.claim())
|
|
270
|
+
|
|
271
|
+
def test_retention_tombstone_and_paused_job_retention(self):
|
|
272
|
+
job = self.submit()
|
|
273
|
+
self.store.cancel("alice", job["job_id"])
|
|
274
|
+
other = self.submit(("c",), key="paused")
|
|
275
|
+
self.store.attempt_failed(self.claim(), {"code": "authentication_failed", "message": "bad config"}, pause=True)
|
|
276
|
+
self.clock.now += 8 * 86400
|
|
277
|
+
for operation in (lambda: self.store.status("alice", job["job_id"]),
|
|
278
|
+
lambda: self.submit(), lambda: self.store.lookup("alice", "job")):
|
|
279
|
+
with self.assertRaises(ServiceError) as error:
|
|
280
|
+
operation()
|
|
281
|
+
self.assertEqual(error.exception.status, 410)
|
|
282
|
+
self.assertEqual(self.store.status("alice", other["job_id"])["state"], "paused")
|
|
283
|
+
|
|
284
|
+
def test_schema_and_input_structure_rejected(self):
|
|
285
|
+
with closing(sqlite3.connect(self.path)) as db:
|
|
286
|
+
db.execute("PRAGMA user_version=99")
|
|
287
|
+
with self.assertRaises(RuntimeError):
|
|
288
|
+
Store(self.path)
|
|
289
|
+
with closing(sqlite3.connect(self.path)) as db:
|
|
290
|
+
db.execute("PRAGMA user_version=2")
|
|
291
|
+
bad = payload()
|
|
292
|
+
bad["inputs"][1]["index"] = 0
|
|
293
|
+
with self.assertRaises(ValueError):
|
|
294
|
+
self.store.submit("alice", "bad", bad, self.releases)
|
|
295
|
+
|
|
296
|
+
def test_concurrent_database_initialization_is_atomic(self):
|
|
297
|
+
path = Path(self.folder.name, "new.sqlite")
|
|
298
|
+
with ThreadPoolExecutor(max_workers=6) as pool:
|
|
299
|
+
stores = list(pool.map(lambda _: Store(path, clock=self.clock), range(6)))
|
|
300
|
+
self.assertEqual(len(stores), 6)
|
|
301
|
+
self.assertEqual(stores[0].create_session("alice", "key"), stores[-1].create_session("alice", "key"))
|
|
302
|
+
|
|
303
|
+
def test_initialization_recovers_from_transient_journal_busy(self):
|
|
304
|
+
connect = sqlite3.connect
|
|
305
|
+
|
|
306
|
+
class BusyOnce(sqlite3.Connection):
|
|
307
|
+
def execute(self, statement, *args, **kwargs):
|
|
308
|
+
if statement == "PRAGMA journal_mode=WAL" and not getattr(self, "injected", False):
|
|
309
|
+
self.injected = True
|
|
310
|
+
error = sqlite3.OperationalError("database is locked")
|
|
311
|
+
error.sqlite_errorcode = sqlite3.SQLITE_BUSY | (1 << 8)
|
|
312
|
+
raise error
|
|
313
|
+
return super().execute(statement, *args, **kwargs)
|
|
314
|
+
|
|
315
|
+
with patch("annoscript.server.store.sqlite3.connect",
|
|
316
|
+
side_effect=lambda *a, **kw: connect(*a, factory=BusyOnce, **kw)):
|
|
317
|
+
store = Store(Path(self.folder.name, "busy.sqlite"), clock=self.clock)
|
|
318
|
+
first = store.create_session("alice", "stable-key")
|
|
319
|
+
self.assertEqual(first, store.create_session("alice", "stable-key"))
|
|
320
|
+
|
|
321
|
+
def test_initialization_preserves_non_busy_database_error(self):
|
|
322
|
+
connect = sqlite3.connect
|
|
323
|
+
error = sqlite3.OperationalError("injected storage error")
|
|
324
|
+
error.sqlite_errorcode = sqlite3.SQLITE_IOERR
|
|
325
|
+
attempts = []
|
|
326
|
+
|
|
327
|
+
class Broken(sqlite3.Connection):
|
|
328
|
+
def execute(self, statement, *args, **kwargs):
|
|
329
|
+
if statement == "PRAGMA journal_mode=WAL":
|
|
330
|
+
attempts.append(statement)
|
|
331
|
+
raise error
|
|
332
|
+
return super().execute(statement, *args, **kwargs)
|
|
333
|
+
|
|
334
|
+
with patch("annoscript.server.store.sqlite3.connect",
|
|
335
|
+
side_effect=lambda *a, **kw: connect(*a, factory=Broken, **kw)):
|
|
336
|
+
with self.assertRaises(sqlite3.OperationalError) as caught:
|
|
337
|
+
Store(Path(self.folder.name, "broken.sqlite"), clock=self.clock)
|
|
338
|
+
self.assertIs(caught.exception, error)
|
|
339
|
+
self.assertEqual(len(attempts), 1)
|
|
340
|
+
|
|
341
|
+
def test_input_key_order_conflicts_even_when_values_match(self):
|
|
342
|
+
self.submit(({"a": 1, "b": 2},))
|
|
343
|
+
with self.assertRaises(ServiceError) as error:
|
|
344
|
+
self.submit(({"b": 2, "a": 1},))
|
|
345
|
+
self.assertEqual(error.exception.code, "idempotency_conflict")
|
|
346
|
+
|
|
347
|
+
def test_foreign_session_and_foreign_resume_are_rejected(self):
|
|
348
|
+
session = self.store.create_session("bob", "session")
|
|
349
|
+
with self.assertRaises(ServiceError) as error:
|
|
350
|
+
self.submit(session_id=session["session_id"])
|
|
351
|
+
self.assertEqual(error.exception.status, 404)
|
|
352
|
+
job = self.submit(("a",))
|
|
353
|
+
self.store.attempt_failed(self.claim(), {"code": "model_timeout", "message": "timeout"}, pause=True)
|
|
354
|
+
with self.assertRaises(ServiceError) as error:
|
|
355
|
+
self.store.resume("bob", job["job_id"], "resume", 1, self.releases)
|
|
356
|
+
self.assertEqual(error.exception.status, 404)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class ExecutionTests(unittest.TestCase):
|
|
360
|
+
def setUp(self):
|
|
361
|
+
self.folder = tempfile.TemporaryDirectory()
|
|
362
|
+
self.addCleanup(self.folder.cleanup)
|
|
363
|
+
self.clock = Clock()
|
|
364
|
+
self.store = Store(Path(self.folder.name, "jobs.sqlite"), clock=self.clock)
|
|
365
|
+
self.releases = Releases(configurations(), "test")
|
|
366
|
+
self.http = TestClient(create_app(self.store, self.releases, {"test-token": "alice"}))
|
|
367
|
+
self.addCleanup(self.http.close)
|
|
368
|
+
|
|
369
|
+
def client(self):
|
|
370
|
+
def handle(request):
|
|
371
|
+
return self.http.request(request.method, str(request.url), content=request.content, headers=request.headers)
|
|
372
|
+
client = AnnoJobClient("http://testserver", "test-token", transport=httpx.MockTransport(handle))
|
|
373
|
+
self.addCleanup(client.close)
|
|
374
|
+
return client
|
|
375
|
+
|
|
376
|
+
def run_claim(self, factory=None):
|
|
377
|
+
if factory is None:
|
|
378
|
+
def factory(manifest):
|
|
379
|
+
backend = FakeBackend()
|
|
380
|
+
return backend, backend
|
|
381
|
+
worker = Worker(self.store, self.releases, backend_factory=factory, random_source=lambda: 0)
|
|
382
|
+
claim = self.store.claim("worker", self.releases)
|
|
383
|
+
self.assertIsNotNone(claim)
|
|
384
|
+
asyncio.run(worker.execute(claim))
|
|
385
|
+
return claim
|
|
386
|
+
|
|
387
|
+
def test_http_ir_deserialization_runtime_and_indexed_results(self):
|
|
388
|
+
client = self.client()
|
|
389
|
+
ir = compile_program(generate)
|
|
390
|
+
for mode in ("eager", "strict"):
|
|
391
|
+
job = client.submit_ir(ir, [{"index": 7, "state": "hello"}], idempotency_key=mode, mode=mode)
|
|
392
|
+
self.run_claim()
|
|
393
|
+
page = client.fetch_results(job)
|
|
394
|
+
self.assertTrue(page["done"])
|
|
395
|
+
self.assertEqual((page["results"][0]["index"], page["results"][0]["value"]), (7, "hello"))
|
|
396
|
+
self.assertEqual(client.lookup(mode)["job_id"], job["job_id"])
|
|
397
|
+
|
|
398
|
+
def test_input_object_iteration_order_survives_http_journal_and_runtime(self):
|
|
399
|
+
client = self.client()
|
|
400
|
+
job = client.submit_ir(compile_program(input_keys), [
|
|
401
|
+
{"index": 0, "state": {"a": 1, "b": 2}},
|
|
402
|
+
{"index": 1, "state": {"b": 2, "a": 1}},
|
|
403
|
+
], idempotency_key="ordered-keys")
|
|
404
|
+
self.run_claim()
|
|
405
|
+
self.run_claim()
|
|
406
|
+
self.assertEqual([r["value"] for r in client.fetch_results(job)["results"]], [["a", "b"], ["b", "a"]])
|
|
407
|
+
|
|
408
|
+
def test_http_auth_bad_json_version_digest_and_limits(self):
|
|
409
|
+
response = self.http.get("/v1/jobs/unknown")
|
|
410
|
+
self.assertEqual(response.status_code, 401)
|
|
411
|
+
headers = {"Authorization": "Bearer test-token", "Content-Type": "application/json", "Idempotency-Key": "bad"}
|
|
412
|
+
for content in (b'{"duplicate":1,"duplicate":2}', b'{"x":NaN}', b'invalid'):
|
|
413
|
+
self.assertEqual(self.http.post("/v1/jobs", content=content, headers=headers).status_code, 400)
|
|
414
|
+
for field, value in (("protocol_version", 2), ("ir_sha256", "wrong")):
|
|
415
|
+
request = payload()
|
|
416
|
+
request[field] = value
|
|
417
|
+
self.assertEqual(self.http.post("/v1/jobs", json=request, headers=headers).status_code, 400)
|
|
418
|
+
oversized = payload(["a"] * 501)
|
|
419
|
+
self.assertEqual(self.http.post("/v1/jobs", json=oversized, headers=headers).status_code, 413)
|
|
420
|
+
|
|
421
|
+
def test_transient_failures_pause_and_can_be_resumed(self):
|
|
422
|
+
client = self.client()
|
|
423
|
+
job = client.submit_ir(compile_program(generate), [{"index": 0, "state": "hello"}], idempotency_key="retry")
|
|
424
|
+
class Failing(FakeBackend):
|
|
425
|
+
async def infer(self, *args):
|
|
426
|
+
raise InferenceError("limited", code="model_http_error", metadata={"http_status": 429, "retry_after": "2"})
|
|
427
|
+
for n in range(3):
|
|
428
|
+
self.run_claim(lambda _: (Failing(), Failing()))
|
|
429
|
+
self.clock.now += 2
|
|
430
|
+
paused = client.status(job)
|
|
431
|
+
self.assertEqual(paused["state"], "paused")
|
|
432
|
+
self.assertEqual(client.fetch_results(job)["results"], [])
|
|
433
|
+
client.resume(job, pause_generation=paused["pause_generation"], idempotency_key="resume")
|
|
434
|
+
self.run_claim()
|
|
435
|
+
self.assertEqual(client.fetch_results(job)["results"][0]["attempts"], 4)
|
|
436
|
+
|
|
437
|
+
def test_real_http_backend_retry_metadata_reaches_journal(self):
|
|
438
|
+
from annoscript.backends.llm import LLMAPIBackend
|
|
439
|
+
client = self.client()
|
|
440
|
+
job = client.submit_ir(compile_program(generate), [{"index": 0, "state": "hello"}], idempotency_key="headers")
|
|
441
|
+
def factory(manifest):
|
|
442
|
+
llm = LLMAPIBackend("http://offline.invalid", "fixture-token", "fixture-model", profile="qwen_vllm",
|
|
443
|
+
transport=httpx.MockTransport(lambda request: httpx.Response(429, json={}, headers={"Retry-After": "7"})))
|
|
444
|
+
return FakeBackend(), llm
|
|
445
|
+
self.run_claim(factory)
|
|
446
|
+
self.assertIsNone(self.store.claim("worker", self.releases))
|
|
447
|
+
with closing(sqlite3.connect(self.store.path)) as db:
|
|
448
|
+
data = json.loads(db.execute("SELECT metadata FROM attempts WHERE job_id=?", (job["job_id"],)).fetchone()[0])
|
|
449
|
+
self.assertEqual(data, {"http_status": 429, "retry_after": "7"})
|
|
450
|
+
self.clock.now += 7
|
|
451
|
+
self.run_claim()
|
|
452
|
+
self.assertTrue(client.fetch_results(job)["done"])
|
|
453
|
+
|
|
454
|
+
def test_worker_uses_bound_profile_despite_new_default_and_environment(self):
|
|
455
|
+
from annoscript.backends.llm import LLMAPIBackend
|
|
456
|
+
from annoscript.server.worker import backends
|
|
457
|
+
config = configurations()
|
|
458
|
+
config["test"]["generation"]["profile"] = "qwen_vllm"
|
|
459
|
+
qwen = Releases(config, "test")
|
|
460
|
+
job = self.store.submit("alice", "qwen", payload(("hello",)), qwen)
|
|
461
|
+
# Both releases remain available; the server's new default is legacy.
|
|
462
|
+
self.releases.manifests.update(qwen.manifests)
|
|
463
|
+
bodies = []
|
|
464
|
+
def handle(request):
|
|
465
|
+
body = json.loads(request.content)
|
|
466
|
+
bodies.append(body)
|
|
467
|
+
return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": "hello"}}]})
|
|
468
|
+
def create(*args, **kwargs):
|
|
469
|
+
return LLMAPIBackend(*args, **kwargs, transport=httpx.MockTransport(handle))
|
|
470
|
+
with patch.dict("os.environ", {"ANNO_DECISION_AUTH_TOKEN": "fixture", "ANNO_GENERATE_AUTH_TOKEN": "fixture",
|
|
471
|
+
"ANNO_GENERATE_PROFILE": "legacy"}),\
|
|
472
|
+
patch("annoscript.backends.llm.LLMAPIBackend", side_effect=create):
|
|
473
|
+
claim = self.run_claim(backends)
|
|
474
|
+
self.assertEqual(claim["release_id"], job["release_id"])
|
|
475
|
+
self.assertEqual(bodies[0]["chat_template_kwargs"], {"enable_thinking": False})
|
|
476
|
+
self.assertNotIn("thinking", bodies[0])
|
|
477
|
+
self.assertEqual(len(bodies), 1)
|
|
478
|
+
self.assertTrue(self.store.results("alice", job["job_id"])["done"])
|
|
479
|
+
|
|
480
|
+
def test_cancelled_attempt_cannot_start_another_model_call(self):
|
|
481
|
+
from annoscript.server.worker import ObservedBackend
|
|
482
|
+
backend = FakeBackend()
|
|
483
|
+
observed = ObservedBackend(backend, [], lambda: False)
|
|
484
|
+
async def run():
|
|
485
|
+
with self.assertRaises(asyncio.CancelledError):
|
|
486
|
+
await observed.infer(None, None)
|
|
487
|
+
with self.assertRaises(asyncio.CancelledError):
|
|
488
|
+
await observed.infer_batch(None, None)
|
|
489
|
+
asyncio.run(run())
|
|
490
|
+
self.assertEqual(backend.calls, [])
|
|
491
|
+
|
|
492
|
+
def test_auth_failure_pauses_immediately_and_invalid_response_is_terminal(self):
|
|
493
|
+
client = self.client()
|
|
494
|
+
class Auth(FakeBackend):
|
|
495
|
+
async def infer(self, *args):
|
|
496
|
+
raise BackendFatalError("bad token", code="authentication_failed")
|
|
497
|
+
job = client.submit_ir(compile_program(generate), [{"index": 0, "state": "a"}], idempotency_key="auth")
|
|
498
|
+
self.run_claim(lambda _: (Auth(), Auth()))
|
|
499
|
+
self.assertEqual(client.status(job)["state"], "paused")
|
|
500
|
+
client.cancel(job)
|
|
501
|
+
class Invalid(FakeBackend):
|
|
502
|
+
async def infer(self, *args):
|
|
503
|
+
raise InferenceError("invalid output", code="invalid_response")
|
|
504
|
+
job = client.submit_ir(compile_program(generate), [{"index": 0, "state": "a"}], idempotency_key="invalid")
|
|
505
|
+
self.run_claim(lambda _: (Invalid(), Invalid()))
|
|
506
|
+
result = client.fetch_results(job)["results"][0]
|
|
507
|
+
self.assertEqual((result["error"]["code"], result["attempts"]), ("invalid_response", 1))
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
if __name__ == "__main__":
|
|
511
|
+
unittest.main()
|