first
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user