This commit is contained in:
2026-08-26 17:42:00 -04:00
commit ad95d530ad
134 changed files with 31750 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
from speech_to_speech.pipeline.cancel_scope import CancelScope
from speech_to_speech.pipeline.control import (
SESSION_END,
ControlKind,
PipelineControlMessage,
is_control_message,
)
from speech_to_speech.pipeline.events import (
AssistantTextEvent,
PartialTranscriptionEvent,
PipelineEvent,
SpeechStartedEvent,
SpeechStoppedEvent,
TokenUsageEvent,
TranscriptionCompletedEvent,
)
from speech_to_speech.pipeline.messages import (
AUDIO_RESPONSE_DONE,
PIPELINE_END,
EndOfResponse,
GenerateResponseRequest,
LLMResponseChunk,
PartialTranscription,
PipelineMessage,
TokenUsage,
Transcription,
TTSInput,
VADAudio,
)
__all__ = [
"AUDIO_RESPONSE_DONE",
"AssistantTextEvent",
"CancelScope",
"ControlKind",
"EndOfResponse",
"GenerateResponseRequest",
"LLMResponseChunk",
"PIPELINE_END",
"PartialTranscription",
"PartialTranscriptionEvent",
"PipelineControlMessage",
"PipelineEvent",
"PipelineMessage",
"SESSION_END",
"SpeechStartedEvent",
"SpeechStoppedEvent",
"TTSInput",
"TokenUsage",
"TokenUsageEvent",
"Transcription",
"TranscriptionCompletedEvent",
"VADAudio",
"is_control_message",
]
+64
View File
@@ -0,0 +1,64 @@
class CancelScope:
"""Unified cancellation signal for the speech-to-speech pipeline.
Uses a generation counter so pipeline threads (LLM, TTS) can detect
cancellation without brief-pulse timing games, and an internal
``discarding`` flag so the async send loop can drop stale output.
Thread safety: one writer (the asyncio router thread) and multiple
readers (pipeline handler threads). Python's GIL makes int/bool
reads and writes atomic at the bytecode level, so no lock is needed.
"""
def __init__(self) -> None:
self._gen: int = 0
self._discarding: bool = False
self._discarded_generation: int | None = None
@property
def generation(self) -> int:
"""Current generation number. Pipeline threads capture this at
the start of each response and compare with ``is_stale``."""
return self._gen
def cancel(self) -> None:
"""Cancel the current response.
Increments the generation (so pipeline threads see their captured
generation as stale) and enables the send-loop discard guard.
"""
# prevent overflow... after 4 billion generations, we'll wrap around xD...
self._discarded_generation = self._gen
self._gen = (self._gen + 1) & 0xFFFFFFFF
self._discarding = True
def response_done(self, generation: int | None = None) -> None:
"""Pipeline acknowledged completion. Clears the discard guard."""
if (
generation is not None
and self._discarded_generation is not None
and generation not in {self._discarded_generation, self._gen}
):
return
self._discarding = False
self._discarded_generation = None
def new_response(self) -> None:
"""An explicit ``response.create`` starts a new response.
Clears the discard guard."""
self._discarding = False
self._discarded_generation = None
def is_stale(self, gen: int) -> bool:
"""Return True if *gen* has been superseded by a ``cancel`` call."""
return gen != self._gen
@property
def discarding(self) -> bool:
"""Whether the send loop should silently drop stale output."""
return self._discarding
def reset(self) -> None:
"""Clear discard state (e.g. on new session connect)."""
self._discarding = False
self._discarded_generation = None
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class ControlKind(str, Enum):
"""Strongly-typed kinds for :class:`PipelineControlMessage`."""
SESSION_END = "session_end"
@dataclass(frozen=True)
class PipelineControlMessage:
kind: ControlKind
# Session that enqueued the message, when known. Lets the pooled realtime
# send loop ignore a SESSION_END from a force-released session so it can't
# satisfy the drain wait of the session that claimed the unit afterwards.
session_id: str | None = None
SESSION_END = PipelineControlMessage(ControlKind.SESSION_END)
def is_control_message(message: object, kind: ControlKind | None = None) -> bool:
return isinstance(message, PipelineControlMessage) and (kind is None or message.kind == kind)
+99
View File
@@ -0,0 +1,99 @@
"""Typed events flowing on ``text_output_queue``.
These are internal pipeline events produced by VAD, TranscriptionNotifier, and
LMOutputProcessor, consumed by the realtime ``WebSocketRouter`` send-loop and
``RealtimeService.dispatch_pipeline_event``. They replace the raw ``dict``
literals that were previously put on the queue.
"""
from __future__ import annotations
from typing import Literal, Optional
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
from pydantic import BaseModel, Field
class PipelineEvent(BaseModel):
"""Base for all text_output_queue events.
The ``type`` field mirrors the former dict ``"type"`` key and acts as a
Pydantic discriminator.
"""
type: str
# ── VAD events ────────────────────────────────────────────────────────
class SpeechStartedEvent(PipelineEvent):
type: Literal["speech_started"] = "speech_started"
audio_start_ms: int = 0
turn_id: str | None = None
turn_revision: int | None = None
reopened: bool = False
interrupt_response: bool = Field(default=True, exclude=True)
class SpeechStoppedEvent(PipelineEvent):
type: Literal["speech_stopped"] = "speech_stopped"
duration_s: float = 0.0
audio_end_ms: int = 0
turn_id: str | None = None
turn_revision: int | None = None
# ── Transcription events (TranscriptionNotifier) ─────────────────────
class PartialTranscriptionEvent(PipelineEvent):
type: Literal["partial_transcription"] = "partial_transcription"
delta: str
turn_id: str | None = None
turn_revision: int | None = None
class TranscriptionCompletedEvent(PipelineEvent):
type: Literal["transcription_completed"] = "transcription_completed"
transcript: str
language_code: Optional[str] = None
turn_id: str | None = None
turn_revision: int | None = None
speech_stopped_at_s: float | None = Field(default=None, exclude=True)
# ── LLM output events (LMOutputProcessor) ────────────────────────────
class AssistantTextEvent(PipelineEvent):
type: Literal["assistant_text"] = "assistant_text"
text: str
tools: list[ResponseFunctionToolCall] = Field(default_factory=list)
turn_id: str | None = None
turn_revision: int | None = None
# Response generation that produced this text, mirroring AudioOutput. Lets the
# send loop discard stale assistant text by the same generation-aware rule as
# audio, instead of blanket-dropping while cancel_scope.discarding is set.
cancel_generation: int | None = None
class TokenUsageEvent(PipelineEvent):
type: Literal["token_usage"] = "token_usage"
input_tokens: int = 0
output_tokens: int = 0
turn_id: str | None = None
turn_revision: int | None = None
class ResponseFailedEvent(PipelineEvent):
"""Signals that a response could not be generated (e.g. invalid out-of-band input).
Dispatched to the service so it can close the in-progress response with
``status="failed"`` instead of the usual ``completed``.
"""
type: Literal["response_failed"] = "response_failed"
message: str = ""
turn_id: str | None = None
turn_revision: int | None = None
@@ -0,0 +1,44 @@
"""Convenience `TypeAlias` definitions for pipeline handler generics.
These aliases keep handler declarations readable (e.g. `BaseHandler[STTIn, STTOut]`)
without hiding what actually flows between stages (see `pipeline/messages.py` and
`pipeline/queue_types.py` for the full story, including control + sentinel items).
"""
from __future__ import annotations
# ruff: noqa: I001
from typing import TypeAlias
import numpy as np
from speech_to_speech.pipeline.messages import (
AudioOutput,
EndOfResponse,
GenerateResponseRequest,
LLMResponseChunk,
PartialTranscription,
TTSInput,
TokenUsage,
Transcription,
VADAudio,
)
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
# ── VAD stage ─────────────────────────────────────────────────────────
VADIn: TypeAlias = bytes | tuple[bytes, RuntimeConfig]
VADOut: TypeAlias = VADAudio
# ── STT stage ─────────────────────────────────────────────────────────
STTIn: TypeAlias = VADAudio
STTOut: TypeAlias = PartialTranscription | Transcription
# ── LLM stage ─────────────────────────────────────────────────────────
LLMIn: TypeAlias = GenerateResponseRequest
LLMOut: TypeAlias = LLMResponseChunk | TokenUsage | EndOfResponse
# ── TTS stage ─────────────────────────────────────────────────────────
TTSIn: TypeAlias = TTSInput | EndOfResponse
TTSOut: TypeAlias = bytes | np.ndarray | AudioOutput
+25
View File
@@ -0,0 +1,25 @@
"""Per-pipeline logging context.
Each isolated pipeline unit sets the contextvar at thread / asyncio-task entry,
and every log record emitted from that thread/task is automatically tagged with
a `[pipeline N] ` prefix via PipelineLogFilter. Non-pipeline-bound code emits
records with an empty prefix.
Works for both threading (handler threads) and asyncio (websocket route, send loops)
because contextvars are per-thread and per-task.
"""
import contextvars
import logging
from typing import Optional
pipeline_log_ctx: contextvars.ContextVar[Optional[int]] = contextvars.ContextVar("pipeline_index", default=None)
class PipelineLogFilter(logging.Filter):
"""Inject `pipeline_prefix` into every LogRecord based on the current contextvar."""
def filter(self, record: logging.LogRecord) -> bool:
idx = pipeline_log_ctx.get()
record.pipeline_prefix = f"[pipeline {idx}] " if idx is not None else ""
return True
+172
View File
@@ -0,0 +1,172 @@
"""Single source of truth for inter-component pipeline messages.
Typed :class:`PipelineMessage` subclasses replace the ad-hoc tuples that
previously flowed between STT, LLM, LMOutputProcessor and TTS stages.
Binary sentinels carried on the audio/output queue are plain ``bytes``
constants.
"""
from __future__ import annotations
from time import perf_counter
from typing import Final, Literal, Optional, TypeAlias
import numpy as np
from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
from pydantic import BaseModel, ConfigDict, Field
from speech_to_speech.api.openai_realtime.runtime_config import RuntimeConfig
# ── Base class ────────────────────────────────────────────────────────
class PipelineMessage(BaseModel):
"""Base for all typed pipeline messages.
The ``tag`` field acts as a Pydantic discriminator so a ``Union`` of
subtypes can be validated from raw dicts when needed.
"""
model_config = ConfigDict(arbitrary_types_allowed=True)
tag: str
# ── VAD → STT ─────────────────────────────────────────────────────────
class VADAudio(PipelineMessage):
"""Audio segment from VAD, with optional mode for realtime transcription."""
tag: Literal["vad_audio"] = "vad_audio"
audio: np.ndarray
mode: Literal["progressive", "final"] | None = None
turn_id: str | None = None
turn_revision: int | None = None
created_at_s: float = Field(default_factory=perf_counter)
# ── STT → TranscriptionNotifier → LLM ────────────────────────────────
class PartialTranscription(PipelineMessage):
"""Live partial transcription (consumed by TranscriptionNotifier, not forwarded to LLM)."""
tag: Literal["partial_transcription"] = "partial_transcription"
text: str
turn_id: str | None = None
turn_revision: int | None = None
class Transcription(PipelineMessage):
"""Final transcription result."""
tag: Literal["transcription"] = "transcription"
text: str
language_code: Optional[str] = None
turn_id: str | None = None
turn_revision: int | None = None
speech_stopped_at_s: float | None = None
# ── LLM → LMOutputProcessor ──────────────────────────────────────────
class LLMResponseChunk(PipelineMessage):
"""One sentence/chunk of the LLM response."""
tag: Literal["llm_response_chunk"] = "llm_response_chunk"
text: str
language_code: Optional[str] = None
tools: list[ResponseFunctionToolCall] = Field(default_factory=list)
runtime_config: RuntimeConfig | None = None
response: RealtimeResponseCreateParams | None = None
turn_id: str | None = None
turn_revision: int | None = None
speech_stopped_at_s: float | None = None
cancel_generation: int | None = None
class TokenUsage(PipelineMessage):
"""Token count report (side-channel, not forwarded to TTS)."""
tag: Literal["token_usage"] = "token_usage"
input_tokens: int
output_tokens: int
turn_id: str | None = None
turn_revision: int | None = None
class EndOfResponse(PipelineMessage):
"""Sentinel marking the end of a response.
``error`` is set when generation could not start (e.g. an out-of-band
response whose ``input`` failed validation); the output processor turns it
into a ``response.done(status="failed")`` while still closing the response
normally for pipeline cleanup.
"""
tag: Literal["end_of_response"] = "end_of_response"
turn_id: str | None = None
turn_revision: int | None = None
cancel_generation: int | None = None
error: str | None = None
# ── LMOutputProcessor → TTS ──────────────────────────────────────────
class TTSInput(PipelineMessage):
"""Text to synthesize with per-response context."""
tag: Literal["tts_input"] = "tts_input"
text: str
language_code: Optional[str] = None
runtime_config: RuntimeConfig | None = None
response: RealtimeResponseCreateParams | None = None
turn_id: str | None = None
turn_revision: int | None = None
speech_stopped_at_s: float | None = None
cancel_generation: int | None = None
class AudioOutput(PipelineMessage):
"""Audio queue item tagged with the response generation that produced it."""
tag: Literal["audio_output"] = "audio_output"
audio: bytes | np.ndarray
cancel_generation: int | None = None
# ── Realtime service → LLM ────────────────────────────────────────────
class GenerateResponseRequest(PipelineMessage):
"""Triggers LLM generation for a realtime session.
Carries everything the LM handler needs to produce a response so it
never has to reach back into shared objects. ``runtime_config``
holds the per-connection session config *and* the conversation chat;
``response`` carries per-response overrides from ``response.create``.
Downstream handlers resolve each attribute by preferring the
per-response value over the session default.
"""
tag: Literal["generate_response"] = "generate_response"
runtime_config: RuntimeConfig
response: RealtimeResponseCreateParams | None = None
language_code: Optional[str] = None
turn_id: str | None = None
turn_revision: int | None = None
speech_stopped_at_s: float | None = None
# ── Binary sentinels (audio/output queue) ─────────────────────────────
AUDIO_RESPONSE_DONE: Final[bytes] = b"__RESPONSE_DONE__"
PIPELINE_END: Final[bytes] = b"END"
PipelineEndSentinel: TypeAlias = Literal[b"END"]
AudioResponseDoneSentinel: TypeAlias = Literal[b"__RESPONSE_DONE__"]
SentinelMessage: TypeAlias = PipelineEndSentinel | AudioResponseDoneSentinel
+46
View File
@@ -0,0 +1,46 @@
"""Typed aliases for items flowing through pipeline queues.
This module centralizes queue payload unions so the rest of the codebase can
reference a small set of stable types instead of repeating large unions.
"""
from __future__ import annotations
# ruff: noqa: I001
from typing import TypeAlias
import numpy as np
from speech_to_speech.pipeline.control import PipelineControlMessage
from speech_to_speech.pipeline.events import PipelineEvent
from speech_to_speech.pipeline.handler_types import LLMIn, LLMOut, STTOut, TTSIn, VADIn, VADOut
from speech_to_speech.pipeline.messages import AudioOutput
# Use plain ``bytes`` for sentinel values on queues (``PIPELINE_END``, ``AUDIO_RESPONSE_DONE``).
# ``Queue`` is invariant; ``Literal[b"END"]`` is not accepted where ``bytes`` is required.
PipelineInternalItem: TypeAlias = PipelineControlMessage | bytes
# Audio chunks coming from IO (socket/websocket/mic) into VAD.
AudioInItem: TypeAlias = VADIn | PipelineControlMessage
# Audio segments flowing from VAD to STT.
VADOutItem: TypeAlias = VADOut | PipelineInternalItem
# STT output (partial + final) flowing to TranscriptionNotifier.
STTOutItem: TypeAlias = STTOut | PipelineInternalItem
# Text prompts flowing into LLM.
TextPromptItem: TypeAlias = LLMIn | PipelineInternalItem
# LLM outputs flowing into LMOutputProcessor.
LMOutItem: TypeAlias = LLMOut | PipelineInternalItem
# Inputs flowing into TTS.
TTSInItem: TypeAlias = TTSIn | PipelineInternalItem
# Audio outputs flowing to speakers / client (includes sentinels as bytes).
AudioOutItem: TypeAlias = bytes | np.ndarray | AudioOutput | PipelineControlMessage
# Side-channel text events sent to websocket/realtime clients.
TextEventItem: TypeAlias = PipelineEvent | PipelineInternalItem
@@ -0,0 +1,418 @@
from __future__ import annotations
import logging
import time
from collections import OrderedDict
from dataclasses import dataclass
from threading import Condition
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _PendingReopen:
base_revision: int
candidate_revision: int
@dataclass(frozen=True)
class _ReopenGrace:
revision: int
deadline: float
class SpeculativeTurnTracker:
"""Thread-safe revision tracker for raw-audio speculative turns."""
_PENDING_REOPEN_WAIT_TIMEOUT_S = 2.0
_MAX_TRACKED_TURNS = 2048
def __init__(self, max_tracked_turns: int = _MAX_TRACKED_TURNS) -> None:
self._condition = Condition()
self._max_tracked_turns = max_tracked_turns
self._latest_revision: OrderedDict[str, int] = OrderedDict()
self._committed_revision: dict[str, int] = {}
self._pending_reopen: dict[str, _PendingReopen] = {}
self._reopen_grace: dict[str, _ReopenGrace] = {}
def observe(self, turn_id: str | None, revision: int | None) -> None:
if turn_id is None or revision is None:
return
with self._condition:
current = self._latest_revision.get(turn_id, -1)
if revision > current:
self._latest_revision[turn_id] = revision
self._latest_revision.move_to_end(turn_id)
self._prune_tracked_turns()
logger.debug("Observed speculative turn %s revision %d", turn_id, revision)
self._condition.notify_all()
def is_latest(self, turn_id: str | None, revision: int | None) -> bool:
if turn_id is None or revision is None:
return True
with self._condition:
return self._latest_revision.get(turn_id, revision) == revision
def is_latest_after_pending_reopen(self, turn_id: str | None, revision: int | None) -> bool:
if turn_id is None or revision is None:
return True
with self._condition:
self._wait_for_pending_reopen_locked(turn_id, revision, self._PENDING_REOPEN_WAIT_TIMEOUT_S)
return self._latest_revision.get(turn_id, revision) == revision
def try_is_latest_after_pending_reopen(self, turn_id: str | None, revision: int | None) -> bool | None:
"""Non-blocking variant of ``is_latest_after_pending_reopen``.
Returns ``None`` when a matching reopen candidate is still pending and
the caller should retry after it resolves.
"""
if turn_id is None or revision is None:
return True
with self._condition:
if self._has_pending_reopen_locked(turn_id, revision):
return None
return self._latest_revision.get(turn_id, revision) == revision
def is_latest_after_reopen_grace(self, turn_id: str | None, revision: int | None) -> bool:
if turn_id is None or revision is None:
return True
with self._condition:
self._wait_for_reopen_gate_locked(turn_id, revision)
return self._latest_revision.get(turn_id, revision) == revision
def try_is_latest_after_reopen_grace(self, turn_id: str | None, revision: int | None) -> bool | None:
if turn_id is None or revision is None:
return True
with self._condition:
if (
self._has_pending_reopen_locked(turn_id, revision)
or self._reopen_grace_remaining_locked(
turn_id,
revision,
)
> 0
):
return None
return self._latest_revision.get(turn_id, revision) == revision
def commit_if_latest_after_pending_reopen(self, turn_id: str | None, revision: int | None) -> bool:
if turn_id is None or revision is None:
return True
with self._condition:
self._wait_for_pending_reopen_locked(turn_id, revision, self._PENDING_REOPEN_WAIT_TIMEOUT_S)
latest = self._latest_revision.get(turn_id, revision)
if revision != latest:
return False
self._committed_revision[turn_id] = revision
logger.debug("Committed speculative turn %s revision %d", turn_id, revision)
self._condition.notify_all()
return True
def commit_if_latest_after_reopen_grace(self, turn_id: str | None, revision: int | None) -> bool:
if turn_id is None or revision is None:
return True
with self._condition:
self._wait_for_reopen_gate_locked(turn_id, revision)
latest = self._latest_revision.get(turn_id, revision)
if revision != latest:
return False
self._committed_revision[turn_id] = revision
logger.debug("Committed speculative turn %s revision %d", turn_id, revision)
self._condition.notify_all()
return True
def try_commit_if_latest_after_pending_reopen(self, turn_id: str | None, revision: int | None) -> bool | None:
"""Non-blocking variant of ``commit_if_latest_after_pending_reopen``.
Returns ``None`` when a matching reopen candidate is still pending and
the caller should retry after it resolves.
"""
if turn_id is None or revision is None:
return True
with self._condition:
if self._has_pending_reopen_locked(turn_id, revision):
return None
latest = self._latest_revision.get(turn_id, revision)
if revision != latest:
return False
self._committed_revision[turn_id] = revision
logger.debug("Committed speculative turn %s revision %d", turn_id, revision)
self._condition.notify_all()
return True
def try_commit_if_latest_after_reopen_grace(self, turn_id: str | None, revision: int | None) -> bool | None:
if turn_id is None or revision is None:
return True
with self._condition:
if (
self._has_pending_reopen_locked(turn_id, revision)
or self._reopen_grace_remaining_locked(
turn_id,
revision,
)
> 0
):
return None
latest = self._latest_revision.get(turn_id, revision)
if revision != latest:
return False
self._committed_revision[turn_id] = revision
logger.debug("Committed speculative turn %s revision %d", turn_id, revision)
self._condition.notify_all()
return True
def has_pending_reopen(self, turn_id: str | None, revision: int | None) -> bool:
if turn_id is None or revision is None:
return False
with self._condition:
return self._has_pending_reopen_locked(turn_id, revision)
def has_pending_reopen_or_grace(self, turn_id: str | None, revision: int | None) -> bool:
if turn_id is None or revision is None:
return False
with self._condition:
return (
self._has_pending_reopen_locked(turn_id, revision)
or self._reopen_grace_remaining_locked(
turn_id,
revision,
)
> 0
)
def start_reopen_grace(self, turn_id: str | None, revision: int | None, grace_s: float) -> None:
if turn_id is None or revision is None or grace_s <= 0:
return
with self._condition:
if self._latest_revision.get(turn_id, revision) != revision:
return
if self._committed_revision.get(turn_id, -1) >= revision:
return
deadline = time.monotonic() + grace_s
existing = self._reopen_grace.get(turn_id)
if existing is None or existing.revision != revision or deadline > existing.deadline:
self._reopen_grace[turn_id] = _ReopenGrace(revision=revision, deadline=deadline)
logger.debug(
"Started speculative reopen grace for turn %s revision %d: %.0fms",
turn_id,
revision,
grace_s * 1000,
)
self._condition.notify_all()
def is_latest_after_stability_window(
self,
turn_id: str | None,
revision: int | None,
settle_s: float,
) -> bool:
if turn_id is None or revision is None:
return True
if settle_s <= 0:
return self.is_latest_after_pending_reopen(turn_id, revision)
with self._condition:
deadline = time.monotonic() + settle_s
while self._latest_revision.get(turn_id, revision) == revision:
if self._has_pending_reopen_locked(turn_id, revision):
break
remaining = deadline - time.monotonic()
if remaining <= 0:
break
self._condition.wait(remaining)
self._wait_for_pending_reopen_locked(turn_id, revision, self._PENDING_REOPEN_WAIT_TIMEOUT_S)
return self._latest_revision.get(turn_id, revision) == revision
def commit(self, turn_id: str | None, revision: int | None) -> None:
if turn_id is None or revision is None:
return
with self._condition:
pending = self._pending_reopen.get(turn_id)
if pending is not None and pending.base_revision == revision:
logger.debug(
"Deferring speculative turn %s revision %d commit while reopen is pending", turn_id, revision
)
return
latest = self._latest_revision.get(turn_id, revision)
if revision == latest:
self._committed_revision[turn_id] = revision
logger.debug("Committed speculative turn %s revision %d", turn_id, revision)
self._condition.notify_all()
def is_committed(self, turn_id: str | None, revision: int | None = None) -> bool:
if turn_id is None:
return False
with self._condition:
committed = self._committed_revision.get(turn_id)
if committed is None:
return False
return revision is None or committed >= revision
def begin_reopen_candidate(self, turn_id: str | None, revision: int | None) -> int | None:
if turn_id is None or revision is None:
return None
with self._condition:
if self._committed_revision.get(turn_id, -1) >= revision:
return None
if self._latest_revision.get(turn_id, revision) != revision:
return None
pending = self._pending_reopen.get(turn_id)
if pending is not None:
if pending.base_revision == revision:
return pending.candidate_revision
return None
candidate_revision = revision + 1
self._pending_reopen[turn_id] = _PendingReopen(
base_revision=revision,
candidate_revision=candidate_revision,
)
logger.debug(
"Started speculative reopen candidate for turn %s revision %d -> %d",
turn_id,
revision,
candidate_revision,
)
self._condition.notify_all()
return candidate_revision
def confirm_reopen_candidate(
self,
turn_id: str | None,
base_revision: int | None,
candidate_revision: int | None,
) -> bool:
if turn_id is None or base_revision is None or candidate_revision is None:
return False
with self._condition:
pending = self._pending_reopen.get(turn_id)
if (
pending is None
or pending.base_revision != base_revision
or pending.candidate_revision != candidate_revision
):
return False
if self._committed_revision.get(turn_id, -1) >= base_revision:
del self._pending_reopen[turn_id]
self._prune_tracked_turns()
self._condition.notify_all()
return False
if self._latest_revision.get(turn_id, base_revision) != base_revision:
del self._pending_reopen[turn_id]
self._prune_tracked_turns()
self._condition.notify_all()
return False
self._latest_revision[turn_id] = candidate_revision
self._latest_revision.move_to_end(turn_id)
del self._pending_reopen[turn_id]
self._prune_tracked_turns()
logger.debug(
"Confirmed speculative reopen candidate for turn %s revision %d",
turn_id,
candidate_revision,
)
self._condition.notify_all()
return True
def cancel_reopen_candidate(self, turn_id: str | None, candidate_revision: int | None = None) -> None:
if turn_id is None:
return
with self._condition:
pending = self._pending_reopen.get(turn_id)
if pending is None:
return
if candidate_revision is not None and pending.candidate_revision != candidate_revision:
return
del self._pending_reopen[turn_id]
self._prune_tracked_turns()
logger.debug("Cancelled speculative reopen candidate for turn %s", turn_id)
self._condition.notify_all()
def wait_for_pending_reopen(
self,
turn_id: str | None,
revision: int | None,
timeout_s: float = _PENDING_REOPEN_WAIT_TIMEOUT_S,
) -> None:
if turn_id is None or revision is None:
return
with self._condition:
self._wait_for_pending_reopen_locked(turn_id, revision, timeout_s)
def _has_pending_reopen_locked(self, turn_id: str, revision: int) -> bool:
pending = self._pending_reopen.get(turn_id)
return pending is not None and pending.base_revision == revision
def _reopen_grace_remaining_locked(self, turn_id: str, revision: int) -> float:
grace = self._reopen_grace.get(turn_id)
if grace is None or grace.revision != revision:
return 0.0
if self._latest_revision.get(turn_id, revision) != revision:
del self._reopen_grace[turn_id]
return 0.0
remaining = grace.deadline - time.monotonic()
if remaining <= 0:
del self._reopen_grace[turn_id]
self._prune_tracked_turns()
return 0.0
return remaining
def _wait_for_reopen_gate_locked(self, turn_id: str, revision: int) -> None:
while self._latest_revision.get(turn_id, revision) == revision:
self._wait_for_pending_reopen_locked(turn_id, revision, self._PENDING_REOPEN_WAIT_TIMEOUT_S)
if self._latest_revision.get(turn_id, revision) != revision:
return
remaining = self._reopen_grace_remaining_locked(turn_id, revision)
if remaining <= 0:
return
logger.debug("Waiting for speculative reopen grace turn=%s rev=%s", turn_id, revision)
self._condition.wait(remaining)
def _wait_for_pending_reopen_locked(self, turn_id: str, revision: int, timeout_s: float) -> None:
deadline = time.monotonic() + timeout_s
pending = self._pending_reopen.get(turn_id)
if pending is None or pending.base_revision != revision:
return
logger.debug("Waiting for pending speculative reopen turn=%s rev=%s", turn_id, revision)
while pending is not None and pending.base_revision == revision:
remaining = deadline - time.monotonic()
if remaining <= 0:
logger.warning("Timed out waiting for pending speculative reopen turn=%s rev=%s", turn_id, revision)
if self._pending_reopen.get(turn_id) == pending:
del self._pending_reopen[turn_id]
self._prune_tracked_turns()
self._condition.notify_all()
return
self._condition.wait(remaining)
pending = self._pending_reopen.get(turn_id)
def _prune_tracked_turns(self) -> None:
if self._max_tracked_turns <= 0:
return
self._drop_expired_reopen_graces_locked()
prunable_turn_ids = [
turn_id
for turn_id in self._latest_revision
if turn_id not in self._pending_reopen and turn_id not in self._reopen_grace
]
while len(prunable_turn_ids) > self._max_tracked_turns:
turn_id = prunable_turn_ids.pop(0)
self._latest_revision.pop(turn_id, None)
self._committed_revision.pop(turn_id, None)
self._reopen_grace.pop(turn_id, None)
def _drop_expired_reopen_graces_locked(self) -> None:
now = time.monotonic()
for turn_id, grace in list(self._reopen_grace.items()):
if self._latest_revision.get(turn_id, grace.revision) != grace.revision or grace.deadline <= now:
del self._reopen_grace[turn_id]
def reset(self) -> None:
with self._condition:
self._latest_revision.clear()
self._committed_revision.clear()
self._pending_reopen.clear()
self._reopen_grace.clear()
self._condition.notify_all()