Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions server/internal/app/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2730,9 +2730,24 @@ paths:
- $ref: "#/components/parameters/CheckUidPath"
- name: cursor
in: query
description: Cursor for pagination
description: >-
Opaque keyset cursor; pass the previous response's `pagination.cursor` back
verbatim. A cursor that cannot be parsed starts from the first page. With
`order=asc` it means "strictly after this row" and the response always carries
a cursor for its last row, even on the last page (an empty page returns the
cursor it was given), so a consumer stores it and resumes from it later.
schema:
type: string
- name: order
in: query
description: >-
Sort order on `(created_at, uid)`. `desc` (default) is newest first. `asc` is oldest
first and only returns events older than 5 seconds (settle window), so a
consumer sees a row at most 5 s late and never skips one.
schema:
type: string
enum: [desc, asc]
default: desc
- name: size
in: query
description: Results per page (default 20, max 100)
Expand Down Expand Up @@ -3535,9 +3550,25 @@ paths:
example: "degraded"
- name: cursor
in: query
description: Cursor for pagination
description: >-
Opaque keyset cursor; pass the previous response's `pagination.cursor` back
verbatim. A cursor that cannot be parsed starts from the first page. With
`order=asc` it means "strictly after this row" and the response always carries
a cursor for its last row, even on the last page (an empty page returns the
cursor it was given), so a consumer stores it and resumes from it later.
schema:
type: string
- name: order
in: query
description: >-
Sort order on `(started_at, uid)`. `desc` (default) is newest first. `asc` is oldest
first and only returns incidents older than 5 seconds (settle window), so a
consumer sees a row at most 5 s late and never skips one.
`asc` lists new incidents only: changes to existing incidents (acked, resolved, reopened) are events, follow `GET /events?type=incident&order=asc`.
schema:
type: string
enum: [desc, asc]
default: desc
- name: size
in: query
description: Results per page (default 20, max 100)
Expand Down Expand Up @@ -3609,9 +3640,24 @@ paths:
- $ref: "#/components/parameters/IncidentUidPath"
- name: cursor
in: query
description: Cursor for pagination
description: >-
Opaque keyset cursor; pass the previous response's `pagination.cursor` back
verbatim. A cursor that cannot be parsed starts from the first page. With
`order=asc` it means "strictly after this row" and the response always carries
a cursor for its last row, even on the last page (an empty page returns the
cursor it was given), so a consumer stores it and resumes from it later.
schema:
type: string
- name: order
in: query
description: >-
Sort order on `(created_at, uid)`. `desc` (default) is newest first. `asc` is oldest
first and only returns events older than 5 seconds (settle window), so a
consumer sees a row at most 5 s late and never skips one.
schema:
type: string
enum: [desc, asc]
default: desc
- name: size
in: query
description: Results per page (default 20, max 100)
Expand Down Expand Up @@ -4771,9 +4817,24 @@ paths:
format: date-time
- name: cursor
in: query
description: Cursor for pagination
description: >-
Opaque keyset cursor; pass the previous response's `pagination.cursor` back
verbatim. A cursor that cannot be parsed starts from the first page. With
`order=asc` it means "strictly after this row" and the response always carries
a cursor for its last row, even on the last page (an empty page returns the
cursor it was given), so a consumer stores it and resumes from it later.
schema:
type: string
- name: order
in: query
description: >-
Sort order on `(created_at, uid)`. `desc` (default) is newest first. `asc` is oldest
first and only returns events older than 5 seconds (settle window), so a
consumer sees a row at most 5 s late and never skips one.
schema:
type: string
enum: [desc, asc]
default: desc
- name: size
in: query
description: Results per page (default 20, max 100)
Expand Down
7 changes: 7 additions & 0 deletions server/internal/db/models/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,13 @@ type ListEventsFilter struct {
CursorTimestamp *time.Time // Optional: events with created_at < this timestamp
CursorUID *string // Optional: for same timestamp, events with UID < this

// Ascending lists oldest first on (created_at, uid); the cursor then means
// "strictly after" and the comparisons flip.
Ascending bool
// SettledBefore only admits events created before this instant (forward
// listings hold back rows that may still be committed out of order).
SettledBefore *time.Time

Limit int // Optional: pagination limit
}

