Skip to content

Design Doc ‐ US‐23 Simulator Persistence and Replayability

Nirav Patel edited this page Jan 27, 2026 · 1 revision

1. Overview

This document describes a revised approach to implementing simulation persistence and replayability in VeloSim. The key architectural change is to persist all frames (both keyframes and diff frames) to the database, eliminating the need to reconstruct simulation state by stitching together running sim instances.

Goals

  1. Persist all frames — Store every emitted frame in the database with its type indicator (is_key)
  2. Enable seek backward/forward — Users can seek to any point in a simulation's history without a running sim instance
  3. Enable playback of historical simulations — Users can replay completed simulations via REST endpoints
  4. Enable branching — Users can create a new simulation that branches from a specific keyframe of an existing simulation
  5. Extend sim_instances — Add name and playback_capable fields to support UI and playback features

2. Background & Current State

Current Architecture

Limitations of Current Approach

  • Only keyframes are persisted; diff frames are lost after streaming
  • To replay, you would need to re-run the simulation or reconstruct state from keyframes only (losing granularity)
  • No support for seek or branching

3. Proposed Architecture

3.1 Database Changes

3.1.1 New sim_frames Table

Create a new table to replace sim_keyframes with a unique constraint on (sim_instance_id, seq_number) to handle potential duplicate frame writes:

Column Type Description
id INTEGER Primary key
sim_instance_id INTEGER FK to sim_instances
seq_number INTEGER Frame sequence number (unique per sim instance)
sim_seconds_elapsed FLOAT Simulation time when frame was captured
frame_data JSONB Complete frame payload
is_key BOOLEAN true for keyframes, false for diff frames
created_at DATETIME Timestamp of persistence

Constraints:

  • UNIQUE (sim_instance_id, seq_number) — Ensures idempotent frame writes

Indexes:

  • Primary key index on id
  • Unique composite index on (sim_instance_id, seq_number)
  • Composite index on (sim_instance_id, sim_seconds_elapsed) for time-based queries

Migration Strategy:

  1. Create new sim_frames table with the unique constraint
  2. Migrate existing data from sim_keyframes (all existing frames are keyframes, so is_key=true)
  3. Drop sim_keyframes table
  4. Update all model references and CRUD operations

3.1.2 Extend sim_instances Table

Column Type Description
name VARCHAR(255) User-provided name (nullable). Fallback: {scenario_name} - {id}
playback_capable BOOLEAN true for all new instances (full frame persistence), false for legacy instances
parent_sim_instance_id INTEGER FK to parent sim_instances for branched simulations (nullable)
branch_keyframe_seq INTEGER The seq_number of the keyframe from which this simulation was branched (nullable)

Notes:

  • playback_capable defaults to true for all newly created sim instances going forward
  • Existing/legacy sim instances will have playback_capable=false (set during migration)
  • For branched simulations, frames are copied from the parent to the new sim instance for query simplicity, and parent_sim_instance_id + branch_keyframe_seq are stored for provenance

3.2 Frame Persistence Changes

Modify KeyframePersistenceSubscriber to persist all frames (not just keyframes):

async def _persist_frame(self, frame: Frame) -> None:
    """Persist any frame (keyframe or diff) to the database. 
    
    Uses upsert semantics to handle potential duplicate writes gracefully.
    """
    frame_data = SimFrameCreate(
        sim_instance_id=self.sim_instance_id,
        seq_number=frame.seq_number,
        sim_seconds_elapsed=frame.sim_seconds_elapsed,
        frame_data=frame.payload_dict,
        is_key=frame.is_key,
    )
    await loop.run_in_executor(None, sim_frame_crud.upsert, db, frame_data)

The CRUD layer should use INSERT ... ON CONFLICT (sim_instance_id, seq_number) DO NOTHING (or equivalent upsert) to handle duplicate writes idempotently.


3.3 REST API: Seek Endpoint (23.3)

Endpoint

GET /api/v1/simulation/{sim_id}/seek

Query Parameters

Parameter Type Required Description
position float Yes Target simulation time in seconds to seek to
frame_window_seconds float No Number of simulation seconds worth of future frames to return. Default: configurable constant (e.g., 5. 0 seconds)
playback_speed float No Desired playback speed to set on the simulation (e.g., 1.0 for normal, 0.5 for half speed, 0 to pause). If provided, the simulation's playback speed will be updated.

Response Schema

class SeekResponse(BaseModel):
    """Response for seek endpoint."""
    
    sim_id: str
    initial_frames: List[SimFrameResponse]  # Keyframe + diff frames to apply instantly to reach the requested position
    future_frames: List[SimFrameResponse]  # Diff frames to play sequentially after the initial state
    has_more_frames: bool  # True if there are more frames beyond the returned window
    current_sim_seconds:  float  # The current live simulation time (if sim is running)
    is_at_live_edge: bool  # True if the returned frames reach the current execution point
    playback_speed:  float  # Current playback speed of the simulation

