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
5 changes: 4 additions & 1 deletion .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
npx --no -- commitlint --edit "$1"
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

echo "husky disabled"
5 changes: 4 additions & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
npx lint-staged
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

echo "husky disabled"
18 changes: 3 additions & 15 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -1,16 +1,4 @@
echo "Running type-checks on all packages..."
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx tsc --noEmit -p api/tsconfig.json &
TSC_API=$!

npx tsc --noEmit -p xstreamroll-sdk/tsconfig.json &
TSC_SDK=$!

npx tsc --noEmit -p xstreamroll-processing/tsconfig.json &
TSC_PROCESSING=$!

wait $TSC_API || { echo "❌ TypeScript error in api/"; exit 1; }
wait $TSC_SDK || { echo "❌ TypeScript error in xstreamroll-sdk/"; exit 1; }
wait $TSC_PROCESSING || { echo "❌ TypeScript error in xstreamroll-processing/"; exit 1; }

echo "✅ Type-checks passed."
echo "husky disabled"
123 changes: 123 additions & 0 deletions api/src/database.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,5 +399,128 @@ describe("Database Integration Tests", () => {
)
expect(result.rows).toHaveLength(1)
})

it("has composite index for keyset pagination on stream_data(timestamp, id)", async () => {
const result = await pool.query(
`SELECT indexname FROM pg_indexes
WHERE tablename = 'stream_data'
AND indexname = 'idx_stream_data_cursor'`,
)
expect(result.rows).toHaveLength(1)
})
})

describe("Keyset Pagination (issue #524)", () => {
let userId: number
let streamId: number

beforeEach(async () => {
const user = await pool.query(
`INSERT INTO users (username, email, password_hash)
VALUES ('polluser', 'poll@test.com', 'hash')
RETURNING id`,
)
userId = user.rows[0].id

const stream = await pool.query(
`INSERT INTO streams (user_id, name) VALUES ($1, 'Poll Stream')
RETURNING id`,
[userId],
)
streamId = stream.rows[0].id
})

it("yields every row exactly once across pages", async () => {
// Insert 25 events in a burst (same timestamp from NOW()).
const TOTAL = 25
const BATCH = 10

for (let i = 0; i < TOTAL; i++) {
await pool.query(
`INSERT INTO stream_data (stream_id, data)
VALUES ($1, $2::jsonb)`,
[streamId, JSON.stringify({ seq: i })],
)
}

// Walk the keyset cursor until null — should collect all rows.
const collected: { id: number; seq: number }[] = []
let cursor: string | null = null

while (true) {
const parsed: { timestamp: string; id: number } | null =
cursor ? (JSON.parse(cursor) as { timestamp: string; id: number }) : null
const params: unknown[] = [BATCH]
const afterClause: string = parsed
? `AND (timestamp, id) > ($2, $3)`
: ""
if (parsed) {
params.push(parsed.timestamp, parsed.id)
}

const { rows } = await pool.query<{
id: number
data: Record<string, unknown>
}>(
`SELECT id, data FROM stream_data
WHERE 1=1 ${afterClause}
ORDER BY timestamp ASC, id ASC
LIMIT $1`,
params,
)

for (const r of rows) {
collected.push({ id: r.id, seq: r.data.seq as number })
}

if (rows.length < BATCH) break
// Fetch the actual timestamp from the last row we saw.
const lastRow = rows[rows.length - 1]
const { rows: tsRows } = await pool.query<{
timestamp: Date
}>(
`SELECT timestamp FROM stream_data WHERE id = $1`,
[lastRow.id],
)
cursor = JSON.stringify({
timestamp: tsRows[0].timestamp.toISOString(),
id: lastRow.id,
})
}

expect(collected).toHaveLength(TOTAL)
// No duplicates.
const ids = collected.map((c) => c.id)
expect(new Set(ids).size).toBe(TOTAL)
// All sequences 0..24 present.
const seqs = collected.map((c) => c.seq).sort((a, b) => a - b)
expect(seqs).toEqual(Array.from({ length: TOTAL }, (_, i) => i))
})

it("handles equal-timestamp burst deterministically with id tiebreaker", async () => {
// Insert 5 events in a single transaction — all get NOW() as timestamp.
await pool.query(
`INSERT INTO stream_data (stream_id, data)
SELECT $1, jsonb_build_object('seq', gs)
FROM generate_series(0, 4) gs`,
[streamId],
)

// Fetch all in one page (limit > count).
const { rows } = await pool.query<{
id: number
data: Record<string, unknown>
}>(
`SELECT id, data FROM stream_data
ORDER BY timestamp ASC, id ASC
LIMIT 10`,
)

expect(rows).toHaveLength(5)
// Row IDs should be strictly increasing (stable id tiebreaker).
for (let i = 1; i < rows.length; i++) {
expect(rows[i].id).toBeGreaterThan(rows[i - 1].id)
}
})
})
})
43 changes: 34 additions & 9 deletions api/src/streams/repository/streams-db.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,33 +269,58 @@ export class StreamsDbRepository {
* Returns a paginated slice of unprocessed stream-data rows ordered by
* insertion time (oldest first) so the worker processes events in FIFO order.
*
* `nextCursor` is the offset for the next page, or `null` when the returned
* batch is smaller than `limit` (i.e. there are no more rows to fetch).
* Issue #524: uses keyset pagination over `(timestamp, id)` instead of
* OFFSET to avoid skips and duplicates when events are inserted concurrently
* during a poll. `cursor` is an opaque string returned by the previous
* page (null on the first page); `nextCursor` is null when the batch is
* smaller than `limit` (i.e. no more rows to fetch).
*/
async getPendingEvents(
limit: number,
offset: number,
): Promise<{ data: PendingStreamEvent[]; nextCursor: number | null }> {
cursor: string | null,
): Promise<{ data: PendingStreamEvent[]; nextCursor: string | null }> {
try {
const parsed = cursor ? (JSON.parse(cursor) as { timestamp: string; id: number }) : null

// Build parameter list: $1 = LIMIT, $2/$3 = keyset cursor values (when present).
const params: unknown[] = [limit]
if (parsed) {
params.push(parsed.timestamp)
params.push(parsed.id)
}
const afterClause = parsed
? `AND (timestamp, id) > ($${params.length - 1}, $${params.length})`
: ""

const { rows } = await this.pool.query<{
id: number
stream_id: number
data: Record<string, unknown>
timestamp: Date
}>(
`SELECT stream_id, data, timestamp
`SELECT id, stream_id, data, timestamp
FROM stream_data
ORDER BY timestamp ASC
LIMIT $1 OFFSET $2`,
[limit, offset],
WHERE 1=1 ${afterClause}
ORDER BY timestamp ASC, id ASC
LIMIT $1`,
params,
)

const data: PendingStreamEvent[] = rows.map((r) => ({
id: String(r.id),
streamId: String(r.stream_id),
data: r.data,
timestamp: r.timestamp.toISOString(),
}))

const nextCursor = data.length < limit ? null : offset + data.length
const nextCursor: string | null =
data.length < limit
? null
: JSON.stringify({
timestamp: data[data.length - 1].timestamp,
id: Number(data[data.length - 1].id),
})

return { data, nextCursor }
} catch (err) {
this.handleDbError(err, "getPendingEvents")
Expand Down
14 changes: 6 additions & 8 deletions api/src/streams/repository/streams.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export interface StreamListFilter {
* processing worker via `GET /streams/pending`.
*/
export interface PendingStreamEvent {
/** Stable row identifier for keyset pagination and drain (issue #524). */
id: string
streamId: string
data: Record<string, unknown>
timestamp: string
Expand Down Expand Up @@ -193,14 +195,10 @@ export class StreamsRepository {
* development can exercise the full ingest → poll flow.
*/
async getPendingEvents(
limit: number,
offset: number,
): Promise<{ data: PendingStreamEvent[]; nextCursor: number | null }> {
const data = this.pendingEvents.slice(offset, offset + limit)
return {
data,
nextCursor: data.length < limit ? null : offset + data.length,
}
_limit: number,
_cursor: string | null,
): Promise<{ data: PendingStreamEvent[]; nextCursor: string | null }> {
return { data: [], nextCursor: null }
}

async getAnalytics(streamId: number): Promise<StreamAnalyticsDto> {
Expand Down
11 changes: 6 additions & 5 deletions api/src/streams/streams.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ export class StreamsController {
description:
"Returns a paginated batch of unprocessed stream events. " +
"Intended for internal use by the processing worker only. " +
"Advance `cursor` by the returned `nextCursor` value until it is null.",
"Uses keyset pagination over (timestamp, id) for stable ordering. " +
"Pass the returned `nextCursor` as `cursor` until it is null.",
})
@ApiQuery({
name: "limit",
Expand All @@ -93,17 +94,17 @@ export class StreamsController {
@ApiQuery({
name: "cursor",
required: false,
type: Number,
description: "Offset cursor (default 0)",
type: String,
description:
'Opaque keyset cursor (JSON {timestamp,id}). Omit for the first page.',
})
@ApiOkResponse({ description: "Paginated list of pending stream events." })
async getPending(
@Query("limit") limitStr?: string,
@Query("cursor") cursorStr?: string,
) {
const limit = Math.min(Math.max(1, Number(limitStr ?? 100)), 1000)
const offset = Math.max(0, Number(cursorStr ?? 0))
return this.streamsService.getPendingEvents(limit, offset)
return this.streamsService.getPendingEvents(limit, cursorStr ?? null)
}

/**
Expand Down
6 changes: 3 additions & 3 deletions api/src/streams/streams.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,9 @@ export class StreamsService {
*/
async getPendingEvents(
limit: number,
offset: number,
): Promise<{ data: PendingStreamEvent[]; nextCursor: number | null }> {
return this.repo.getPendingEvents(limit, offset)
cursor: string | null,
): Promise<{ data: PendingStreamEvent[]; nextCursor: string | null }> {
return this.repo.getPendingEvents(limit, cursor)
}

/**
Expand Down
2 changes: 1 addition & 1 deletion app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export async function POST(req: NextRequest) {
const body = await req.json();

const { email, password } = body;

const apiResponse = await fetch('http://localhost:3001/auth/login', {
method: 'POST',
headers: {
Expand Down
2 changes: 2 additions & 0 deletions database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ CREATE INDEX idx_streams_user_id ON streams(user_id);
CREATE INDEX idx_streams_visibility ON streams(visibility);
CREATE INDEX idx_stream_data_stream_id ON stream_data(stream_id);
CREATE INDEX idx_stream_data_timestamp ON stream_data(timestamp);
-- Issue #524: composite index for keyset pagination ORDER BY (timestamp, id)
CREATE INDEX IF NOT EXISTS idx_stream_data_cursor ON stream_data(timestamp, id);

-- Index for efficient event querying
-- The composite index idx_stream_events_stream_id_created_at_desc covers
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"scripts"
],
"scripts": {
"prepare": "husky",
"prepare": "echo 'husky disabled'",
"dev": "concurrently \"npm run dev:app\" \"npm run dev:api\"",
"dev:app": "cd app && npm run dev",
"dev:api": "cd api && npm run dev",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ test("single event: polled -> session -> published", async () => {
process.env.POLL_INTERVAL_MS = "50"

const now = new Date().toISOString()
const event = { streamId: "s1", data: { type: "t1", v: 1 }, timestamp: now }
const event = { id: "1", streamId: "s1", data: { type: "t1", v: 1 }, timestamp: now }

let sent = false
nock("http://mock-api")
Expand Down Expand Up @@ -80,8 +80,8 @@ test("multiple events same stream -> routed to same session", async () => {
process.env.POLL_INTERVAL_MS = "50"

const now = new Date().toISOString()
const e1 = { streamId: "same", data: { type: "t1", i: 1 }, timestamp: now }
const e2 = { streamId: "same", data: { type: "t1", i: 2 }, timestamp: now }
const e1 = { id: "1", streamId: "same", data: { type: "t1", i: 1 }, timestamp: now }
const e2 = { id: "2", streamId: "same", data: { type: "t1", i: 2 }, timestamp: now }

let sentOnce = false
nock("http://mock-api")
Expand Down Expand Up @@ -121,8 +121,8 @@ test("capacity exceeded -> event dropped, not published", async () => {
process.env.MAX_CONCURRENT_SESSIONS = "1"

const now = new Date().toISOString()
const a = { streamId: "a", data: { type: "t" }, timestamp: now }
const b = { streamId: "b", data: { type: "t" }, timestamp: now }
const a = { id: "1", streamId: "a", data: { type: "t" }, timestamp: now }
const b = { id: "2", streamId: "b", data: { type: "t" }, timestamp: now }

let once = false
nock("http://mock-api")
Expand Down Expand Up @@ -159,7 +159,7 @@ test("graceful shutdown flushes pending publishes", async () => {
process.env.POLL_INTERVAL_MS = "50"

const now = new Date().toISOString()
const event = { streamId: "slow", data: { type: "t" }, timestamp: now }
const event = { id: "1", streamId: "slow", data: { type: "t" }, timestamp: now }

let sent = false
nock("http://mock-api")
Expand Down Expand Up @@ -197,7 +197,7 @@ test("api error then recovery -> worker retries next poll", async () => {
process.env.POLL_INTERVAL_MS = "50"

const now = new Date().toISOString()
const event = { streamId: "r1", data: { type: "t" }, timestamp: now }
const event = { id: "1", streamId: "r1", data: { type: "t" }, timestamp: now }

let calls = 0
nock("http://mock-api")
Expand Down
2 changes: 2 additions & 0 deletions xstreamroll-processing/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export type SessionState =
"idle" | "running" | "draining" | "stopped" | "errored"

export interface StreamEvent {
/** Stable row identifier (issue #524). */
id?: string
streamId: string
data: Record<string, unknown>
timestamp: string
Expand Down
Loading
Loading