Expand Down
6 changes: 6 additions & 0 deletions server/internal/db/models/incident.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,12 @@ type ListIncidentsFilter struct {
CursorTimestamp *time.Time // Optional: incidents with started_at < this timestamp
CursorUID *string // Optional: for same timestamp, incidents with UID < this

// Ascending lists oldest first on (started_at, uid); the cursor then means
// "strictly after" and the comparisons flip.
Ascending bool
// SettledBefore only admits incidents started before this instant.
SettledBefore *time.Time

Limit int // Optional: pagination limit
}

Expand Down
50 changes: 38 additions & 12 deletions server/internal/db/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -4150,22 +4150,34 @@ func (s *Service) ListIncidents(
) ([]*models.Incident, int64, error) {
var incidents []*models.Incident

order := []string{"started_at DESC", "uid DESC"}
cmp := "<"

if filter.Ascending {
order = []string{"started_at ASC", "uid ASC"}
cmp = ">"
}

query := applyIncidentsFilter(
s.db.NewSelect().Model(&incidents).Order("started_at DESC"),
s.db.NewSelect().Model(&incidents).Order(order...),
filter,
)

countQuery := applyIncidentsFilter(s.db.NewSelect().Model((*models.Incident)(nil)), filter)

if filter.CursorTimestamp != nil {
if filter.CursorUID != nil {
query = query.Where("(started_at < ? OR (started_at = ? AND uid < ?))",
query = query.Where("(started_at "+cmp+" ? OR (started_at = ? AND uid "+cmp+" ?))",
*filter.CursorTimestamp, *filter.CursorTimestamp, *filter.CursorUID)
} else {
query = query.Where("started_at < ?", *filter.CursorTimestamp)
query = query.Where("started_at "+cmp+" ?", *filter.CursorTimestamp)
}
}

if filter.SettledBefore != nil {
query = query.Where("started_at < ?", *filter.SettledBefore)
}

if filter.Limit > 0 {
query = query.Limit(filter.Limit)
}
Expand Down Expand Up @@ -4328,25 +4340,39 @@ func (s *Service) ListEvents(ctx context.Context, filter *models.ListEventsFilte

query := s.db.NewSelect().
Model(&events).
Where("organization_uid = ?", filter.OrganizationUID).
// uid is part of the sort key, not decoration: the keyset cursor's
// predicate is (created_at < ? OR (created_at = ? AND uid < ?)), which
// only works if rows sharing a created_at have a DEFINED order. Without
// this tie-break an audit trail — which bulk-inserts within the same
// microsecond constantly — could skip events at a page boundary.
Order("created_at DESC", "uid DESC")
Where("organization_uid = ?", filter.OrganizationUID)

// uid is part of the sort key, not decoration: the keyset cursor's
// predicate is (created_at < ? OR (created_at = ? AND uid < ?)), which
// only works if rows sharing a created_at have a DEFINED order. Without
// this tie-break an audit trail — which bulk-inserts within the same
// microsecond constantly — could skip events at a page boundary.
if filter.Ascending {
query = query.Order("created_at ASC", "uid ASC")
} else {
query = query.Order("created_at DESC", "uid DESC")
}

query = applyEventFilters(query, filter)

if filter.CursorTimestamp != nil {
cmp := "<"
if filter.Ascending {
cmp = ">"
}

if filter.CursorUID != nil {
query = query.Where("(created_at < ? OR (created_at = ? AND uid < ?))",
query = query.Where("(created_at "+cmp+" ? OR (created_at = ? AND uid "+cmp+" ?))",
*filter.CursorTimestamp, *filter.CursorTimestamp, *filter.CursorUID)
} else {
query = query.Where("created_at < ?", *filter.CursorTimestamp)
query = query.Where("created_at "+cmp+" ?", *filter.CursorTimestamp)
}
}

if filter.SettledBefore != nil {
query = query.Where("created_at < ?", *filter.SettledBefore)
}

if filter.Limit > 0 {
query = query.Limit(filter.Limit)
}
Expand Down
97 changes: 97 additions & 0 deletions server/internal/db/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"os"
"sort"
"strconv"
"sync"
"testing"
Expand Down Expand Up @@ -73,6 +74,7 @@ func testService(t *testing.T, svc db.Service) {

t.Run("EventsKeysetPaginationTieBreak", func(t *testing.T) {
testEventsKeysetPaginationTieBreak(ctx, t, svc)
testAscendingKeysetPagination(ctx, t, svc)
})

t.Run("EventsTargetPayloadFilters", func(t *testing.T) {
Expand Down Expand Up @@ -2904,3 +2906,98 @@ func strPtrTest(value string) *string { return &value }
// tokenActivityTimePtr is a local pointer helper for the TokenActivityByUsers
// test cases above.
func tokenActivityTimePtr(value time.Time) *time.Time { return &value }

// testAscendingKeysetPagination walks events and incidents oldest first with
// a page size smaller than the group of rows sharing one timestamp, and checks
// the settle bound. Runs on both engines: the flipped comparison and the
// ASC tie-break are SQL, not Go.
func testAscendingKeysetPagination(ctx context.Context, t *testing.T, svc db.Service) {
t.Helper()

r := require.New(t)

org := models.NewOrganization("asc-"+uuid.New().String()[:8], "Acme")
r.NoError(svc.CreateOrganization(ctx, org))

check := models.NewCheck(org.UID, "asc-check", "http")
r.NoError(svc.CreateCheck(ctx, check))

base := time.Date(2026, time.October, 1, 9, 0, 0, 0, time.UTC)
// Ten rows on one instant, then one later row (the settle test excludes it).
for i := 0; i < 11; i++ {
at := base
if i == 10 {
at = base.Add(time.Hour)
}

event := models.NewEvent(org.UID, models.EventTypeCheckCreated, models.ActorTypeSystem)
event.CreatedAt = at
r.NoError(svc.CreateEvent(ctx, event))

inc := models.NewIncident(org.UID, check.UID, at, "asc incident")
r.NoError(svc.CreateIncident(ctx, inc))
}

settled := base.Add(30 * time.Minute)

var (
seenEvents []string
seenIncidents []string
evTS, incTS *time.Time
evUID, incUID *string
)

for page := 0; page < 10; page++ {
rows, err := svc.ListEvents(ctx, &models.ListEventsFilter{
OrganizationUID: org.UID, Limit: 3, Ascending: true, SettledBefore: &settled,
CursorTimestamp: evTS, CursorUID: evUID,
})
r.NoError(err)

if len(rows) == 0 {
break
}

for _, row := range rows {
seenEvents = append(seenEvents, row.UID)
}

ts, uid := rows[len(rows)-1].CreatedAt, rows[len(rows)-1].UID
evTS, evUID = &ts, &uid
}

for page := 0; page < 10; page++ {
rows, _, err := svc.ListIncidents(ctx, &models.ListIncidentsFilter{
OrganizationUID: org.UID, Limit: 3, Ascending: true, SettledBefore: &settled,
CursorTimestamp: incTS, CursorUID: incUID,
})
r.NoError(err)

if len(rows) == 0 {
break
}

for _, row := range rows {
seenIncidents = append(seenIncidents, row.UID)
}

ts, uid := rows[len(rows)-1].StartedAt, rows[len(rows)-1].UID
incTS, incUID = &ts, &uid
}

r.Len(seenEvents, 10, "ten tied events once each, the unsettled one held back")
r.Len(seenIncidents, 10, "ten tied incidents once each, the unsettled one held back")
r.Len(uniqueStrings(seenEvents), 10)
r.Len(uniqueStrings(seenIncidents), 10)
r.True(sort.StringsAreSorted(seenEvents), "ties come back in uid order")
r.True(sort.StringsAreSorted(seenIncidents), "ties come back in uid order")
}

func uniqueStrings(in []string) map[string]struct{} {
out := make(map[string]struct{}, len(in))
for _, v := range in {
out[v] = struct{}{}
}

return out
}
Loading
Loading