Skip to content

[Bug]: /api/events/list can read the whole events table #4465

Open

Description

@r4victor

Steps to reproduce

  1. Have a large events table (1.3M events on dstack Sky).
  2. Call POST /api/events/list for 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):

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/a

Additional 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 limit matches. No sort is needed, but the scan can only start at the cursor from a single condition such as recorded_at >= value, which Postgres doesn't derive from the OR ($1 and $2 are 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:

  1. 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.
  2. Store recorded_at on event_targets and index (entity_project_id, recorded_at). Even with fix 1, a request reads all events after the cursor until it finds limit events 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

  1. self-assigned this
    on Nov 5, 2026
  2. ShivanshShukla commented on Nov 10, 2026

    @ShivanshShukla

    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 OR condition is spot on.

    My Execution Plan:

    1. I will implement Fix 1 immediately by injecting the redundant 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.
    2. Regarding Fix 2: Since this requires a database migration (denormalising recorded_at onto event_targets and 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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions