Skip to content

Commit cce9574

Browse files
authored
Merge pull request #35 from cardmagic/fix/polling-query-performance
Optimize polling query plans
2 parents edced2c + 1d46128 commit cce9574

20 files changed

Lines changed: 1078 additions & 126 deletions

‎CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Changelog
22

3+
## 0.14.6 - 2026-09-03
4+
5+
- Poll effects, reminders, and broadcasts through ordered indexes installed by
6+
schema migration 8. PostgreSQL and MySQL claim one row with
7+
`FOR UPDATE SKIP LOCKED`; candidate probes avoid joins and load actor identity
8+
by primary key after the claim. SQLite keeps its serialized transaction path.
9+
The broadcast revision guard has its own
10+
`(instance_id, state_revision, status)` index instead of rescanning the outbox
11+
for every candidate. Claim transactions use read committed isolation on
12+
PostgreSQL and MySQL so preceding recovery work cannot turn row skipping into
13+
InnoDB gap-lock contention.
14+
- Replace bulk broadcast and reminder recovery updates with separate available
15+
and stale probes. Compare category heads without locking them, lock only the
16+
oldest candidate by primary key, and retry past work already locked by another
17+
claimant. This preserves global delivery order, concurrent progress,
18+
per-instance broadcast revision order, stale-process recovery, and
19+
at-least-once delivery.
20+
- On 50,000 production-shaped rows, SQLite, PostgreSQL 18, and MySQL 8.4 all
21+
move from scans and explicit sorts to ordered index probes. The worst SQLite
22+
broadcast probe fell from 944 ms to 0.018 ms; PostgreSQL's probes finish in
23+
0.02-0.09 ms and MySQL's in 0.10-1.2 ms.
24+
325
## 0.14.5 - 2026-08-29
426

527
- Record the Ruby state warning in the parity ledger. The row said the Ruby gem

‎docs/api.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,12 @@ rows for `preview()` and rows actually deleted for `prune()`.
206206
`InstrumentationEvent` type the host integration contract. `JsonPrimitive`,
207207
`JsonValue`, `JsonObject`, `DeepReadonly`, `ActorIdentifier`, `MessageContext`,
208208
`MessageStatus`, and `Logger` are shared types. `Database`,
209-
`DatabaseConnection`, `DatabaseFamily`, and `RunResult` support custom database
210-
and commit-action integration.
209+
`DatabaseConnection`, `DatabaseFamily`, `DatabaseTransactionOptions`, and
210+
`RunResult` support custom database and commit-action integration. A custom
211+
PostgreSQL or MySQL adapter must honor
212+
`transaction(callback, { isolationLevel: "read_committed" })`; outbox claiming
213+
uses that isolation with row locks to avoid InnoDB gap-lock contention. SQLite
214+
adapters may treat the option as their ordinary serialized transaction.
211215

212216
`BroadcastEvent.observables` contains changed value-broadcast projections.
213217
`BroadcastEvent.invalidations` contains changed invalidation-only names. The

‎docs/parity.md‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,15 @@ such boundary between a gem and its dependents.
8080

8181
## Databases and wake-up
8282

