cctally 1.92.2 → 1.93.0
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.
- package/CHANGELOG.md +43 -0
- package/bin/_cctally_cache.py +354 -0
- package/bin/_cctally_core.py +180 -3
- package/bin/_cctally_dashboard.py +71 -1
- package/bin/_cctally_dashboard_envelope.py +28 -2
- package/bin/_cctally_dashboard_share.py +75 -19
- package/bin/_cctally_dashboard_sources.py +12 -0
- package/bin/_cctally_db.py +89 -1
- package/bin/_cctally_doctor.py +31 -0
- package/bin/_cctally_forecast.py +4 -2
- package/bin/_cctally_journal.py +3482 -258
- package/bin/_cctally_journal_repair.py +123 -32
- package/bin/_cctally_milestone_history.py +4 -1
- package/bin/_cctally_project.py +8 -6
- package/bin/_cctally_quota.py +420 -20
- package/bin/_cctally_rederive.py +57 -23
- package/bin/_cctally_reporting.py +8 -6
- package/bin/_cctally_share.py +74 -37
- package/bin/_cctally_source_analytics.py +6 -8
- package/bin/_cctally_store.py +13 -2
- package/bin/_cctally_tui.py +53 -0
- package/bin/_lib_cache_coverage.py +547 -0
- package/bin/_lib_doctor.py +54 -2
- package/bin/_lib_journal.py +235 -95
- package/bin/_lib_journal_router.py +21 -0
- package/bin/_lib_segment_summary.py +374 -0
- package/bin/_lib_selector_state.py +959 -0
- package/bin/_lib_share.py +1073 -165
- package/bin/_lib_share_templates.py +35 -11
- package/bin/_lib_stats_wal.py +327 -0
- package/bin/_lib_view_models.py +2 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
- package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-DnWdv8um.css +0 -1
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
"""Durable selector state — the pure kernel (#496 S5b Stage 1, spec §3).
|
|
2
|
+
|
|
3
|
+
`_lib_journal.resolve_effective_events` accumulates six things over the record
|
|
4
|
+
stream — per-evt candidates, batch markers, batch actions, protocol resolutions,
|
|
5
|
+
tainted batches with their distinct violations, and a per-fingerprint
|
|
6
|
+
`violation_available_after` minimum — and returns only a summary. Every live
|
|
7
|
+
tick that meets a correction record therefore re-derives them by reading the
|
|
8
|
+
whole journal prefix. This module turns those six into durable rows and merges a
|
|
9
|
+
delta into them, which is what lets a validated generation continue the fold
|
|
10
|
+
instead of restarting it.
|
|
11
|
+
|
|
12
|
+
Two rules govern what is stored, and both are load-bearing:
|
|
13
|
+
|
|
14
|
+
**No per-candidate table.** Same-revision containment needs only, per event id,
|
|
15
|
+
the winning revision, the lowest-sequence winner and the set of distinct content
|
|
16
|
+
hashes observed at that revision. `journal_effective_events` is already keyed
|
|
17
|
+
that way, so two added columns complete it.
|
|
18
|
+
|
|
19
|
+
**Action cores are retained while a batch is `begin_only` OR `tainted`, and
|
|
20
|
+
dropped only on `completed`.** An early taint does not end a batch's record
|
|
21
|
+
stream: later actions and a commit can still establish a further violation such
|
|
22
|
+
as `manifest_actions_hash_mismatch`, whose derivation hashes every first-seen
|
|
23
|
+
action core. Dropping cores at taint would leave that underivable. Dropping on
|
|
24
|
+
`completed` is safe in both directions — a duplicate that matches changes
|
|
25
|
+
nothing, and one that conflicts is detected from the retained whole-record
|
|
26
|
+
digest, which taints the batch and forces a rebuild that re-derives from the
|
|
27
|
+
journal.
|
|
28
|
+
|
|
29
|
+
This module imports nothing outside the stdlib and `_lib_journal`, and in
|
|
30
|
+
particular never imports `_cctally_journal`, so it is unit-testable without a
|
|
31
|
+
journal on disk — the same rule `bin/_lib_journal_router.py` follows.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
from dataclasses import dataclass, replace
|
|
37
|
+
|
|
38
|
+
import _lib_journal as _jl
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
#: Bumped whenever the durable row shapes or the merge semantics change. A
|
|
42
|
+
#: mismatch is one of the states that falls back to full selection silently.
|
|
43
|
+
SELECTOR_VERSION = 1
|
|
44
|
+
|
|
45
|
+
#: What `_lib_journal.decode_line` returns for a line that is not a record. A
|
|
46
|
+
#: malformed line produces NO decoded entry, so it consumes no sequence number.
|
|
47
|
+
MALFORMED = None
|
|
48
|
+
|
|
49
|
+
_BEGIN_ONLY = "begin_only"
|
|
50
|
+
_COMPLETED = "completed"
|
|
51
|
+
_TAINTED = "tainted"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class IncrementalSelectionUnavailable(Exception):
|
|
55
|
+
"""The delta contains something an incremental merge may not decide alone.
|
|
56
|
+
|
|
57
|
+
Two things reach it:
|
|
58
|
+
|
|
59
|
+
- a `journal_protocol_resolution` op, because acknowledging a violation
|
|
60
|
+
requires an exact length-framed raw-prefix SHA-256 and no semantic summary
|
|
61
|
+
can reconstruct that hash, so the caller must fall back to full selection
|
|
62
|
+
rather than accept a claimed one; and
|
|
63
|
+
- a batch whose durable status is `completed` gaining a marker phase or an
|
|
64
|
+
action sequence the durable rows do not hold. Its action cores were
|
|
65
|
+
dropped at completion, so its verdict can only be carried forward, and
|
|
66
|
+
carrying it forward over a record that a full pass would have folded into
|
|
67
|
+
the verdict is exactly how the two paths diverge.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------------------
|
|
72
|
+
# row shapes
|
|
73
|
+
# --------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class SelectorStateRow:
|
|
77
|
+
"""`journal_selector_state` — one row."""
|
|
78
|
+
|
|
79
|
+
next_sequence: int
|
|
80
|
+
selector_version: int = SELECTOR_VERSION
|
|
81
|
+
covered_segment: "str | None" = None
|
|
82
|
+
covered_offset: "int | None" = None
|
|
83
|
+
#: Written at PUBLICATION, not at scratch construction: the publication
|
|
84
|
+
#: stamp does not exist while the scratch is being built, so a row populated
|
|
85
|
+
#: then cannot carry the identity it will publish under.
|
|
86
|
+
generation_record_path: "str | None" = None
|
|
87
|
+
generation_stamped_at_utc: "str | None" = None
|
|
88
|
+
#: `cutover_seen` distinguishes "no cutover op exists" from "the op exists
|
|
89
|
+
#: and recorded no account". A plain NULL cannot carry both answers.
|
|
90
|
+
cutover_seen: bool = False
|
|
91
|
+
cutover_account_key: "str | None" = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class SelectorBatchRow:
|
|
96
|
+
"""`journal_selector_batches` — one row per correction batch."""
|
|
97
|
+
|
|
98
|
+
batch_id: str
|
|
99
|
+
status: str
|
|
100
|
+
action_count: "int | None" = None
|
|
101
|
+
action_set_hash: "str | None" = None
|
|
102
|
+
begin_segment: "str | None" = None
|
|
103
|
+
begin_offset: "int | None" = None
|
|
104
|
+
earliest_commit_segment: "str | None" = None
|
|
105
|
+
earliest_commit_offset: "int | None" = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class SelectorBatchRecordRow:
|
|
110
|
+
"""`journal_selector_batch_records` — one row per marker and per action."""
|
|
111
|
+
|
|
112
|
+
batch_id: str
|
|
113
|
+
kind: str # "marker" | "action"
|
|
114
|
+
key: str # the phase, or the action sequence rendered as text
|
|
115
|
+
record_digest: str
|
|
116
|
+
sequence: int
|
|
117
|
+
identity_digest: "str | None" = None
|
|
118
|
+
action_core_json: "str | None" = None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class SelectorEffectiveRow:
|
|
123
|
+
"""`journal_effective_events` — one row per event id."""
|
|
124
|
+
|
|
125
|
+
event_id: str
|
|
126
|
+
rev: int
|
|
127
|
+
status: str
|
|
128
|
+
content_hash: str
|
|
129
|
+
batch_id: "str | None"
|
|
130
|
+
event_json: "str | None"
|
|
131
|
+
winning_sequence: int
|
|
132
|
+
conflict_hashes_json: "str | None" = None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class SelectorViolationRow:
|
|
137
|
+
"""`journal_protocol_violations` — one row per distinct violation."""
|
|
138
|
+
|
|
139
|
+
fingerprint: str
|
|
140
|
+
batch_id: str
|
|
141
|
+
kind: str
|
|
142
|
+
violation_json: str
|
|
143
|
+
available_after: "int | None" = None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class SelectorRows:
|
|
148
|
+
"""One generation's durable selector state, as comparable value objects."""
|
|
149
|
+
|
|
150
|
+
state: SelectorStateRow
|
|
151
|
+
batches: tuple = ()
|
|
152
|
+
batch_records: tuple = ()
|
|
153
|
+
effective: tuple = ()
|
|
154
|
+
violations: tuple = ()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class TaintTransition:
|
|
159
|
+
"""A batch whose durable status was `completed` and is now `tainted`.
|
|
160
|
+
|
|
161
|
+
`causal_sequence` is the sequence of the FIRST record that established the
|
|
162
|
+
taint — never the batch's earliest commit. A rebuild bounded at the commit
|
|
163
|
+
excludes the tainting record, faithfully reproduces the completed
|
|
164
|
+
correction, and meets the same taint on the next tick, which is a livelock
|
|
165
|
+
rather than a recovery (spec §3.7).
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
batch_id: str
|
|
169
|
+
causal_sequence: int
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --------------------------------------------------------------------------
|
|
173
|
+
# the decoded-entry counting primitive
|
|
174
|
+
# --------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def decoded_entry_count(decode_results) -> int:
|
|
177
|
+
"""How many `decoded` entries a stream of PHYSICAL LINES produces.
|
|
178
|
+
|
|
179
|
+
``decode_results`` is one element per physical journal line — exactly what
|
|
180
|
+
`_lib_journal.decode_line` returned for it — so ``None`` here means the line
|
|
181
|
+
FAILED TO DECODE. That is the opposite of the `None` in the rebuild's
|
|
182
|
+
`decoded` list, which is a placeholder for a valid non-retained record; the
|
|
183
|
+
two conventions are distinct and this primitive takes the first.
|
|
184
|
+
|
|
185
|
+
Mirrors the rebuild read loop's branching exactly: a decoded record produces
|
|
186
|
+
ONE entry whether or not its type is retained, because the loop appends an
|
|
187
|
+
explicit `None` PLACEHOLDER for a valid non-retained record; a line that
|
|
188
|
+
failed to decode produces none, because the loop skips and counts it
|
|
189
|
+
separately.
|
|
190
|
+
|
|
191
|
+
That distinction is not bookkeeping. `resolve_effective_events` numbers
|
|
192
|
+
candidates with `enumerate(records)`, three of the seven structural
|
|
193
|
+
violation kinds put that number inside `ProtocolViolation.evidence`, and the
|
|
194
|
+
fingerprint hashes it. The fingerprint is durable — it lands in
|
|
195
|
+
`journal_protocol_violations` and is referenced by name from a
|
|
196
|
+
`journal_protocol_resolution` op — so renumbering would make a previously
|
|
197
|
+
acknowledged violation unresolvable and raise on every later rebuild. An
|
|
198
|
+
elided segment therefore contributes exactly this count in its stead.
|
|
199
|
+
"""
|
|
200
|
+
return sum(1 for result in decode_results if isinstance(result, dict))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --------------------------------------------------------------------------
|
|
204
|
+
# full selection -> durable rows
|
|
205
|
+
# --------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def _batch_status(fold, batch_id: str) -> str:
|
|
208
|
+
if batch_id in fold.tainted_batches:
|
|
209
|
+
return _TAINTED
|
|
210
|
+
if batch_id in fold.completed:
|
|
211
|
+
return _COMPLETED
|
|
212
|
+
return _BEGIN_ONLY
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _coordinate(coordinates, sequence):
|
|
216
|
+
if not coordinates or sequence is None:
|
|
217
|
+
return (None, None)
|
|
218
|
+
found = coordinates.get(sequence)
|
|
219
|
+
return (None, None) if found is None else (found[0], int(found[1]))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _canonical_json(value) -> str:
|
|
223
|
+
return json.dumps(value, separators=(",", ":"), sort_keys=True,
|
|
224
|
+
ensure_ascii=False)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _batch_rows(fold, coordinates):
|
|
228
|
+
batches = []
|
|
229
|
+
records = []
|
|
230
|
+
for batch_id in sorted(set(fold.markers) | set(fold.actions)):
|
|
231
|
+
status = _batch_status(fold, batch_id)
|
|
232
|
+
markers = fold.markers.get(batch_id, {})
|
|
233
|
+
begin = markers.get("begin")
|
|
234
|
+
commit = markers.get("commit")
|
|
235
|
+
begin_segment, begin_offset = _coordinate(
|
|
236
|
+
coordinates, begin[3] if begin else None)
|
|
237
|
+
commit_segment, commit_offset = _coordinate(
|
|
238
|
+
coordinates, commit[3] if commit else None)
|
|
239
|
+
batches.append(
|
|
240
|
+
SelectorBatchRow(
|
|
241
|
+
batch_id=batch_id,
|
|
242
|
+
status=status,
|
|
243
|
+
action_count=begin[0]["action_count"] if begin else None,
|
|
244
|
+
action_set_hash=begin[0]["actions_hash"] if begin else None,
|
|
245
|
+
begin_segment=begin_segment,
|
|
246
|
+
begin_offset=begin_offset,
|
|
247
|
+
earliest_commit_segment=commit_segment,
|
|
248
|
+
earliest_commit_offset=commit_offset,
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
for phase in sorted(markers):
|
|
252
|
+
_core, digest, identity_digest, sequence = markers[phase]
|
|
253
|
+
records.append(
|
|
254
|
+
SelectorBatchRecordRow(
|
|
255
|
+
batch_id=batch_id,
|
|
256
|
+
kind="marker",
|
|
257
|
+
key=phase,
|
|
258
|
+
record_digest=digest,
|
|
259
|
+
sequence=sequence,
|
|
260
|
+
identity_digest=identity_digest,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
for seq in sorted(fold.actions.get(batch_id, {})):
|
|
264
|
+
normalized, digest, sequence = fold.actions[batch_id][seq]
|
|
265
|
+
records.append(
|
|
266
|
+
SelectorBatchRecordRow(
|
|
267
|
+
batch_id=batch_id,
|
|
268
|
+
kind="action",
|
|
269
|
+
key=str(seq),
|
|
270
|
+
record_digest=digest,
|
|
271
|
+
sequence=sequence,
|
|
272
|
+
action_core_json=(
|
|
273
|
+
None
|
|
274
|
+
if status == _COMPLETED or normalized is None
|
|
275
|
+
else _canonical_json(
|
|
276
|
+
_jl._correction_action_core(normalized))
|
|
277
|
+
),
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
# Canonical order `(batch_id, kind, key)` — the same order a durable read
|
|
281
|
+
# returns, so a stored generation and an in-memory derivation compare equal
|
|
282
|
+
# without either side re-sorting. `key` is text on both sides, so SQLite's
|
|
283
|
+
# BINARY collation and Python's string ordering agree.
|
|
284
|
+
records.sort(key=lambda row: (row.batch_id, row.kind, row.key))
|
|
285
|
+
return tuple(batches), tuple(records)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _effective_rows(selection):
|
|
289
|
+
conflicts = {
|
|
290
|
+
conflict.event_id: list(conflict.content_hashes)
|
|
291
|
+
for conflict in selection.conflicts
|
|
292
|
+
}
|
|
293
|
+
rows = []
|
|
294
|
+
for event_id, selected in selection.by_id.items():
|
|
295
|
+
event_json = None
|
|
296
|
+
if selected.record is not None:
|
|
297
|
+
event_json = (
|
|
298
|
+
_jl.encode_line(selected.record).decode("utf-8").rstrip("\n")
|
|
299
|
+
)
|
|
300
|
+
hashes = conflicts.get(event_id)
|
|
301
|
+
rows.append(
|
|
302
|
+
SelectorEffectiveRow(
|
|
303
|
+
event_id=event_id,
|
|
304
|
+
rev=selected.rev,
|
|
305
|
+
status=selected.status,
|
|
306
|
+
content_hash=selected.content_hash,
|
|
307
|
+
batch_id=selected.batch_id,
|
|
308
|
+
event_json=event_json,
|
|
309
|
+
winning_sequence=selected.sequence,
|
|
310
|
+
conflict_hashes_json=(
|
|
311
|
+
None if hashes is None else _canonical_json(hashes)
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
rows.sort(key=lambda row: row.event_id)
|
|
316
|
+
return tuple(rows)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def violation_rows(selection, fold):
|
|
320
|
+
"""Durable violation rows for ONE full selection.
|
|
321
|
+
|
|
322
|
+
Public because the live path's full-prefix fallback writes the same rows the
|
|
323
|
+
rebuild does. Deriving them twice, in two places, is how the two ended up
|
|
324
|
+
disagreeing on `available_after` and on `ensure_ascii`.
|
|
325
|
+
"""
|
|
326
|
+
rows = []
|
|
327
|
+
for violation in (
|
|
328
|
+
*selection.protocol_violations,
|
|
329
|
+
*selection.acknowledged_protocol_violations,
|
|
330
|
+
):
|
|
331
|
+
rows.append(
|
|
332
|
+
SelectorViolationRow(
|
|
333
|
+
fingerprint=violation.fingerprint,
|
|
334
|
+
batch_id=violation.batch_id,
|
|
335
|
+
kind=violation.kind,
|
|
336
|
+
violation_json=_canonical_json(violation.to_dict()),
|
|
337
|
+
available_after=fold.violation_available_after.get(
|
|
338
|
+
violation.fingerprint),
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
rows.sort(key=lambda row: (row.batch_id, row.kind, row.fingerprint))
|
|
342
|
+
return tuple(rows)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def rows_from_selection(
|
|
346
|
+
selection,
|
|
347
|
+
*,
|
|
348
|
+
accumulators,
|
|
349
|
+
next_sequence: int,
|
|
350
|
+
coordinates=None,
|
|
351
|
+
covered=None,
|
|
352
|
+
cutover_seen: bool = False,
|
|
353
|
+
cutover_account_key: "str | None" = None,
|
|
354
|
+
) -> SelectorRows:
|
|
355
|
+
"""Durable rows for ONE full selection.
|
|
356
|
+
|
|
357
|
+
``accumulators`` is the out-dict `resolve_effective_events` populated, which
|
|
358
|
+
is where the six accumulators live; `EffectiveSelection` deliberately did
|
|
359
|
+
not grow a field for them, so every existing caller stays byte-unaffected.
|
|
360
|
+
|
|
361
|
+
``next_sequence`` is the pass's TOTAL decoded-entry count — the sequence the
|
|
362
|
+
next stream must start at.
|
|
363
|
+
|
|
364
|
+
``coordinates`` maps a sequence number to its ``(segment, end_offset)``.
|
|
365
|
+
Only correction-batch MARKER sequences are ever looked up, so a caller need
|
|
366
|
+
populate no more than those; a missing entry leaves the coordinate columns
|
|
367
|
+
NULL rather than guessing one.
|
|
368
|
+
"""
|
|
369
|
+
fold = accumulators["fold"]
|
|
370
|
+
batches, batch_records = _batch_rows(fold, coordinates)
|
|
371
|
+
covered_segment, covered_offset = (
|
|
372
|
+
(None, None) if covered is None else (covered[0], int(covered[1]))
|
|
373
|
+
)
|
|
374
|
+
return SelectorRows(
|
|
375
|
+
state=SelectorStateRow(
|
|
376
|
+
next_sequence=next_sequence,
|
|
377
|
+
selector_version=SELECTOR_VERSION,
|
|
378
|
+
covered_segment=covered_segment,
|
|
379
|
+
covered_offset=covered_offset,
|
|
380
|
+
cutover_seen=bool(cutover_seen),
|
|
381
|
+
cutover_account_key=cutover_account_key,
|
|
382
|
+
),
|
|
383
|
+
batches=batches,
|
|
384
|
+
batch_records=batch_records,
|
|
385
|
+
effective=_effective_rows(selection),
|
|
386
|
+
violations=violation_rows(selection, fold),
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# --------------------------------------------------------------------------
|
|
391
|
+
# durable rows -> a seeded fold
|
|
392
|
+
# --------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
def comparable(rows: "SelectorRows | None"):
|
|
395
|
+
"""``rows`` with the generation identity cleared, for validation.
|
|
396
|
+
|
|
397
|
+
The identity is written at PUBLICATION, so a durable generation carries it
|
|
398
|
+
and a fresh derivation from the journal cannot. Comparing it would make
|
|
399
|
+
every validation of a published index fail. Everything else — the covered
|
|
400
|
+
prefix, `next_sequence`, `selector_version`, the cutover pair, and all four
|
|
401
|
+
row groups — IS compared.
|
|
402
|
+
"""
|
|
403
|
+
if rows is None:
|
|
404
|
+
return None
|
|
405
|
+
return replace(
|
|
406
|
+
rows,
|
|
407
|
+
state=replace(
|
|
408
|
+
rows.state,
|
|
409
|
+
generation_record_path=None,
|
|
410
|
+
generation_stamped_at_utc=None,
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _seed_fold(rows: SelectorRows):
|
|
416
|
+
"""Reconstruct the accumulator state the durable rows describe.
|
|
417
|
+
|
|
418
|
+
A marker's normalized core is rebuilt from the batch row's `action_count`
|
|
419
|
+
and `action_set_hash`, which is exactly the subset phase 2 reads, plus the
|
|
420
|
+
stored identity digest — and identity-digest equality implies core equality,
|
|
421
|
+
because the identity digest covers the whole record minus its phase.
|
|
422
|
+
"""
|
|
423
|
+
fold = _jl.SelectorFold()
|
|
424
|
+
by_batch = {row.batch_id: row for row in rows.batches}
|
|
425
|
+
for row in rows.batch_records:
|
|
426
|
+
batch = by_batch.get(row.batch_id)
|
|
427
|
+
if row.kind == "marker":
|
|
428
|
+
core = {
|
|
429
|
+
"action_count": None if batch is None else batch.action_count,
|
|
430
|
+
"actions_hash": None if batch is None else batch.action_set_hash,
|
|
431
|
+
}
|
|
432
|
+
fold.markers.setdefault(row.batch_id, {})[row.key] = (
|
|
433
|
+
core, row.record_digest, row.identity_digest, row.sequence,
|
|
434
|
+
)
|
|
435
|
+
else:
|
|
436
|
+
normalized = None
|
|
437
|
+
if row.action_core_json is not None:
|
|
438
|
+
# `_correction_action_core` deliberately excludes `v`, `t`,
|
|
439
|
+
# `batch` and `seq`, which `_validate_correction_record` does
|
|
440
|
+
# produce and which `_candidate_from_correction` and phase 2
|
|
441
|
+
# read. They are restored around the stored core rather than
|
|
442
|
+
# widened into it, so the stored JSON stays the canonical core
|
|
443
|
+
# the manifest hash is computed over while the reconstructed
|
|
444
|
+
# record is field-for-field what a fresh validation returns.
|
|
445
|
+
normalized = {
|
|
446
|
+
"v": _jl.LINE_VERSION,
|
|
447
|
+
"t": "correction",
|
|
448
|
+
**json.loads(row.action_core_json),
|
|
449
|
+
"batch": row.batch_id,
|
|
450
|
+
"seq": int(row.key),
|
|
451
|
+
}
|
|
452
|
+
fold.actions.setdefault(row.batch_id, {})[int(row.key)] = (
|
|
453
|
+
normalized, row.record_digest, row.sequence,
|
|
454
|
+
)
|
|
455
|
+
for row in rows.batches:
|
|
456
|
+
if row.status == _TAINTED:
|
|
457
|
+
fold.tainted_batches.add(row.batch_id)
|
|
458
|
+
elif row.status == _COMPLETED:
|
|
459
|
+
fold.completed.add(row.batch_id)
|
|
460
|
+
# `fold.violations` is deliberately NOT seeded. Phase 2 never reads it, and
|
|
461
|
+
# `_merged_violation_rows` decides each stored row against the re-resolved
|
|
462
|
+
# verdict instead: it keeps a phase-1 row (not re-derivable from the first
|
|
463
|
+
# record this seeds) and an acknowledged one (whose richer JSON a
|
|
464
|
+
# re-derivation would not reproduce), and drops a phase-2 row the
|
|
465
|
+
# re-resolution withdrew.
|
|
466
|
+
return fold
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _event_from_row(row) -> "object":
|
|
470
|
+
return _jl.EffectiveEvent(
|
|
471
|
+
event_id=row.event_id,
|
|
472
|
+
rev=row.rev,
|
|
473
|
+
status=row.status,
|
|
474
|
+
content_hash=row.content_hash,
|
|
475
|
+
batch_id=row.batch_id,
|
|
476
|
+
record=(
|
|
477
|
+
None if row.event_json is None else json.loads(row.event_json)
|
|
478
|
+
),
|
|
479
|
+
sequence=row.winning_sequence,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _merge_candidates(rows: SelectorRows, candidates):
|
|
484
|
+
"""Fold new candidates into the durable winners, reproducing #374 exactly.
|
|
485
|
+
|
|
486
|
+
Only the WINNING revision is seeded, and that is sufficient: a candidate
|
|
487
|
+
below the winning revision can never take the winner, and its same-revision
|
|
488
|
+
group is filtered out by the revision-scoping rule anyway.
|
|
489
|
+
|
|
490
|
+
Returns winners for the event ids the DELTA NAMES, not for every durable
|
|
491
|
+
row. That scoping is what keeps the live path affordable: this runs inside
|
|
492
|
+
an ingest tick, and materializing every stored winner would parse each
|
|
493
|
+
row's retained record — at most 34,644 JSON documents on the maintainer's
|
|
494
|
+
journal, an upper bound rather than a count, because a row whose
|
|
495
|
+
`event_json` is NULL parses nothing — on a tick that names two or three of
|
|
496
|
+
them. An untouched winner
|
|
497
|
+
cannot change, so its row passes through verbatim (see
|
|
498
|
+
`_merged_effective_rows`).
|
|
499
|
+
"""
|
|
500
|
+
prior_rows = {row.event_id: row for row in rows.effective}
|
|
501
|
+
winners: dict = {}
|
|
502
|
+
conflicts: dict = {}
|
|
503
|
+
|
|
504
|
+
def seeded(event_id):
|
|
505
|
+
"""The durable winner for ``event_id``, materialized on first use."""
|
|
506
|
+
if event_id in winners:
|
|
507
|
+
return winners[event_id]
|
|
508
|
+
row = prior_rows.get(event_id)
|
|
509
|
+
if row is None:
|
|
510
|
+
return None
|
|
511
|
+
if row.conflict_hashes_json is not None:
|
|
512
|
+
conflicts.setdefault(
|
|
513
|
+
event_id, set(json.loads(row.conflict_hashes_json)))
|
|
514
|
+
return _event_from_row(row)
|
|
515
|
+
|
|
516
|
+
for candidate in sorted(candidates, key=lambda item: item.sequence):
|
|
517
|
+
prior = seeded(candidate.event_id)
|
|
518
|
+
if prior is None or candidate.rev > prior.rev:
|
|
519
|
+
winners[candidate.event_id] = candidate
|
|
520
|
+
conflicts.pop(candidate.event_id, None)
|
|
521
|
+
continue
|
|
522
|
+
if candidate.rev < prior.rev:
|
|
523
|
+
winners.setdefault(candidate.event_id, prior)
|
|
524
|
+
continue
|
|
525
|
+
if (
|
|
526
|
+
prior.content_hash == candidate.content_hash
|
|
527
|
+
and prior.status == candidate.status
|
|
528
|
+
):
|
|
529
|
+
if prior.sequence is None:
|
|
530
|
+
# A durable row with no winning sequence came from the LIVE emit
|
|
531
|
+
# path, which writes the six legacy columns for an evt it
|
|
532
|
+
# journals past the cycle's own high-water. The next cycle reads
|
|
533
|
+
# that line and folds it here at a known sequence, so adopting
|
|
534
|
+
# the candidate replaces an unknown with the number a full
|
|
535
|
+
# derivation would compute. Without it the row stays sequenceless
|
|
536
|
+
# forever and `stats_index_matches_journal_prefix` can never
|
|
537
|
+
# agree again, which is the shape of the defect this session was
|
|
538
|
+
# asked to close rather than relocate.
|
|
539
|
+
winners[candidate.event_id] = candidate
|
|
540
|
+
else:
|
|
541
|
+
winners.setdefault(candidate.event_id, prior)
|
|
542
|
+
continue
|
|
543
|
+
if (
|
|
544
|
+
_jl._is_legacy_quota_arming_state(prior)
|
|
545
|
+
and _jl._is_legacy_quota_arming_state(candidate)
|
|
546
|
+
):
|
|
547
|
+
# Legacy qaa carve-out: last-wins AND silent. A pre-#372 arming
|
|
548
|
+
# record deliberately reuses its natural id as a state stream, so
|
|
549
|
+
# successive lines are not a conflict.
|
|
550
|
+
winners[candidate.event_id] = candidate
|
|
551
|
+
continue
|
|
552
|
+
winners.setdefault(candidate.event_id, prior)
|
|
553
|
+
conflicts.setdefault(candidate.event_id, set()).update(
|
|
554
|
+
{prior.content_hash, candidate.content_hash}
|
|
555
|
+
)
|
|
556
|
+
return winners, conflicts
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _merged_effective_rows(rows: SelectorRows, winners, conflicts):
|
|
560
|
+
"""Durable winners advanced by the delta.
|
|
561
|
+
|
|
562
|
+
A row the delta did not name passes through VERBATIM. It cannot have
|
|
563
|
+
changed — nothing else in the fold reaches it — and re-encoding it would
|
|
564
|
+
re-serialize every retained record on every tick.
|
|
565
|
+
"""
|
|
566
|
+
merged = [row for row in rows.effective if row.event_id not in winners]
|
|
567
|
+
for event_id, selected in winners.items():
|
|
568
|
+
event_json = None
|
|
569
|
+
if selected.record is not None:
|
|
570
|
+
event_json = (
|
|
571
|
+
_jl.encode_line(selected.record).decode("utf-8").rstrip("\n")
|
|
572
|
+
)
|
|
573
|
+
hashes = conflicts.get(event_id)
|
|
574
|
+
merged.append(
|
|
575
|
+
SelectorEffectiveRow(
|
|
576
|
+
event_id=event_id,
|
|
577
|
+
rev=selected.rev,
|
|
578
|
+
status=selected.status,
|
|
579
|
+
content_hash=selected.content_hash,
|
|
580
|
+
batch_id=selected.batch_id,
|
|
581
|
+
event_json=event_json,
|
|
582
|
+
winning_sequence=selected.sequence,
|
|
583
|
+
conflict_hashes_json=(
|
|
584
|
+
None if not hashes else _canonical_json(sorted(hashes))
|
|
585
|
+
),
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
merged.sort(key=lambda row: row.event_id)
|
|
589
|
+
return tuple(merged)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
#: The two violation kinds phase 1 establishes, from a DUPLICATE record whose
|
|
593
|
+
#: digest differs from the one already accumulated.
|
|
594
|
+
#:
|
|
595
|
+
#: They are separated from the other five because they behave differently under
|
|
596
|
+
#: re-resolution. A phase-1 violation is MONOTONE: the duplicate is durably in
|
|
597
|
+
#: the journal, so every later full derivation reproduces it — but an
|
|
598
|
+
#: incremental pass cannot, because `_seed_fold` restores only the FIRST record
|
|
599
|
+
#: at each phase and action sequence, which is what the durable rows store. The
|
|
600
|
+
#: five phase-2 kinds are the opposite: `resolve_batches` re-derives all of them
|
|
601
|
+
#: from the accumulated batch state on every pass, and a later record can
|
|
602
|
+
#: WITHDRAW one — an incomplete action set completed by a late action stops
|
|
603
|
+
#: producing `manifest_action_sequence_mismatch`.
|
|
604
|
+
PHASE_ONE_VIOLATION_KINDS = frozenset(
|
|
605
|
+
{"marker_conflict", "action_sequence_conflict"}
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _is_acknowledged(row: SelectorViolationRow) -> bool:
|
|
610
|
+
"""Whether ``row``'s stored JSON is an acknowledged violation's richer shape.
|
|
611
|
+
|
|
612
|
+
`AcknowledgedProtocolViolation.to_dict` adds `auditId`, `journalHighWater`
|
|
613
|
+
and `journalPrefixHash` to the plain violation dict, and an incremental fold
|
|
614
|
+
never carries a resolution — `merge_delta` refuses a delta that contains one
|
|
615
|
+
— so a re-derivation here can never reproduce that shape. Such a row is
|
|
616
|
+
therefore never withdrawn.
|
|
617
|
+
|
|
618
|
+
Retaining it is conservative only about THIS path's own state. It is not
|
|
619
|
+
harmless in general: when a later record genuinely withdraws an acknowledged
|
|
620
|
+
phase-2 violation, a full derivation raises `JournalProtocolError` at
|
|
621
|
+
`bin/_lib_journal.py:1105` for an acknowledgement that resolves nothing, so
|
|
622
|
+
every later rebuild is wedged while the incremental path keeps serving the
|
|
623
|
+
stale row. That is pre-existing — a full derivation reaches the same state
|
|
624
|
+
without this function — and production holds zero
|
|
625
|
+
`journal_protocol_resolution` ops, so nothing here can reach it today.
|
|
626
|
+
"""
|
|
627
|
+
try:
|
|
628
|
+
return "auditId" in json.loads(row.violation_json)
|
|
629
|
+
except (TypeError, ValueError):
|
|
630
|
+
return True
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _with_earliest_available_after(stored, derived):
|
|
634
|
+
"""``stored`` carrying the earlier of the two rows' ``available_after``."""
|
|
635
|
+
if derived is None or derived.available_after is None:
|
|
636
|
+
return stored
|
|
637
|
+
if stored.available_after is None:
|
|
638
|
+
return replace(stored, available_after=derived.available_after)
|
|
639
|
+
if derived.available_after >= stored.available_after:
|
|
640
|
+
return stored
|
|
641
|
+
return replace(stored, available_after=derived.available_after)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _merged_violation_rows(rows: SelectorRows, fold, resolved_batches):
|
|
645
|
+
"""Durable violation rows advanced by the delta, WITHDRAWALS included.
|
|
646
|
+
|
|
647
|
+
``resolved_batches`` names the batches phase 2 actually re-resolved in this
|
|
648
|
+
delta. For exactly those, `fold.violations` is the complete phase-2 verdict a
|
|
649
|
+
full derivation would produce, so a stored phase-2 row the re-resolution did
|
|
650
|
+
not reproduce has been withdrawn and must be dropped rather than unioned
|
|
651
|
+
forward. Unioning left a withdrawn `manifest_action_sequence_mismatch`
|
|
652
|
+
durable after the missing action arrived, which made `doctor`'s
|
|
653
|
+
`journal.protocol` leg FAIL and print a `db journal-repair --violation
|
|
654
|
+
<fingerprint>` command naming a fingerprint no fresh derivation reproduces.
|
|
655
|
+
|
|
656
|
+
Two classes of stored row are never withdrawn. A **phase-1** row is monotone
|
|
657
|
+
and not re-derivable from the seeded fold (`PHASE_ONE_VIOLATION_KINDS`), and
|
|
658
|
+
an **acknowledged** row carries operator audit an incremental pass cannot
|
|
659
|
+
reconstruct.
|
|
660
|
+
|
|
661
|
+
A batch outside ``resolved_batches`` is untouched for the same reason its
|
|
662
|
+
other rows are: it received no record this delta could fold.
|
|
663
|
+
|
|
664
|
+
**The stored row wins for a fingerprint both produce, except on
|
|
665
|
+
``available_after``.** Stored-wins is required, because an acknowledged row's
|
|
666
|
+
richer JSON is exactly what a re-derivation cannot reproduce. But
|
|
667
|
+
`available_after` is not one of the fingerprint's inputs, so the two rows can
|
|
668
|
+
legitimately disagree on it, and it is a MINIMUM over sightings
|
|
669
|
+
(`SelectorFold.taint`) rather than a last-write value. Taking the pointwise
|
|
670
|
+
minimum is therefore the correct merge in both directions: it can never
|
|
671
|
+
regress an earlier boundary a longer derivation established, and it adopts a
|
|
672
|
+
value for a stored row that a four-column fallback left NULL.
|
|
673
|
+
"""
|
|
674
|
+
derived = {}
|
|
675
|
+
for (batch_id, kind, fingerprint), violation in fold.violations.items():
|
|
676
|
+
derived[fingerprint] = SelectorViolationRow(
|
|
677
|
+
fingerprint=fingerprint,
|
|
678
|
+
batch_id=batch_id,
|
|
679
|
+
kind=kind,
|
|
680
|
+
violation_json=_canonical_json(violation.to_dict()),
|
|
681
|
+
available_after=fold.violation_available_after.get(fingerprint),
|
|
682
|
+
)
|
|
683
|
+
merged: dict = {}
|
|
684
|
+
for row in rows.violations:
|
|
685
|
+
if (
|
|
686
|
+
row.batch_id in resolved_batches
|
|
687
|
+
and row.fingerprint not in derived
|
|
688
|
+
and row.kind not in PHASE_ONE_VIOLATION_KINDS
|
|
689
|
+
and not _is_acknowledged(row)
|
|
690
|
+
):
|
|
691
|
+
continue
|
|
692
|
+
merged[row.fingerprint] = _with_earliest_available_after(
|
|
693
|
+
row, derived.get(row.fingerprint))
|
|
694
|
+
for fingerprint, row in derived.items():
|
|
695
|
+
merged.setdefault(fingerprint, row)
|
|
696
|
+
ordered = sorted(
|
|
697
|
+
merged.values(), key=lambda row: (row.batch_id, row.kind, row.fingerprint)
|
|
698
|
+
)
|
|
699
|
+
return tuple(ordered)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
#: Record types `_lib_journal._fold_one` actually consumes. Everything else
|
|
703
|
+
#: leaves the fold untouched and only consumes a sequence number.
|
|
704
|
+
FOLD_RECORD_TYPES = frozenset({"evt", "correction", "correction_batch"})
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def delta_batch_scope(records) -> set:
|
|
708
|
+
"""Every correction batch a delta of ``records`` can reach.
|
|
709
|
+
|
|
710
|
+
A `correction_batch` names its batch through `id` and a `correction` through
|
|
711
|
+
`batch`. A batch the delta does not name receives no new record, so phase 1
|
|
712
|
+
cannot taint it and phase 2 cannot change its verdict, and its durable rows
|
|
713
|
+
stand untouched — which is what lets the caller read `journal_selector_
|
|
714
|
+
batches` and `journal_selector_batch_records` scoped to this set instead of
|
|
715
|
+
materializing all 64,248 batch-record rows a production journal holds on
|
|
716
|
+
every tick.
|
|
717
|
+
"""
|
|
718
|
+
scope = set()
|
|
719
|
+
for record in records:
|
|
720
|
+
if not isinstance(record, dict):
|
|
721
|
+
continue
|
|
722
|
+
kind = record.get("t")
|
|
723
|
+
if kind == "correction_batch":
|
|
724
|
+
batch_id = record.get("id")
|
|
725
|
+
elif kind == "correction":
|
|
726
|
+
batch_id = record.get("batch")
|
|
727
|
+
else:
|
|
728
|
+
continue
|
|
729
|
+
if isinstance(batch_id, str):
|
|
730
|
+
scope.add(batch_id)
|
|
731
|
+
return scope
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def delta_event_scope(records, batch_records=()) -> set:
|
|
735
|
+
"""Every event id `_merge_candidates` can look a durable winner up for.
|
|
736
|
+
|
|
737
|
+
Three sources, and all three are necessary:
|
|
738
|
+
|
|
739
|
+
- an `evt` record is a candidate for its own `id`;
|
|
740
|
+
- a `correction` action replaces the event named by its `id`;
|
|
741
|
+
- an action of a `begin_only` or `tainted` batch that arrived in an EARLIER
|
|
742
|
+
generation names an id no delta record mentions. That is the split-cycle
|
|
743
|
+
case, and it is exactly why those batches retain their action cores.
|
|
744
|
+
|
|
745
|
+
A `completed` batch is skipped by phase 2 and produces no candidate, so its
|
|
746
|
+
dropped cores cannot widen the scope and their absence is not a gap.
|
|
747
|
+
"""
|
|
748
|
+
scope = set()
|
|
749
|
+
for record in records:
|
|
750
|
+
if not isinstance(record, dict):
|
|
751
|
+
continue
|
|
752
|
+
if record.get("t") in ("evt", "correction"):
|
|
753
|
+
event_id = record.get("id")
|
|
754
|
+
if isinstance(event_id, str):
|
|
755
|
+
scope.add(event_id)
|
|
756
|
+
for row in batch_records:
|
|
757
|
+
if row.kind != "action" or row.action_core_json is None:
|
|
758
|
+
continue
|
|
759
|
+
event_id = json.loads(row.action_core_json).get("id")
|
|
760
|
+
if isinstance(event_id, str):
|
|
761
|
+
scope.add(event_id)
|
|
762
|
+
return scope
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def advance_counter(
|
|
766
|
+
rows: SelectorRows,
|
|
767
|
+
*,
|
|
768
|
+
consumed: int,
|
|
769
|
+
covered=None,
|
|
770
|
+
) -> SelectorRows:
|
|
771
|
+
"""Advance ONLY the prefix counters, reusing every row object.
|
|
772
|
+
|
|
773
|
+
For a delta the fold does not consume — observations and ordinary ops — the
|
|
774
|
+
merge is the identity on all four row groups, and running it anyway would
|
|
775
|
+
make an ordinary status-line tick pay for the whole durable generation. The
|
|
776
|
+
row tuples are returned by reference, which is also what lets the glue's
|
|
777
|
+
delta writer skip those groups outright rather than diff them.
|
|
778
|
+
|
|
779
|
+
The cutover pair carries forward and cannot be set here. An incremental pass
|
|
780
|
+
may never adopt a cutover operation the durable prefix has not folded: a full
|
|
781
|
+
derivation applies the legacy account stamp to every legacy Claude line in
|
|
782
|
+
that prefix, changing those events' `content_hash` and `event_json`, and this
|
|
783
|
+
path normalizes only the delta. The caller falls back instead.
|
|
784
|
+
"""
|
|
785
|
+
return SelectorRows(
|
|
786
|
+
state=replace(
|
|
787
|
+
rows.state,
|
|
788
|
+
next_sequence=consumed,
|
|
789
|
+
selector_version=SELECTOR_VERSION,
|
|
790
|
+
covered_segment=(
|
|
791
|
+
rows.state.covered_segment if covered is None else covered[0]),
|
|
792
|
+
covered_offset=(
|
|
793
|
+
rows.state.covered_offset if covered is None
|
|
794
|
+
else int(covered[1])),
|
|
795
|
+
),
|
|
796
|
+
batches=rows.batches,
|
|
797
|
+
batch_records=rows.batch_records,
|
|
798
|
+
effective=rows.effective,
|
|
799
|
+
violations=rows.violations,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def merge_delta(
|
|
804
|
+
rows: SelectorRows,
|
|
805
|
+
new_records,
|
|
806
|
+
*,
|
|
807
|
+
next_sequence: int,
|
|
808
|
+
coordinates=None,
|
|
809
|
+
covered=None,
|
|
810
|
+
):
|
|
811
|
+
"""Continue the fold over ``new_records`` from durable state.
|
|
812
|
+
|
|
813
|
+
``next_sequence`` is the sequence the FIRST new entry takes, which is the
|
|
814
|
+
durable state's own `next_sequence`.
|
|
815
|
+
|
|
816
|
+
Returns ``(rows, transitions)``. ``transitions`` names every batch whose
|
|
817
|
+
durable status was `completed` and which this delta tainted; the caller
|
|
818
|
+
turns each into a `CorrectionRebuildRequired` bounded at the causal record,
|
|
819
|
+
because carrying a stale `completed` status forward is the one way an
|
|
820
|
+
incremental path can make the pre-existing #510 staleness worse.
|
|
821
|
+
|
|
822
|
+
The cutover pair carries forward and cannot be set here, for the reason
|
|
823
|
+
`advance_counter` gives.
|
|
824
|
+
|
|
825
|
+
Raises :class:`IncrementalSelectionUnavailable` when the delta contains a
|
|
826
|
+
`journal_protocol_resolution` op: acknowledging a violation authenticates an
|
|
827
|
+
exact length-framed raw-prefix SHA-256, a claimed hash is never accepted
|
|
828
|
+
without recomputation, and no durable summary can reconstruct it.
|
|
829
|
+
"""
|
|
830
|
+
fold = _seed_fold(rows)
|
|
831
|
+
seeded_completed = {
|
|
832
|
+
row.batch_id for row in rows.batches if row.status == _COMPLETED
|
|
833
|
+
}
|
|
834
|
+
# The shape a completed batch had when its cores were dropped. Phase 2 is
|
|
835
|
+
# skipped for these batches, so anything the delta ADDS to one of them is
|
|
836
|
+
# never folded into a verdict — see the refusal below.
|
|
837
|
+
seeded_shape = {
|
|
838
|
+
batch_id: (
|
|
839
|
+
frozenset(fold.markers.get(batch_id, {})),
|
|
840
|
+
frozenset(fold.actions.get(batch_id, {})),
|
|
841
|
+
)
|
|
842
|
+
for batch_id in seeded_completed
|
|
843
|
+
}
|
|
844
|
+
consumed = _jl.fold_records(fold, new_records, start_sequence=next_sequence)
|
|
845
|
+
if fold.resolutions:
|
|
846
|
+
raise IncrementalSelectionUnavailable(
|
|
847
|
+
"a journal_protocol_resolution op requires a verified raw-prefix "
|
|
848
|
+
"read; fall back to full selection"
|
|
849
|
+
)
|
|
850
|
+
for batch_id, (markers, actions) in sorted(seeded_shape.items()):
|
|
851
|
+
# A DUPLICATE marker or action is safe to carry forward: phase 1 keeps
|
|
852
|
+
# the first record and taints from the retained whole-record digest when
|
|
853
|
+
# the duplicate differs, which the transition loop below then reports. A
|
|
854
|
+
# record at a phase or action sequence the durable rows do not hold is
|
|
855
|
+
# different in kind — a full pass would re-run phase 2 over the widened
|
|
856
|
+
# set and could taint the batch (`manifest_action_sequence_mismatch`,
|
|
857
|
+
# `record_order_violation`), while this path would keep it completed and
|
|
858
|
+
# raise nothing. The cores were dropped at completion, so re-deriving the
|
|
859
|
+
# verdict here is not an option; refusing is.
|
|
860
|
+
if (
|
|
861
|
+
frozenset(fold.markers.get(batch_id, {})) != markers
|
|
862
|
+
or frozenset(fold.actions.get(batch_id, {})) != actions
|
|
863
|
+
):
|
|
864
|
+
raise IncrementalSelectionUnavailable(
|
|
865
|
+
f"batch {batch_id} is durably completed and the delta adds a "
|
|
866
|
+
"marker phase or action sequence its stored rows do not hold; "
|
|
867
|
+
"fall back to full selection"
|
|
868
|
+
)
|
|
869
|
+
resolved_batches = (
|
|
870
|
+
set(fold.markers) | set(fold.actions)
|
|
871
|
+
) - seeded_completed
|
|
872
|
+
_jl.resolve_batches(fold, batch_ids=resolved_batches)
|
|
873
|
+
fold.completed |= seeded_completed - fold.tainted_batches
|
|
874
|
+
|
|
875
|
+
transitions = []
|
|
876
|
+
for batch_id in sorted(seeded_completed & fold.tainted_batches):
|
|
877
|
+
# A completed batch carries no violations — `completed` and `tainted`
|
|
878
|
+
# are disjoint — so every violation now standing against it was
|
|
879
|
+
# established by THIS delta, and the earliest of them is the record that
|
|
880
|
+
# caused the transition.
|
|
881
|
+
causal = [
|
|
882
|
+
fold.violation_available_after[fingerprint]
|
|
883
|
+
for (candidate_batch, _kind, fingerprint) in fold.violations
|
|
884
|
+
if candidate_batch == batch_id
|
|
885
|
+
and fingerprint in fold.violation_available_after
|
|
886
|
+
]
|
|
887
|
+
if not causal:
|
|
888
|
+
# The causal offset is MANDATORY and this path fails closed without
|
|
889
|
+
# it. Substituting the pinned high-water is unsafe: it is `st_size`,
|
|
890
|
+
# `_iter_segment_lines` omits an incomplete trailing line, and
|
|
891
|
+
# torn-tail repair can truncate below it, so a cursor written there
|
|
892
|
+
# can sit beyond unread data (spec §3.7).
|
|
893
|
+
raise IncrementalSelectionUnavailable(
|
|
894
|
+
f"batch {batch_id} moved completed -> tainted with no causal "
|
|
895
|
+
"offset; fall back to full selection"
|
|
896
|
+
)
|
|
897
|
+
transitions.append(
|
|
898
|
+
TaintTransition(batch_id=batch_id, causal_sequence=min(causal))
|
|
899
|
+
)
|
|
900
|
+
# CAUSAL order, not batch-id order. The caller raises on the FIRST
|
|
901
|
+
# transition, so raising the batch whose causal record sits later would
|
|
902
|
+
# rebuild through a longer prefix than necessary. Both orders converge;
|
|
903
|
+
# this one converges through the narrowest prefix.
|
|
904
|
+
transitions.sort(key=lambda item: (item.causal_sequence, item.batch_id))
|
|
905
|
+
|
|
906
|
+
winners, conflicts = _merge_candidates(rows, fold.candidates)
|
|
907
|
+
batches, batch_records = _batch_rows(fold, coordinates)
|
|
908
|
+
merged_batches = _carry_coordinates(rows, batches)
|
|
909
|
+
state = replace(
|
|
910
|
+
rows.state,
|
|
911
|
+
next_sequence=consumed,
|
|
912
|
+
selector_version=SELECTOR_VERSION,
|
|
913
|
+
covered_segment=(
|
|
914
|
+
rows.state.covered_segment if covered is None else covered[0]),
|
|
915
|
+
covered_offset=(
|
|
916
|
+
rows.state.covered_offset if covered is None else int(covered[1])),
|
|
917
|
+
)
|
|
918
|
+
return (
|
|
919
|
+
SelectorRows(
|
|
920
|
+
state=state,
|
|
921
|
+
batches=merged_batches,
|
|
922
|
+
batch_records=batch_records,
|
|
923
|
+
effective=_merged_effective_rows(rows, winners, conflicts),
|
|
924
|
+
violations=_merged_violation_rows(rows, fold, resolved_batches),
|
|
925
|
+
),
|
|
926
|
+
transitions,
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _carry_coordinates(rows: SelectorRows, batches):
|
|
931
|
+
"""Keep a coordinate the durable row already holds.
|
|
932
|
+
|
|
933
|
+
A delta's `coordinates` map covers only the delta's own records, so a batch
|
|
934
|
+
whose begin marker arrived in an earlier generation would otherwise lose the
|
|
935
|
+
coordinate that generation resolved.
|
|
936
|
+
"""
|
|
937
|
+
prior = {row.batch_id: row for row in rows.batches}
|
|
938
|
+
carried = []
|
|
939
|
+
for row in batches:
|
|
940
|
+
old = prior.get(row.batch_id)
|
|
941
|
+
if old is None:
|
|
942
|
+
carried.append(row)
|
|
943
|
+
continue
|
|
944
|
+
carried.append(
|
|
945
|
+
replace(
|
|
946
|
+
row,
|
|
947
|
+
begin_segment=row.begin_segment or old.begin_segment,
|
|
948
|
+
begin_offset=(
|
|
949
|
+
row.begin_offset if row.begin_offset is not None
|
|
950
|
+
else old.begin_offset),
|
|
951
|
+
earliest_commit_segment=(
|
|
952
|
+
row.earliest_commit_segment or old.earliest_commit_segment),
|
|
953
|
+
earliest_commit_offset=(
|
|
954
|
+
row.earliest_commit_offset
|
|
955
|
+
if row.earliest_commit_offset is not None
|
|
956
|
+
else old.earliest_commit_offset),
|
|
957
|
+
)
|
|
958
|
+
)
|
|
959
|
+
return tuple(carried)
|