seqevi 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (90) hide show
  1. {seqevi-0.2.0 → seqevi-0.3.0}/PKG-INFO +6 -3
  2. {seqevi-0.2.0 → seqevi-0.3.0}/README.md +5 -2
  3. {seqevi-0.2.0 → seqevi-0.3.0}/pyproject.toml +1 -1
  4. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/__init__.py +1 -1
  5. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/annotate.py +252 -31
  6. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/cli.py +204 -1
  7. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/distribution/oci.py +5 -0
  8. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/errors.py +12 -0
  9. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/evidence.py +99 -1
  10. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/kits/dbcan-cazyme.toml +1 -1
  11. seqevi-0.3.0/src/seqevi/runner.py +571 -0
  12. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/service/__init__.py +2 -1
  13. seqevi-0.3.0/src/seqevi/service/app.py +695 -0
  14. seqevi-0.3.0/src/seqevi/service/config.py +82 -0
  15. seqevi-0.3.0/src/seqevi/service/persistence.py +1508 -0
  16. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/store/__init__.py +9 -1
  17. seqevi-0.3.0/src/seqevi/store/_artifact_reader.py +47 -0
  18. seqevi-0.3.0/src/seqevi/store/_resolver.py +35 -0
  19. seqevi-0.3.0/src/seqevi/store/client.py +1426 -0
  20. seqevi-0.3.0/src/seqevi/store/contract.py +77 -0
  21. seqevi-0.3.0/src/seqevi/store/local.py +1118 -0
  22. seqevi-0.3.0/src/seqevi/store/migration.py +1471 -0
  23. seqevi-0.3.0/src/seqevi/store/migrations/versions/0003_evidence_claim_leases.py +59 -0
  24. seqevi-0.3.0/src/seqevi/store/migrations/versions/0004_claim_sessions.py +89 -0
  25. seqevi-0.3.0/src/seqevi/store/schema.py +212 -0
  26. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/store/transport.py +167 -0
  27. seqevi-0.3.0/tests/test_acceptance_harnesses.py +1083 -0
  28. seqevi-0.3.0/tests/test_annotate.py +880 -0
  29. seqevi-0.3.0/tests/test_architecture_contracts.py +11 -0
  30. seqevi-0.3.0/tests/test_claim_leases.py +131 -0
  31. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_cli.py +65 -1
  32. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_dbcan_image_assets.py +2 -0
  33. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_deployment_assets.py +19 -0
  34. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_local_store.py +222 -2
  35. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_managed_setup.py +2 -2
  36. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_oci.py +3 -1
  37. seqevi-0.3.0/tests/test_runner.py +830 -0
  38. seqevi-0.3.0/tests/test_shared_store.py +6593 -0
  39. seqevi-0.2.0/src/seqevi/runner.py +0 -163
  40. seqevi-0.2.0/src/seqevi/service/app.py +0 -213
  41. seqevi-0.2.0/src/seqevi/service/config.py +0 -38
  42. seqevi-0.2.0/src/seqevi/service/persistence.py +0 -360
  43. seqevi-0.2.0/src/seqevi/store/client.py +0 -311
  44. seqevi-0.2.0/src/seqevi/store/contract.py +0 -33
  45. seqevi-0.2.0/src/seqevi/store/local.py +0 -479
  46. seqevi-0.2.0/src/seqevi/store/migration.py +0 -62
  47. seqevi-0.2.0/src/seqevi/store/schema.py +0 -86
  48. seqevi-0.2.0/tests/test_annotate.py +0 -393
  49. seqevi-0.2.0/tests/test_runner.py +0 -81
  50. seqevi-0.2.0/tests/test_shared_store.py +0 -649
  51. {seqevi-0.2.0 → seqevi-0.3.0}/LICENSE +0 -0
  52. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/__main__.py +0 -0
  53. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/adapters/__init__.py +0 -0
  54. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/adapters/base.py +0 -0
  55. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/adapters/dbcan_cazyme.py +0 -0
  56. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/adapters/eggnog.py +0 -0
  57. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/adapters/interpro_pfam.py +0 -0
  58. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/adapters/registry.py +0 -0
  59. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/api.py +0 -0
  60. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/distribution/__init__.py +0 -0
  61. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/distribution/manifest.py +0 -0
  62. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/distribution/setup.py +0 -0
  63. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/execution_profile.py +0 -0
  64. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/hashing.py +0 -0
  65. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/kits/__init__.py +0 -0
  66. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/resource_lock.py +0 -0
  67. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/result.py +0 -0
  68. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/runtime_identity.py +0 -0
  69. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/sequence.py +0 -0
  70. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/store/artifact.py +0 -0
  71. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/store/factory.py +0 -0
  72. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/store/migrations/__init__.py +0 -0
  73. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/store/migrations/env.py +0 -0
  74. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/store/migrations/versions/0001_initial_store.py +0 -0
  75. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/store/migrations/versions/0002_artifact_byte_size_bigint.py +0 -0
  76. {seqevi-0.2.0 → seqevi-0.3.0}/src/seqevi/store/migrations/versions/__init__.py +0 -0
  77. {seqevi-0.2.0 → seqevi-0.3.0}/tests/__init__.py +0 -0
  78. {seqevi-0.2.0 → seqevi-0.3.0}/tests/support.py +0 -0
  79. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_api.py +0 -0
  80. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_artifact_store.py +0 -0
  81. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_dbcan_cazyme.py +0 -0
  82. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_eggnog.py +0 -0
  83. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_evidence.py +0 -0
  84. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_execution_profile.py +0 -0
  85. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_incremental_reuse.py +0 -0
  86. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_interpro_pfam.py +0 -0
  87. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_resource_lock.py +0 -0
  88. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_result_prototype.py +0 -0
  89. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_runtime_identity.py +0 -0
  90. {seqevi-0.2.0 → seqevi-0.3.0}/tests/test_sequence.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seqevi
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Content-addressed, reusable protein sequence annotation evidence.
5
5
  Author-Email: Fuqing Zhang <fu.qing.zhang.work@gmail.com>, FuqingZhang <103730099+FuqingZh@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -45,11 +45,11 @@ single-file DuckDB result for the current FASTA.
45
45
  ## Status
46
46
 
47
47
  The target architecture and v1.1 result contracts are approved. The SeqEvi
48
- 0.2.0 source tree uses strict protein sequence identity, the single-host
48
+ 0.3.0 source tree uses strict protein sequence identity, the single-host
49
49
  SQLite/POSIX Store, the external tool runner, exact cache-miss orchestration,
50
50
  and atomic DuckDB result materialization.
51
51
 
52
- SeqEvi 0.2.0 provides managed setup for dbCAN only. The `eggnog` and
52
+ SeqEvi 0.3.0 provides managed setup for dbCAN only. The `eggnog` and
53
53
  `interpro-pfam` adapters remain supported through explicit runtimes and named
54
54
  host profiles; managed setup for them is later feature work. The
55
55
  [Slice D gate record](docs/benchmarks/20260806-v1.4-dbcan-public-release-gate.md)
@@ -208,6 +208,9 @@ seqevi annotate \
208
208
 
209
209
  Shared deployments expose the same Store contract:
210
210
 
211
+ The shared Store requires PostgreSQL 17 or newer so every mutation can enforce
212
+ one cumulative transaction deadline inside the claim lease runway.
213
+
211
214
  ```bash
212
215
  seqevi serve \
213
216
  --database-url postgresql+psycopg://seqevi@postgres/seqevi \
@@ -11,11 +11,11 @@ single-file DuckDB result for the current FASTA.
11
11
  ## Status
12
12
 
13
13
  The target architecture and v1.1 result contracts are approved. The SeqEvi
14
- 0.2.0 source tree uses strict protein sequence identity, the single-host
14
+ 0.3.0 source tree uses strict protein sequence identity, the single-host
15
15
  SQLite/POSIX Store, the external tool runner, exact cache-miss orchestration,
16
16
  and atomic DuckDB result materialization.
17
17
 
18
- SeqEvi 0.2.0 provides managed setup for dbCAN only. The `eggnog` and
18
+ SeqEvi 0.3.0 provides managed setup for dbCAN only. The `eggnog` and
19
19
  `interpro-pfam` adapters remain supported through explicit runtimes and named
20
20
  host profiles; managed setup for them is later feature work. The
21
21
  [Slice D gate record](docs/benchmarks/20260806-v1.4-dbcan-public-release-gate.md)
@@ -174,6 +174,9 @@ seqevi annotate \
174
174
 
175
175
  Shared deployments expose the same Store contract:
176
176
 
177
+ The shared Store requires PostgreSQL 17 or newer so every mutation can enforce
178
+ one cumulative transaction deadline inside the claim lease runway.
179
+
177
180
  ```bash