83-
| Capability | Status | TypeScript shape or remaining work |
84-
| ------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
85-
| SQLite | Native | Uses built-in `node:sqlite`, serialized process-local access, bounded transient writer retries, foreign keys, strict tables, database time, and deadline-bounded access and lock waits. |
86-
| PostgreSQL | Native | Optional `pg` 8.23 peer, bounded pooling, 64-bit schema, row-locked sequences, server checks, and deadline-bounded pool, statement, and lock waits. |
87-
| MySQL | Native | Optional `mysql2` 3.23 peer, bounded pooling, InnoDB schema, row-locked sequences, scoped deadlock retry, and deadline-bounded pool, query, and lock waits. Ruby also tests a second client, `trilogy`; Node has no comparable second MySQL client, so only `mysql2` is tracked here. |
88-
| Durable polling fallback | Native | Every role progresses without a notification service. |
89-
| In-process wake-up | Native | A generation-based default adapter prevents claim-to-wait signal loss; commits wake role-specific waiters and polling remains the fallback. |
90-
| PostgreSQL wake-up | Native | `database.wakeUp()` uses one dedicated event-driven client, role-specific `LISTEN/NOTIFY`, generation fencing, reconnectable listeners, and durable polling fallback. |
91-
| Redis wake-up | Native | An optional `redis` peer provides role-specific Pub/Sub over separate lazy publisher/subscriber connections, with bounded failures and durable polling fallback. |
83+
| Capability | Status | TypeScript shape or remaining work |
84+
| ------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
85+
| SQLite | Native | Uses built-in `node:sqlite`, serialized process-local access, bounded transient writer retries, foreign keys, strict tables, database time, and deadline-bounded access and lock waits. |
86+
| PostgreSQL | Native | Optional `pg` 8.23 peer, bounded pooling, 64-bit schema, row-locked sequences, server checks, and deadline-bounded pool, statement, and lock waits. |
87+
| MySQL | Native | Optional `mysql2` 3.23 peer, bounded pooling, InnoDB schema, row-locked sequences, scoped deadlock retry, and deadline-bounded pool, query, and lock waits. Ruby also tests a second client, `trilogy`; Node has no comparable second MySQL client, so only `mysql2` is tracked here. |
88+
| Durable polling fallback | Native | Every role progresses without a notification service. Effects, reminders, and broadcasts use canonical ordered polling indexes; PostgreSQL and MySQL lock only the selected row with `FOR UPDATE SKIP LOCKED`. Broadcasts and reminders compare separate available and stale-recovery probes, preserve the oldest-first choice, and retry past candidates locked by another claimant. |
89+
| In-process wake-up | Native | A generation-based default adapter prevents claim-to-wait signal loss; commits wake role-specific waiters and polling remains the fallback. |
90+
| PostgreSQL wake-up | Native | `database.wakeUp()` uses one dedicated event-driven client, role-specific `LISTEN/NOTIFY`, generation fencing, reconnectable listeners, and durable polling fallback. |
91+
| Redis wake-up | Native | An optional `redis` peer provides role-specific Pub/Sub over separate lazy publisher/subscriber connections, with bounded failures and durable polling fallback. |
9292

9393
Every wake-up adapter above is opt-in. Neither runtime selects one
9494
automatically. An application that configures nothing keeps polling. Each

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "solid-objects",
3-
"version": "0.14.5",
3+
"version": "0.14.6",
44
"description": "Race-free realtime state per application identity, backed by your SQL database",
55
"type": "module",
66
"license": "MIT",

‎src/application-database.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { applicationWritesForbidden } from "./context.js"
2-
import type { Database, DatabaseConnection } from "./database/types.js"
2+
import type { Database, DatabaseConnection, DatabaseTransactionOptions } from "./database/types.js"
33
import { ApplicationWriteForbidden } from "./errors.js"
44

