Files
turnstone/turnstone/core/attachment_buffer.py
T
Patrick Buckley 0e0d0bbf72 fix: pre-push review fixes from the canonical-trajectory deep-dive
A deep-dive review of the branch surfaced a budget regression, SDK doc
drift, dead code, and stale docstrings. Each was boundary-spiked before
fixing.

- R1 (regression): by-reference document attachments were invisible to the
  token budget — _msg_text_chars returned 0 doc_chars for a
  {type:document,attachment_id} placeholder, and the comment's claim that
  the budget "lands at calibration" was false (calibration discards
  doc_chars). Thread the doc size through _attachments_meta (size_bytes, at
  both the live-append and reconstruct build sites) and count it in
  _msg_text_chars, guarded against double-counting the inline form.
  Regression test added.
- F1: the history-DTO schema description and the TS HistoryEvent docstring
  still advertised the removed reminders/advisories keys and omitted the
  system role; corrected server_schemas.py + sdk/typescript/src/events.ts to
  match the shipped shape. The committed OpenAPI JSON snapshots were already
  ~679 lines stale on main; their regen is left to its own chore branch.
- D1: removed AttachmentBuffer.take() — dead (no production caller; the
  commit path uses discard()) and scope-weak (ws_id only, unlike its
  siblings) — with its test and the now-orphaned Iterable import.
- O1: 4 docstrings referenced the moved ChatSession._fold_system_turns →
  lowering.fold_system_turns.
2026-06-04 11:03:13 -07:00

143 lines
5.3 KiB
Python

"""Per-node in-memory buffer for pending (uploaded-but-unsent) attachments.
In the content-addressed attachment model, blob bytes are written to
``workstream_attachments`` only at send-commit, so every *stored* blob is born
referenced (refcount >= 1). Between the upload request and the send that references it,
the bytes live here — keyed by their content hash, scoped to ``(ws_id, user_id)``, and
bounded by a TTL plus a total-size ceiling so a flood of unsent uploads can't exhaust a
node. ws->node affinity (HRW routing) guarantees the upload and the later send hit the
same node, so this buffer is process-local.
Losing an unsent upload on a crash / restart / node re-route is acceptable: it is
pre-commit transient state, never committed data — the user re-uploads. This is the
deliberate simplification that replaces the persisted pending/reserved/consumed
lifecycle (and its orphan-sweep) the old upload flow carried.
"""
from __future__ import annotations
import hashlib
import threading
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
# OOM-safety backstops (per node), not a product policy — the per-user upload cap was
# removed. Generous: the goal is only to bound a pathological flood of unsent uploads.
_DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024 # 1 GiB
_DEFAULT_TTL_SECONDS = 3600.0 # unsent uploads expire after an hour
@dataclass(frozen=True, slots=True)
class StagedAttachment:
"""A pending upload held in memory until the send that references it commits."""
attachment_id: str # content hash (sha256 hex) — also the content-addressed blob id
ws_id: str
user_id: str
filename: str
mime_type: str
kind: str # 'image' | 'text'
content: bytes
staged_at: float
@property
def size_bytes(self) -> int:
return len(self.content)
class AttachmentBuffer:
"""Thread-safe, size- and TTL-bounded store of pending uploads, keyed by content hash."""
def __init__(
self,
*,
max_total_bytes: int = _DEFAULT_MAX_TOTAL_BYTES,
ttl_seconds: float = _DEFAULT_TTL_SECONDS,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._lock = threading.Lock()
self._entries: dict[str, StagedAttachment] = {}
self._max_total_bytes = max_total_bytes
self._ttl = ttl_seconds
self._clock = clock
def stage(
self, *, ws_id: str, user_id: str, filename: str, mime_type: str, kind: str, content: bytes
) -> StagedAttachment:
"""Hold *content* until a send commits it; returns the staged entry.
The id is the content hash, so re-uploading identical bytes is idempotent (and
dedupes against the eventual content-addressed blob).
"""
handle = hashlib.sha256(content).hexdigest()
entry = StagedAttachment(
attachment_id=handle,
ws_id=ws_id,
user_id=user_id,
filename=filename,
mime_type=mime_type,
kind=kind,
content=content,
staged_at=self._clock(),
)
with self._lock:
self._evict_expired_locked()
self._entries[handle] = entry
self._evict_over_capacity_locked()
return entry
def get(self, handle: str, *, ws_id: str, user_id: str) -> StagedAttachment | None:
"""Fetch a pending upload, enforcing the uploader's scope (else ``None``)."""
with self._lock:
entry = self._entries.get(handle)
if entry is None or entry.ws_id != ws_id or entry.user_id != user_id:
return None
return entry
def list_for(self, *, ws_id: str, user_id: str) -> list[StagedAttachment]:
"""Pending uploads for ``(ws_id, user_id)``."""
with self._lock:
self._evict_expired_locked()
return [e for e in self._entries.values() if e.ws_id == ws_id and e.user_id == user_id]
def discard(self, handle: str, *, ws_id: str, user_id: str) -> bool:
"""Drop a pending upload (scope-checked); ``True`` if it existed."""
with self._lock:
entry = self._entries.get(handle)
if entry is None or entry.ws_id != ws_id or entry.user_id != user_id:
return False
del self._entries[handle]
return True
# -- eviction (caller holds the lock) ------------------------------------
def _evict_expired_locked(self) -> None:
if self._ttl <= 0:
return
cutoff = self._clock() - self._ttl
stale = [h for h, e in self._entries.items() if e.staged_at < cutoff]
for handle in stale:
del self._entries[handle]
def _evict_over_capacity_locked(self) -> None:
total = sum(e.size_bytes for e in self._entries.values())
if total <= self._max_total_bytes:
return
# Evict oldest-first until under the ceiling.
for handle, entry in sorted(self._entries.items(), key=lambda kv: kv[1].staged_at):
if total <= self._max_total_bytes:
break
total -= entry.size_bytes
del self._entries[handle]
_BUFFER = AttachmentBuffer()
def get_attachment_buffer() -> AttachmentBuffer:
"""The per-node pending-upload buffer singleton."""
return _BUFFER