forked from chiki2bum2/SniperGold_ML
89 lines
2.7 KiB
Python
89 lines
2.7 KiB
Python
"""Commit journal (spec 13.1): append-only, hash-chained, fail-closed.
| |||
| |||
Each line is ``{"seq", "chunk", "content_id", "file_sha256", "prev",
| |||
"ts_utc"}``; ``prev`` is the hash of the previous line's exact UTF-8 text
| |||
(SHA-256 of the canonical JSON text of the previous line, no trailing
| |||
newline). seq 0 uses SHA-256 of the empty string as prev. ``journal_tail``
| |||
recorded in the checkpoint is (seq, hash of last line).
| |||
"""
| |||
| |||
import os
| |||
| |||
from .util import canonical_json, sha256_bytes, atomic_write_bytes
| |||
| |||
EMPTY_HASH = sha256_bytes(b"")
| |||
| |||
| |||
class JournalError(Exception):
| |||
pass
| |||
| |||
| |||
def _line_for(record):
| |||
return canonical_json(record)
| |||
| |||
| |||
def _line_hash(line_text):
| |||
return sha256_bytes(line_text)
| |||
| |||
| |||
def append_commit(journal_path, seq, chunk_index, content_id, file_sha256,
| |||
ts_utc, prev_hash):
| |||
"""Append one commit line atomically (fail-closed: fsync)."""
| |||
record = {
| |||
"seq": seq,
| |||
"chunk": chunk_index,
| |||
"content_id": content_id,
| |||
"file_sha256": file_sha256,
| |||
"prev": prev_hash,
| |||
"ts_utc": ts_utc,
| |||
}
| |||
line = _line_for(record)
| |||
os.makedirs(os.path.dirname(os.path.abspath(journal_path)), exist_ok=True)
| |||
with open(journal_path, "ab") as fh:
| |||
fh.write(line.encode("utf-8"))
| |||
fh.flush()
| |||
os.fsync(fh.fileno())
| |||
# Line hash is defined over the line text WITHOUT the trailing newline
| |||
# (read_journal recomputes it the same way).
| |||
return _line_hash(line.rstrip("\n"))
| |||
| |||
| |||
def read_journal(journal_path):
| |||
"""Read and validate the chain. Returns (records, tail_hash).
| |||
Raises JournalError on chain break or malformed line."""
| |||
if not os.path.exists(journal_path):
| |||
return [], EMPTY_HASH
| |||
records = []
| |||
prev = EMPTY_HASH
| |||
tail = EMPTY_HASH
| |||
with open(journal_path, "rb") as fh:
| |||
for raw in fh:
| |||
text = raw.rstrip(b"\r\n")
| |||
if not text:
| |||
continue
| |||
if raw.endswith(b"\r\n"):
| |||
line = raw[:-2]
| |||
elif raw.endswith(b"\n"):
| |||
line = raw[:-1]
| |||
else:
| |||
line = raw
| |||
try:
| |||
import json
| |||
record = json.loads(line.decode("utf-8"))
| |||
except Exception as e:
| |||
raise JournalError("journal line unparseable: %s" % e)
| |||
if record.get("prev") != prev:
| |||
raise JournalError("journal chain broken at seq %r"
| |||
% record.get("seq"))
| |||
lh = _line_hash(line.decode("utf-8"))
| |||
records.append(record)
| |||
prev = lh
| |||
tail = lh
| |||
return records, tail
| |||
| |||
| |||
def seq_after(records):
| |||
"""Next seq = last seq + 1 (0 when journal is empty)."""
| |||
if not records:
| |||
return 0
| |||
return records[-1].get("seq", -1) + 1
|