178
181
  seqevi serve \
179
182
  --database-url postgresql+psycopg://seqevi@postgres/seqevi \
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "seqevi"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Content-addressed, reusable protein sequence annotation evidence."
5
5
  authors = [
6
6
  { name = "Fuqing Zhang", email = "fu.qing.zhang.work@gmail.com" },
@@ -1,6 +1,6 @@
1
1
  """Content-addressed protein sequence annotation evidence."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
4
4
 
5
5
  from .api import annotate, scan_annotations
6
6
 
@@ -2,12 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import shutil
6
7
  import sys
7
8
  import tempfile
9
+ import threading
8
10
  import time
9
- from dataclasses import dataclass
10
11
  from collections.abc import Mapping
12
+ from dataclasses import dataclass
11
13
  from datetime import UTC, datetime
12
14
  from itertools import batched
13
15
  from pathlib import Path
@@ -17,13 +19,15 @@ from . import __version__
17
19
  from .adapters.base import AdapterBatchResult, AnnotationAdapter
18
20
  from .errors import AnnotationError, OutputPackageError
19
21
  from .evidence import (
22
+ ClaimDisposition,
23
+ CommitOutcome,
20
24
  EvidenceCommit,
21
25
  EvidenceQuery,
22
26
  EvidenceSource,
23
27
  EvidenceStatus,
24
28
  )
25
29
  from .result import RESULT_FORMAT_VERSION, materialize_result_database
26
- from .runner import ToolCommand, ToolRunResult, ToolRunner
30
+ from .runner import ToolCancelledError, ToolCommand, ToolRunResult, ToolRunner
27
31
  from .sequence import (
28
32
  SequenceIdentity,
29
33
  iter_staged_identities,
@@ -31,15 +35,25 @@ from .sequence import (
31
35
  iter_fasta_lines,
32
36
  stage_fasta,
33
37
  )
34
- from .store.contract import EvidenceStore
38
+ from .store.contract import (
39
+ ClaimSession,
40
+ EvidenceStore,
41
+ is_claim_session_capable_store,
42
+ )
35
43
 
36
44
  _STORE_BATCH_SIZE = 1_000
37
45
  _ANNOTATION_BATCH_SIZE = 10_000
46
+ _CLAIM_RUNWAY_SECONDS = 5.0
38
47
 
39
48
 
40
49
  @dataclass(frozen=True, slots=True)
41
50
  class AnnotationMetrics:
42
- """Operational measurements for one successful annotation invocation."""
51
+ """Operational measurements for one successful annotation invocation.
52
+
53
+ ``existing_finalizations`` counts proposed terminal records that a
54
+ ClaimSession resolved to immutable evidence already finalized by a peer.
55
+ It is observational and does not change computed/cache provenance.
56
+ """
43
57
 
44
58
  elapsed_seconds: float
45
59
  fasta_staging_seconds: float
@@ -56,6 +70,7 @@ class AnnotationMetrics:
56
70
  tool_batches: int
57
71
  unique_artifact_reads: int
58
72
  configured_threads: int
73
+ existing_finalizations: int = 0
59
74
 
60
75
 
61
76
  @dataclass(frozen=True, slots=True)
@@ -77,11 +92,19 @@ class _BatchMetrics:
77
92
  commit_batches: int
78
93
  adapter_seconds: float
79
94
  store_commit_seconds: float
95
+ peer_completed_sequence_ids: frozenset[str] = frozenset()
96
+ existing_finalizations: int = 0
80
97
 
81
98
 
82
99
  class _MeasuringToolRunner(ToolRunner):
83
- def __init__(self, delegate: ToolRunner) -> None:
100
+ def __init__(
101
+ self,
102
+ delegate: ToolRunner,
103
+ *,
104
+ cancellation_signal: threading.Event | None = None,
105
+ ) -> None:
84
106
  self.delegate = delegate
107
+ self.cancellation_signal = cancellation_signal
85
108
  self.duration_seconds = 0.0
86
109
 
87
110
  def run(
@@ -89,8 +112,13 @@ class _MeasuringToolRunner(ToolRunner):
89
112
  command: ToolCommand,
90
113
  *,
91
114
  timeout_seconds: float | None = None,
115
+ cancellation_signal: threading.Event | None = None,
92
116
  ) -> ToolRunResult:
93
- result = self.delegate.run(command, timeout_seconds=timeout_seconds)
117
+ result = self.delegate.run(
118
+ command,
119
+ timeout_seconds=timeout_seconds,
120
+ cancellation_signal=cancellation_signal or self.cancellation_signal,
121
+ )
94
122
  self.duration_seconds += result.duration_seconds
95
123
  return result
96
124
 
@@ -138,11 +166,41 @@ def run_annotation(
138
166
  store_lookup_seconds = 0.0
139
167
  adapter_seconds = 0.0
140
168
  store_commit_seconds = 0.0
169
+ existing_finalizations = 0
170
+ claim_session: ClaimSession | None = None
171
+ primary_failure: BaseException | None = None
172
+ close_failure: BaseException | None = None
173
+ published_output_identity: tuple[int, int] | None = None
174
+ published_output_marker: Path | None = None
175
+
176
+ def unlink_owned_output() -> None:
177
+ if published_output_identity is None or published_output_marker is None:
178
+ return
179
+ try:
180
+ current = output_dir.stat(follow_symlinks=False)
181
+ marker = published_output_marker.stat(follow_symlinks=False)
182
+ except FileNotFoundError:
183
+ return
184
+ if (current.st_dev, current.st_ino) == published_output_identity and (
185
+ marker.st_dev,
186
+ marker.st_ino,
187
+ ) == published_output_identity:
188
+ output_dir.unlink()
189
+
141
190
  try:
142
191
  keys_by_sequence_id = {}
143
192
  computed_ids: set[str] = set()
144
193
  pending_misses: list[SequenceIdentity] = []
145
- tool_runner = _MeasuringToolRunner(runner or ToolRunner())
194
+ busy_queries: list[EvidenceQuery] = []
195
+ busy_retry_after: float | None = None
196
+ if is_claim_session_capable_store(store):
197
+ claim_session = store.claim_session().__enter__()
198
+ tool_runner = _MeasuringToolRunner(
199
+ runner or ToolRunner(),
200
+ cancellation_signal=(
201
+ claim_session.cancellation_signal if claim_session is not None else None
202
+ ),
203
+ )
146
204
 
147
205
  for identity_batch in batched(iter_staged_identities(stage), _STORE_BATCH_SIZE):
148
206
  queries = tuple(
@@ -153,16 +211,43 @@ def run_annotation(
153
211
  (query.identity.sequence_id, query.key) for query in queries
154
212
  )
155
213
  lookup_started = time.perf_counter()
156
- cached = store.lookup_many(queries)
214
+ if claim_session is not None:
215
+ claim_session.raise_if_lost()
216
+ decisions = claim_session.acquire_many(queries)
217
+ cached = {
218
+ query.key: decision.record
219
+ for query, decision in zip(queries, decisions, strict=True)
220
+ if decision.disposition is ClaimDisposition.CACHED
221
+ }
222
+ for query, decision in zip(queries, decisions, strict=True):
223
+ if decision.disposition is ClaimDisposition.ACQUIRED:
224
+ assert decision.claim is not None
225
+ pending_misses.append(query.identity)
226
+ elif decision.disposition is ClaimDisposition.BUSY:
227
+ busy_queries.append(query)
228
+ assert decision.busy is not None
229
+ busy_retry_after = (
230
+ decision.busy.retry_after_seconds
231
+ if busy_retry_after is None
232
+ else min(
233
+ busy_retry_after,
234
+ decision.busy.retry_after_seconds,
235
+ )
236
+ )
237
+ else:
238
+ cached = store.lookup_many(queries)
157
239
  store_lookup_seconds += time.perf_counter() - lookup_started
158
240
  lookup_batches += 1
159
- for query in queries:
160
- if query.key not in cached:
161
- pending_misses.append(query.identity)
162
- computed_ids.add(query.identity.sequence_id)
241
+ if claim_session is None:
242
+ for query in queries:
243
+ if query.key not in cached:
244
+ pending_misses.append(query.identity)
163
245
  while len(pending_misses) >= _ANNOTATION_BATCH_SIZE:
164
246
  annotation_identities = tuple(pending_misses[:_ANNOTATION_BATCH_SIZE])
165
247
  del pending_misses[:_ANNOTATION_BATCH_SIZE]
248
+ computed_ids.update(
249
+ identity.sequence_id for identity in annotation_identities
250
+ )
166
251
  tool_batches += 1
167
252
  batch_metrics = _run_annotation_batch(
168
253
  identities=annotation_identities,
@@ -173,12 +258,18 @@ def run_annotation(
173
258
  runner=tool_runner,
174
259
  timeout_seconds=timeout_seconds,
175
260
  threads=threads,
261
+ claim_session=claim_session,
176
262
  )
177
263
  commit_batches += batch_metrics.commit_batches
178
264
  adapter_seconds += batch_metrics.adapter_seconds
179
265
  store_commit_seconds += batch_metrics.store_commit_seconds
266
+ existing_finalizations += batch_metrics.existing_finalizations
267
+ computed_ids.difference_update(
268
+ batch_metrics.peer_completed_sequence_ids
269
+ )
180
270
 
181
271
  if pending_misses:
272
+ computed_ids.update(identity.sequence_id for identity in pending_misses)
182
273
  tool_batches += 1
183
274
  batch_metrics = _run_annotation_batch(
184
275
  identities=tuple(pending_misses),
@@ -189,14 +280,72 @@ def run_annotation(
189
280
  runner=tool_runner,
190
281
  timeout_seconds=timeout_seconds,
191
282
  threads=threads,
283
+ claim_session=claim_session,
192
284
  )
193
285
  commit_batches += batch_metrics.commit_batches
194
286
  adapter_seconds += batch_metrics.adapter_seconds
195
287
  store_commit_seconds += batch_metrics.store_commit_seconds
288
+ existing_finalizations += batch_metrics.existing_finalizations
289
+ computed_ids.difference_update(batch_metrics.peer_completed_sequence_ids)
290
+
291
+ while busy_queries:
292
+ time.sleep(busy_retry_after or 1.0)
293
+ next_retry_after: float | None = None
294
+ next_busy = []
295
+ acquired = []
296
+ assert claim_session is not None
297
+ for busy_batch in batched(busy_queries, _STORE_BATCH_SIZE):
298
+ reacquire_started = time.perf_counter()
299
+ claim_session.raise_if_lost()
300
+ decisions = claim_session.acquire_many(busy_batch)
301
+ store_lookup_seconds += time.perf_counter() - reacquire_started
302
+ lookup_batches += 1
303
+ for query, decision in zip(busy_batch, decisions, strict=True):
304
+ if decision.disposition is ClaimDisposition.ACQUIRED:
305
+ assert decision.claim is not None
306
+ acquired.append(query.identity)
307
+ elif decision.disposition is ClaimDisposition.BUSY:
308
+ assert decision.busy is not None
309
+ next_retry_after = (
310
+ decision.busy.retry_after_seconds
311
+ if next_retry_after is None
312
+ else min(
313
+ next_retry_after,
314
+ decision.busy.retry_after_seconds,
315
+ )
316
+ )
317
+ next_busy.append(query)
318
+ for acquired_batch in batched(acquired, _ANNOTATION_BATCH_SIZE):
319
+ computed_ids.update(identity.sequence_id for identity in acquired_batch)
320
+ tool_batches += 1
321
+ batch_metrics = _run_annotation_batch(
322
+ identities=acquired_batch,
323
+ batch_number=tool_batches,
324
+ work_dir=work_dir,
325
+ adapter=adapter,
326
+ store=store,
327
+ runner=tool_runner,
328
+ timeout_seconds=timeout_seconds,
329
+ threads=threads,
330
+ claim_session=claim_session,
331
+ )
332
+ commit_batches += batch_metrics.commit_batches
333
+ adapter_seconds += batch_metrics.adapter_seconds
334
+ store_commit_seconds += batch_metrics.store_commit_seconds
335
+ existing_finalizations += batch_metrics.existing_finalizations
336
+ computed_ids.difference_update(
337
+ batch_metrics.peer_completed_sequence_ids
338
+ )
339
+ busy_queries = next_busy
340
+ busy_retry_after = next_retry_after
196
341
 
342
+ if claim_session is not None:
343
+ claim_session.raise_if_lost()
197
344
  fetch_started = time.perf_counter()
198
345
  fetched_by_key = store.fetch_many(keys_by_sequence_id.values())
199
346
  store_fetch_seconds = time.perf_counter() - fetch_started
347
+ if claim_session is not None:
348
+ claim_session.raise_if_lost()
200
349
  missing_keys = set(keys_by_sequence_id.values()) - fetched_by_key.keys()
201
350
  if missing_keys:
202
351
  raise AnnotationError(
@@ -223,8 +372,17 @@ def run_annotation(
223
372
  metadata = dict(result_metadata or _default_result_metadata(adapter))
224
373
  metadata["InputDigest"] = stage.input_digest
225
374
  metadata.setdefault("CreatedAt", datetime.now(UTC).isoformat())
375
+ package_output = (
376
+ work_dir / "candidate-result.duckdb"
377
+ if claim_session is not None
378
+ else output_dir
379
+ )
380
+ if claim_session is not None:
381
+ claim_session.raise_if_lost()
382
+ if output_dir.exists():
383
+ raise AnnotationError(f"output path already exists: {output_dir}")
226
384
  materialize_result_database(
227
- output_path=output_dir,
385
+ output_path=package_output,
228
386
  records=iter_staged_records(stage),
229
387
  input_record_count=stage.input_records,
230
388
  fetched_by_sequence_id=fetched_by_sequence_id,
@@ -240,18 +398,56 @@ def run_annotation(
240
398
  "computed": len(computed_ids),
241
399
  "hits": statuses.count(EvidenceStatus.HIT),
242
400
  "no_hits": statuses.count(EvidenceStatus.NO_HIT),
401
+ "existing_finalizations": existing_finalizations,
243
402
  },
244
403
  )
404
+ if claim_session is not None:
405
+ try:
406
+ claim_session.raise_if_lost()
407
+ candidate = package_output.stat(follow_symlinks=False)
408
+ published_output_identity = (candidate.st_dev, candidate.st_ino)
409
+ published_output_marker = package_output
410
+ os.link(package_output, output_dir)
411
+ claim_session.raise_if_lost()
412
+ except BaseException:
413
+ unlink_owned_output()
414
+ raise
245
415
  package_seconds = time.perf_counter() - package_started
246
- except Exception as error:
416
+ except BaseException as error:
417
+ primary_failure = error
418
+ if not isinstance(error, Exception):
419
+ raise
247
420
  raise AnnotationError(
248
421
  f"annotation failed; diagnostics retained at {work_dir}: {error}"
249
422
  ) from error
250
- else:
251
- shutil.rmtree(work_dir, ignore_errors=True)
252
423
  finally:
424
+ if claim_session is not None:
425
+ try:
426
+ claim_session.__exit__(None, None, None)
427
+ except BaseException as close_error:
428
+ if primary_failure is None:
429
+ close_failure = close_error
430
+ else:
431
+ primary_failure.add_note(
432
+ f"ClaimSession cleanup also failed: {close_error!r}"
433
+ )
253
434
  shutil.rmtree(stage.root, ignore_errors=True)
254
435
 
436
+ if close_failure is not None:
437
+ unlink_owned_output()
438
+ raise close_failure
439
+ if claim_session is not None:
440
+ try:
441
+ claim_session.raise_if_lost()
442
+ except BaseException as error:
443
+ unlink_owned_output()
444
+ if not isinstance(error, Exception):
445
+ raise
446
+ raise AnnotationError(
447
+ f"annotation failed; diagnostics retained at {work_dir}: {error}"
448
+ ) from error
449
+ shutil.rmtree(work_dir, ignore_errors=True)
450
+
255
451
  statuses = [fetched.record.status for fetched in fetched_by_sequence_id.values()]
256
452
  artifact_digests = {
257
453
  digest
@@ -286,6 +482,7 @@ def run_annotation(
286
482
  tool_batches=tool_batches,
287
483
  unique_artifact_reads=len(artifact_digests),
288
484
  configured_threads=threads,
485
+ existing_finalizations=existing_finalizations,
289
486
  ),
290
487
  )
291
488
 
@@ -340,36 +537,60 @@ def _run_annotation_batch(
340
537
  runner: ToolRunner,
341
538
  timeout_seconds: float | None,
342
539
  threads: int,
540
+ claim_session: ClaimSession | None = None,
343
541
  ) -> _BatchMetrics:
344
542
  batch_dir = work_dir / f"batch-{batch_number:06d}"
345
543
  batch_dir.mkdir()
346
544
  misses_fasta = batch_dir / "cache-misses.fasta"
545
+ if claim_session is not None:
546
+ claim_session.raise_if_lost()
347
547
  with misses_fasta.open("w", encoding="ascii", newline="\n") as handle:
348
548
  handle.writelines(iter_fasta_lines(identities))
349
549
  adapter_started = time.perf_counter()
350
- batch = adapter.run_batch(
351
- identities=identities,
352
- input_fasta=misses_fasta,
353
- work_dir=batch_dir,
354
- runner=runner,
355
- timeout_seconds=timeout_seconds,
356
- threads=threads,
357
- )
550
+ try:
551
+ batch = adapter.run_batch(
552
+ identities=identities,
553
+ input_fasta=misses_fasta,
554
+ work_dir=batch_dir,
555
+ runner=runner,
556
+ timeout_seconds=timeout_seconds,
557
+ threads=threads,
558
+ )
559
+ except ToolCancelledError:
560
+ if claim_session is not None:
561
+ claim_session.raise_if_lost()
562
+ raise
358
563
  adapter_seconds = time.perf_counter() - adapter_started
359
- commits = _build_commits(
360
- batch=batch,
361
- identities=identities,
362
- adapter=adapter,
363
- )
564
+ if claim_session is not None:
565
+ claim_session.raise_if_lost()
566
+ commits = _build_commits(batch=batch, identities=identities, adapter=adapter)
364
567
  batches = 0
568
+ peer_completed_sequence_ids: set[str] = set()
569
+ existing_finalizations = 0
365
570
  commit_started = time.perf_counter()
366
571
  for commit_batch in batched(commits, _STORE_BATCH_SIZE):
367
- store.commit_many(commit_batch)
368
- batches += 1
572
+ if claim_session is None:
573
+ store.commit_many(commit_batch)
574
+ batches += 1
575
+ else:
576
+ claim_session.raise_if_lost()
577
+ outcomes = claim_session.finalize_many(commit_batch)
578
+ claim_session.raise_if_lost()
579
+ peer_completed_sequence_ids.update(
580
+ commit.identity.sequence_id
581
+ for commit, outcome in zip(commit_batch, outcomes, strict=True)
582
+ if outcome is CommitOutcome.EXISTING
583
+ )
584
+ existing_finalizations += sum(
585
+ outcome is CommitOutcome.EXISTING for outcome in outcomes
586
+ )
587
+ batches += 1
369
588
  return _BatchMetrics(
370
589
  commit_batches=batches,
371
590
  adapter_seconds=adapter_seconds,
372
591
  store_commit_seconds=time.perf_counter() - commit_started,
592
+ peer_completed_sequence_ids=frozenset(peer_completed_sequence_ids),
593
+ existing_finalizations=existing_finalizations,
373
594
  )
374
595
 
375
596