Behavior

  1. Find the keyframe with sim_seconds_elapsed <= position (most recent keyframe at or before requested position)
  2. Collect all diff frames between that keyframe and position — these form initial_frames (keyframe first, then diffs in order)
  3. Collect diff frames from position to position + frame_window_seconds — these form future_frames
  4. If playback_speed is provided, update the simulation's playback speed
  5. If the user is seeking to position 0, this is effectively a "start playback" request
  6. Frontend can make successive calls to continue fetching frames as the user plays through history

Example Request

GET /api/v1/simulation/abc-123/seek?position=120. 5&frame_window_seconds=5.0&playback_speed=1.0

Example Response

{
  "sim_id": "abc-123",
  "initial_frames": [
    {
      "id": 42,
      "sim_instance_id": 1,
      "seq_number": 240,
      "sim_seconds_elapsed": 120.0,
      "frame_data":  { "... ": "..." },
      "is_key": true,
      "created_at": "2026-01-18T10:00:00Z"
    },
    {
      "id":  43,
      "sim_instance_id": 1,
      "seq_number": 241,
      "sim_seconds_elapsed": 120.25,
      "frame_data":  { "...": "..." },
      "is_key": false,
      "created_at":  "2026-01-18T10:00:00Z"
    },
    {
      "id":  44,
      "sim_instance_id": 1,
      "seq_number": 242,
      "sim_seconds_elapsed": 120.5,
      "frame_data": { "...": "..." },
      "is_key": false,
      "created_at": "2026-01-18T10:00:01Z"
    }
  ],
  "future_frames": [
    {
      "id":  45,
      "sim_instance_id": 1,
      "seq_number": 243,
      "sim_seconds_elapsed": 120.75,
      "frame_data": { "...": "..." },
      "is_key": false,
      "created_at": "2026-01-18T10:00:01Z"
    }
  ],
  "has_more_frames": true,
  "current_sim_seconds": 300.0,
  "is_at_live_edge": false,
  "playback_speed": 1.0
}

3.4 Playback (23.4)

Playback is implemented using the same seek endpoint. The frontend initiates playback by calling seek with position=0, then continues to poll the seek endpoint as the user plays through the simulation.

Playback Flow

  1. Start Playback: GET /api/v1/simulation/{sim_id}/seek?position=0
  2. Frontend renders the initial frames instantly (applying keyframe + diffs to reach position 0), then plays future_frames at the appropriate rate
  3. Continue Playback: When the frontend exhausts the current batch of future frames, it calls seek again with position=<last_frame_time>
  4. User Seeks: If the user scrubs to a different position, call seek with the new position

Frontend Behavior During Playback

  • Read-only mode: FE blocks mutating actions (task assignment, etc.) when user is viewing historical frames
  • Live edge indicator: FE shows whether user is at the current simulation time or viewing history
  • Catch-up: If user clicks "Go to Live", FE stops fetching historical frames and resumes WebSocket streaming

WebSocket Behavior During Seek/Playback

  • The simulation continues executing in the background
  • WebSocket continues to stream current frames
  • Frontend ignores incoming WS frames while displaying historical data (or buffers them)
  • When user returns to "live", frontend resumes consuming WS frames

3.5 Branching (23.5)

Endpoint

POST /api/v1/simulation/{sim_id}/branch

Request Body

class BranchRequest(BaseModel):
    """Request to branch a simulation."""
    
    keyframe_seq: int  # seq_number of the keyframe to branch from
    name: str  # Name for the new simulation

Response

class BranchResponse(BaseModel):
    """Response for branch operation."""
    
    new_sim_id: str  # UUID of the new simulation
    new_sim_db_id: int  # Database ID of the new simulation
    branched_from_sim_id: str  # Original simulation UUID
    branched_from_keyframe_seq: int  # The keyframe seq that was branched from
    status: str  # "created" - the new sim is not yet running

Behavior

  1. Validate that sim_id exists and the user has access
  2. Validate that keyframe_seq exists and is a keyframe (is_key=true)
  3. Create a new sim_instance with:
    • name = provided name
    • parent_sim_instance_id = original sim's db ID
    • branch_keyframe_seq = provided keyframe seq
    • playback_capable = true
    • scenario_payload = copy from parent
  4. Copy all frames from the parent simulation where seq_number <= keyframe_seq to the new sim instance (with new sim_instance_id, preserving seq_number)
  5. Return the new simulation info

Starting a Branched Simulation

The branched simulation can be started like any other simulation via the existing /simulation/stream/{sim_id} WebSocket endpoint. The simulator will:

  1. Load state from the branched keyframe
  2. Begin execution from that point
  3. Emit new frames that are persisted under the new sim instance ID

3.6 Schema Updates

SimFrameCreate (New)

class SimFrameCreate(BaseModel):
    """Schema for creating a new simulation frame (keyframe or diff)."""
    
    sim_instance_id:  int = Field(... , description="ID of the simulation instance")
    seq_number: int = Field(..., ge=0, description="Frame sequence number (unique per sim instance)")
    sim_seconds_elapsed: float = Field(..., ge=0, description="Simulation time in seconds")
    frame_data: Dict[str, Any] = Field(..., description="Complete frame payload")
    is_key: bool = Field(... , description="True if this is a keyframe, false if diff frame")

SimFrameResponse (New)