55
export function guardApplicationDatabase(database: Database): Database {
@@ -27,8 +27,9 @@ class GuardedApplicationDatabase implements Database {
2727

2828
transaction<Result>(
2929
callback: (connection: DatabaseConnection) => Promise<Result>,
30+
options: DatabaseTransactionOptions = {},
3031
): Promise<Result> {
31-
return this.database.transaction((connection) => callback(guardConnection(connection)))
32+
return this.database.transaction((connection) => callback(guardConnection(connection)), options)
3233
}
3334

3435
close(): Promise<void> {

‎src/database/mysql.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import mysqlDriver, {
66
type RowDataPacket,
77
} from "mysql2/promise"
88
import type { ExecuteValues } from "mysql2"
9-
import type { Database, DatabaseConnection, RunResult } from "./types.js"
9+
import type {
10+
Database,
11+
DatabaseConnection,
12+
DatabaseTransactionOptions,
13+
RunResult,
14+
} from "./types.js"
1015
import {
1116
acquireBeforeDatabaseDeadline,
1217
databaseDeadlineRemainingMilliseconds,
@@ -152,6 +157,7 @@ export class MySQLDatabase implements Database {
152157

153158
async transaction<Result>(
154159
callback: (connection: DatabaseConnection) => Promise<Result>,
160+
options: DatabaseTransactionOptions = {},
155161
): Promise<Result> {
156162
return withDatabaseTransaction(this, async () => {
157163
const deadlineActive = databaseDeadlineRemainingMilliseconds() !== undefined
@@ -160,6 +166,9 @@ export class MySQLDatabase implements Database {
160166
(lateConnection) => lateConnection.release(),
161167
)
162168
try {
169+
if (options.isolationLevel === "read_committed") {
170+
await connection.query("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
171+
}
163172
await connection.beginTransaction()
164173
const remaining = requireDatabaseDeadlineRemaining()
165174
if (remaining !== undefined) {

‎src/database/postgresql.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import "../platform/node.js"
22
import { Pool, TypeOverrides, types, type PoolClient, type PoolConfig } from "pg"
33
import { postgresqlSql } from "./postgresql-sql.js"
4-
import type { Database, DatabaseConnection, RunResult } from "./types.js"
4+
import type {
5+
Database,
6+
DatabaseConnection,
7+
DatabaseTransactionOptions,
8+
RunResult,
9+
} from "./types.js"
510
import { PostgreSQLWakeUpAdapter, type PostgreSQLWakeUpFailure } from "../wake-up/postgresql.js"
611
import {
712
acquireBeforeDatabaseDeadline,
@@ -154,6 +159,7 @@ export class PostgreSQLDatabase implements Database {
154159

155160
async transaction<Result>(
156161
callback: (connection: DatabaseConnection) => Promise<Result>,
162+
options: DatabaseTransactionOptions = {},
157163
): Promise<Result> {
158164
return withDatabaseTransaction(this, async () => {
159165
const deadlineActive = databaseDeadlineRemainingMilliseconds() !== undefined
@@ -162,7 +168,11 @@ export class PostgreSQLDatabase implements Database {
162168
(lateClient) => lateClient.release(),
163169
)
164170
try {
165-
await client.query("BEGIN")
171+
await client.query(
172+
options.isolationLevel === "read_committed"
173+
? "BEGIN ISOLATION LEVEL READ COMMITTED"
174+
: "BEGIN",
175+
)
166176
const remaining = requireDatabaseDeadlineRemaining()
167177
if (remaining !== undefined) {
168178
await applyPostgreSQLDeadline({ client, milliseconds: remaining, scope: "transaction" })

‎src/database/types.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ export interface RunResult {
55
lastInsertId?: string
66
}
77

8+
export interface DatabaseTransactionOptions {
9+
isolationLevel?: "read_committed"
10+
}
11+
812
export interface DatabaseConnection {
913
run(sql: string, parameters?: readonly unknown[]): Promise<RunResult>
1014
get<Row extends object>(sql: string, parameters?: readonly unknown[]): Promise<Row | undefined>
@@ -19,6 +23,7 @@ export interface Database {
1923
connection<Result>(callback: (connection: DatabaseConnection) => Promise<Result>): Promise<Result>
2024
transaction<Result>(
2125
callback: (connection: DatabaseConnection) => Promise<Result>,
26+
options?: DatabaseTransactionOptions,
2227
): Promise<Result>
2328
close(): Promise<void>
2429
}

‎src/doctor.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,11 +202,11 @@ export class Doctor {
202202
message: `incompatible schema identity ${wrongIdentity.schema_identity}`,
203203
})
204204
}
205-
if (versions.join(",") !== "1,2,3,4,5,6,7") {
205+
if (versions.join(",") !== "1,2,3,4,5,6,7,8") {
206206
return check({
207207
name: "schema",
208208
status: "fail",
209-
message: `expected schema migrations 1, 2, 3, 4, 5, 6; found ${versions.join(", ")}`,
209+
message: `expected schema migrations 1, 2, 3, 4, 5, 6, 7, 8; found ${versions.join(", ")}`,
210210
})
211211
}
212212
return check({

‎src/index.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,13 @@ export type {
133133
MessageStatus,
134134
SnapshotOptions,
135135
} from "./types.js"
136-
export type { Database, DatabaseConnection, DatabaseFamily, RunResult } from "./database/types.js"
136+
export type {
137+
Database,
138+
DatabaseConnection,
139+
DatabaseFamily,
140+
DatabaseTransactionOptions,
141+
RunResult,
142+
} from "./database/types.js"
137143
export {
138144
ApplicationWriteForbidden,
139145
ActorCallCycle,

0 commit comments

Comments
 (0)