-
Notifications
You must be signed in to change notification settings - Fork 0
Design Doc ‐ US‐23 Simulator Persistence and Replayability
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.
-
Persist all frames — Store every emitted frame in the database with its type indicator (
is_key) - Enable seek backward/forward — Users can seek to any point in a simulation's history without a running sim instance
- Enable playback of historical simulations — Users can replay completed simulations via REST endpoints
- Enable branching — Users can create a new simulation that branches from a specific keyframe of an existing simulation
-
Extend sim_instances — Add
nameandplayback_capablefields to support UI and playback features
-
sim_keyframestable: Stores keyframe snapshots at regular intervals with complete simulation state -
SimKeyframemodel (back/models/sim_keyframe.py): Hassim_instance_id,sim_seconds_elapsed,frame_data(JSONB),created_at -
WebSocket streaming: Frames are streamed live via
WebSocketSubscriberinback/api/v1/utils/sim_websocket_helpers.py -
KeyframePersistenceSubscriber(back/services/keyframe_persistence_service.py): Currently only persists keyframes
- 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
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:
- Create new
sim_framestable with the unique constraint - Migrate existing data from
sim_keyframes(all existing frames are keyframes, sois_key=true) - Drop
sim_keyframestable - Update all model references and CRUD operations
| 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_capabledefaults totruefor 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_seqare stored for provenance
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.
GET /api/v1/simulation/{sim_id}/seek
| 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. |
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- Find the keyframe with
sim_seconds_elapsed <= position(most recent keyframe at or before requested position) - Collect all diff frames between that keyframe and
position— these forminitial_frames(keyframe first, then diffs in order) - Collect diff frames from
positiontoposition + frame_window_seconds— these formfuture_frames - If
playback_speedis provided, update the simulation's playback speed - If the user is seeking to position 0, this is effectively a "start playback" request
- Frontend can make successive calls to continue fetching frames as the user plays through history
GET /api/v1/simulation/abc-123/seek?position=120. 5&frame_window_seconds=5.0&playback_speed=1.0
{
"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
}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.
-
Start Playback:
GET /api/v1/simulation/{sim_id}/seek?position=0 -
Frontend renders the initial frames instantly (applying keyframe + diffs to reach position 0), then plays
future_framesat the appropriate rate -
Continue Playback: When the frontend exhausts the current batch of future frames, it calls seek again with
position=<last_frame_time> -
User Seeks: If the user scrubs to a different position, call seek with the new
position
- 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
- 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
POST /api/v1/simulation/{sim_id}/branch
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 simulationclass 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- Validate that
sim_idexists and the user has access - Validate that
keyframe_seqexists and is a keyframe (is_key=true) - Create a new
sim_instancewith:-
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
-
-
Copy all frames from the parent simulation where
seq_number <= keyframe_seqto the new sim instance (with newsim_instance_id, preservingseq_number) - Return the new simulation info
The branched simulation can be started like any other simulation via the existing /simulation/stream/{sim_id} WebSocket endpoint. The simulator will:
- Load state from the branched keyframe
- Begin execution from that point
- Emit new frames that are persisted under the new sim instance ID
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")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)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] = Noneclass 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 ... 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| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
-
Concurrent access: Multiple users seeking/playing back the same simulation will work correctly since these are read-only operations against persisted frame data.
-
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. -
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.
| 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 |
Simulation Meeting Minutes
Frontend Meeting Minutes
Backend Meeting Minutes
Risks
User Consent and End-User License Agreement
Legal and Ethical Issues
Economic
Budget
Personas
Diversity Statement
Overall Architecture and Class Diagrams
Infrastructure and Tools
Name Conventions
Testing Plan and Continuous Integration
Security
Performance
Deployment Plan and Infrastructure
Missing Knowledge and Independent Learning
Glossary
Mockups
UI Evolution
Logging
Metrics
VeloSim Observability & Performance Insights
User Manual
Usability Tests