class SimFrameResponse(BaseModel):
    """Schema for simulation frame response."""
    
    id: int
    sim_instance_id: int
    seq_number: int
    sim_seconds_elapsed: float
    frame_data: Dict[str, Any]
    is_key: bool
    created_at: datetime
    
    model_config = ConfigDict(from_attributes=True)

SimInstanceCreate (Updated)

class SimInstanceCreate(SimInstanceBase):
    """Schema for creating a new SimInstance."""
    
    scenario_payload: Optional[Dict[str, Any]] = None
    name: Optional[str] = None
    parent_sim_instance_id: Optional[int] = None
    branch_keyframe_seq: Optional[int] = None

SimInstanceResponse (Updated)

class SimInstanceResponse(SimInstanceBase):
    """Schema for SimInstance response."""
    
    id: int
    name: Optional[str]  # User-provided or generated fallback
    playback_capable:  bool
    parent_sim_instance_id: Optional[int]
    branch_keyframe_seq:  Optional[int]
    date_created: datetime
    date_updated: datetime
    # ... existing computed fields ... 

4. Configuration

Add the following configuration options to back/core/config.py:

# Frame persistence settings
SEEK_DEFAULT_FRAME_WINDOW_SECONDS: float = 5.0  # Default future frame window for seek
SEEK_MAX_FRAME_WINDOW_SECONDS: float = 30.0  # Maximum allowed frame window

5. Task Breakdown

Phase 1: Database & Model Changes

Task Description
1.1 Create Alembic migration: new sim_frames table with unique constraint on (sim_instance_id, seq_number)
1.2 Create Alembic migration: migrate data from sim_keyframes to sim_frames
1.3 Create Alembic migration: drop sim_keyframes table
1.4 Create Alembic migration: add name, playback_capable, parent_sim_instance_id, branch_keyframe_seq to sim_instances (with playback_capable=false for existing rows)
1.5 Create SimFrame model with new fields and unique constraint
1.6 Update SimInstance model with new fields
1.7 Create new schemas (SimFrameCreate, SimFrameResponse)
1.8 Update SimInstanceCreate and SimInstanceResponse schemas

Phase 2: Frame Persistence

Task Description
2.1 Create sim_frame_crud with upsert operation (ON CONFLICT DO NOTHING)
2.2 Update KeyframePersistenceSubscriber to persist all frames with is_key and seq_number
2.3 Update Frame entity to include seq_number if not already exposed
2.4 Add unit tests for full frame persistence with duplicate handling

Phase 3: Seek Endpoint (23.3)

Task Description
3.1 Add CRUD method get_keyframe_at_or_before(sim_id, position)
3.2 Add CRUD method get_frames_in_range(sim_id, start_time, end_time)
3.3 Implement GET /simulation/{sim_id}/seek endpoint
3.4 Integrate playback_speed parameter with existing playback speed service
3.5 Add configuration for SEEK_DEFAULT_FRAME_WINDOW_SECONDS
3.6 Add unit tests for seek endpoint
3.7 Add integration tests for seek with running simulation

Phase 4: Playback (23.4)

Task Description
4.1 Frontend: Implement playback mode using seek endpoint
4.2 Frontend: Implement read-only mode during historical playback
4.3 Frontend: Implement "Go to Live" functionality
4.4 Frontend: Add playback controls (play/pause/seek slider)
4.5 Add E2E tests for playback flow

Phase 5: Branching (23.5)

Task Description
5.1 Add CRUD method to copy frames from parent sim to new sim
5.2 Implement POST /simulation/{sim_id}/branch endpoint
5.3 Update simulation initialization to support starting from branched keyframe
5.4 Add unit tests for branching
5.5 Add integration tests for branching and starting branched sim

Phase 6: Sim Instance Naming & Playback Capability

Task Description
6.1 Update simulation initialization to accept optional name
6.2 Implement name fallback logic ({scenario_name} - {id})
6.3 Ensure playback_capable=true is set for all new sim instances
6.4 Frontend: Display simulation name in simulations list
6.5 Frontend: Filter/indicate playback-capable simulations

6. Notes

  1. Concurrent access: Multiple users seeking/playing back the same simulation will work correctly since these are read-only operations against persisted frame data.

  2. Playback speed during seek: When seeking backward, the frontend may want to set playback_speed=0 (pause) to prevent the simulation from continuing to generate new frames while the user is viewing historical data. This avoids a disconnect between the rate of replaying seeked frames and the rate of new frame generation. This is left as an implementation detail for the frontend.

  3. Retention policy: We may eventually implement a retention policy for sim instances and their frames (e.g., delete frames older than X days, archive to cold storage). This is currently out of scope for US-23.


7. Appendix: API Summary

Method Endpoint Description
GET /simulation/{sim_id}/seek?position=X Seek to position X, returns initial frames + future frames
POST /simulation/{sim_id}/branch Create a branched simulation from a keyframe
GET /simulation/{sim_id}/keyframes (Existing, to be updated) List all frames with pagination
GET /simulation/{sim_id}/keyframes/{sim_seconds} (Existing, to be updated) Get frame at specific time

Clone this wiki locally