Repository navigation
Description
Steps to reproduce
- Have a large
eventstable (1.3M events on dstack Sky). - Call
POST /api/events/listfor a project with a cursor (prev_recorded_at,prev_id).
Actual behaviour
Requests take seconds, even when they return no events. On dstack Sky, a client that polls one project's events every minute gets ~3s per request, almost all of it in the SELECT ... FROM events query.
The cursor filter is recorded_at > $1 OR (recorded_at = $2 AND id < $3):
dstack/src/dstack/_internal/server/services/events.py
Lines 416 to 435 in 5e3bbf9
| if prev_recorded_at is not None: | |
| if ascending: | |
| if prev_id is None: | |
| event_filters.append(EventModel.recorded_at > prev_recorded_at) | |
| else: | |
| event_filters.append( | |
| or_( | |
| EventModel.recorded_at > prev_recorded_at, | |
| and_(EventModel.recorded_at == prev_recorded_at, EventModel.id < prev_id), | |
| ) | |
| ) | |
| else: | |
| if prev_id is None: | |
| event_filters.append(EventModel.recorded_at < prev_recorded_at) | |
| else: | |
| event_filters.append( | |
| or_( | |
| EventModel.recorded_at < prev_recorded_at, | |
| and_(EventModel.recorded_at == prev_recorded_at, EventModel.id > prev_id), | |
| ) |
Postgres can't start reading ix_events_recorded_at at the cursor from this OR, so it often starts at the oldest event and skips everything before the cursor. Here it skipped 838k events to return 100:
Index Scan using ix_events_recorded_at on events (cost=0.43..74776.95 rows=493912 width=24) (actual time=1178.487..1178.653 rows=161 loops=1)
Filter: ((recorded_at > '2026-10-07 00:00:00'::timestamp without time zone) OR ((recorded_at = '2026-10-07 00:00:00'::timestamp without time zone) AND (id < '00000000-0000-0000-0000-000000000000'::uuid)))
Rows Removed by Filter: 838179
Expected behaviour
A request's cost depends on how many events it returns, not on the size of the table.
dstack version
0.22.7
Server logs
n/aAdditional information
Postgres can use the OR with the index in two ways:
- Look up each branch separately and sort the combined result. Events before the cursor are skipped, but everything after the cursor has to be sorted. Postgres does this when it expects few events after the cursor.
- Read the index in order and stop after
limitmatches. No sort is needed, but the scan can only start at the cursor from a single condition such asrecorded_at >= value, which Postgres doesn't derive from the OR ($1and$2are separate parameters). So it starts at the oldest event. Postgres does this when it expects many events after the cursor.
Postgres expects many events after the cursor when the cursor is old, and always with a generic plan, which is planned without the parameter values and assumes about a third of the table comes after the cursor. The server's queries are prepared statements (asyncpg), and Postgres can switch a prepared statement to a generic plan after 5 runs on a connection. On dstack Sky, the same query with a cursor a few minutes old took 11 ms with the values known and 1.7 s with a generic plan.
Two fixes:
- Add
recorded_at >= prev_recorded_at(<=for descending) next to the OR. It doesn't change the results, but lets the ordered scan start at the cursor, so events before the cursor are never read. Locally, the generic plan went from 257 ms to 4.8 ms. - Store
recorded_atonevent_targetsand index(entity_project_id, recorded_at). Even with fix 1, a request reads all events after the cursor until it findslimitevents of the project. For a project with few events and an old cursor, or no cursor, that is most of the table.
| Recent cursor | Old cursor | |
|---|---|---|
| Project with many events | fix 1 | fix 1 |
| Project with few events | fix 1 | fix 2 |
Fix 1 is enough for most requests: on dstack Sky, reading 1,548 events after a cursor took 8 ms.
Activity
ShivanshShukla commentedon Nov 10, 2026
Hi @r4victor,
I would love to take ownership of this optimisation. Your analysis of the Postgres query planner switching to a generic plan and missing the index bound due to the
ORcondition is spot on.My Execution Plan:
recorded_at >= prev_recorded_at(or<=for descending) condition next to the OR. This is an elegant way to force the planner's ordered scan to start at the cursor without altering the table structure.recorded_atontoevent_targetsand creating the composite index(entity_project_id, recorded_at)), I am happy to implement it if you feel the schema change is worth the migration cost for projects with sparse events.Let me know if you want just Fix 1 for now, or both, and I will open a PR!