@boxxy/store-sqlite 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +24 -3
  2. package/dist/codecs/parse-json-text.d.ts +1 -0
  3. package/dist/codecs/parse-store-row.d.ts +2 -0
  4. package/dist/codecs/table-codecs.d.ts +10 -0
  5. package/dist/codecs/to-column-name.d.ts +1 -0
  6. package/dist/codecs/to-store-columns.d.ts +3 -0
  7. package/dist/constants.d.ts +1 -0
  8. package/dist/create-sqlite-claim-store.d.ts +5 -0
  9. package/dist/index.d.ts +1 -1
  10. package/dist/migrations/create-tables-migration.d.ts +2 -0
  11. package/dist/migrations/rename-relations-migration.d.ts +2 -0
  12. package/dist/migrations/run-store-migrations.d.ts +3 -0
  13. package/dist/migrations/v1-tables-migration.d.ts +2 -0
  14. package/dist/reads/build-sqlite-reader.d.ts +4 -0
  15. package/dist/reads/is-latest-of-session.d.ts +3 -0
  16. package/dist/reads/read-item-views.d.ts +5 -0
  17. package/dist/reads/read-limit-fills.d.ts +4 -0
  18. package/dist/reads/read-wakes.d.ts +4 -0
  19. package/dist/test-utils/create-claim-bench.d.ts +8 -0
  20. package/dist/test-utils/create-test-claim-store.d.ts +2 -0
  21. package/dist/types.d.ts +172 -0
  22. package/dist/with-busy-retry.d.ts +1 -0
  23. package/dist/with-transaction.d.ts +3 -0
  24. package/dist/writes/apply-sqlite-change.d.ts +4 -0
  25. package/dist/writes/create-sqlite-tx.d.ts +4 -0
  26. package/dist/writes/take-write-lock.d.ts +3 -0
  27. package/package.json +15 -1
  28. package/src/codecs/parse-json-text.ts +5 -0
  29. package/src/codecs/parse-store-row.ts +36 -0
  30. package/src/codecs/table-codecs.ts +148 -0
  31. package/src/codecs/to-column-name.ts +3 -0
  32. package/src/codecs/to-store-columns.ts +44 -0
  33. package/src/constants.ts +1 -0
  34. package/src/create-sqlite-claim-store.ts +114 -0
  35. package/src/index.ts +1 -1
  36. package/src/migrations/create-tables-migration.ts +146 -0
  37. package/src/migrations/rename-relations-migration.ts +17 -0
  38. package/src/migrations/run-store-migrations.ts +42 -0
  39. package/src/migrations/v1-tables-migration.ts +184 -0
  40. package/src/reads/build-sqlite-reader.ts +484 -0
  41. package/src/reads/is-latest-of-session.ts +21 -0
  42. package/src/reads/read-item-views.ts +92 -0
  43. package/src/reads/read-limit-fills.ts +32 -0
  44. package/src/reads/read-wakes.ts +34 -0
  45. package/src/test-utils/create-claim-bench.ts +66 -0
  46. package/src/test-utils/create-test-claim-store.ts +23 -0
  47. package/src/types.ts +173 -0
  48. package/src/with-busy-retry.ts +32 -0
  49. package/src/with-transaction.ts +29 -0
  50. package/src/writes/apply-sqlite-change.ts +223 -0
  51. package/src/writes/create-sqlite-tx.ts +229 -0
  52. package/src/writes/take-write-lock.ts +8 -0
  53. package/dist/package-name.d.ts +0 -1
  54. package/src/package-name.ts +0 -1
@@ -0,0 +1,66 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { claimNext } from '@boxxy/dispatch';
5
+ import { createTestDispatch } from '@boxxy/dispatch/test-utils/create-test-dispatch';
6
+ import type { ItemId } from '@boxxy/model';
7
+ import { createSQLiteClaimStore } from '../create-sqlite-claim-store.ts';
8
+
9
+ export interface ClaimBenchOptions {
10
+ readonly itemCount: number;
11
+ }
12
+
13
+ export interface ClaimBench extends AsyncDisposable {
14
+ // one `claimNext` call, which holds the store's lock for the whole decision; throws when it
15
+ // picks no item
16
+ readonly claim: () => Promise<ItemId>;
17
+ }
18
+
19
+ // A SQLite store in a temp folder, seeded with open items at their pipeline's first phase, and
20
+ // every limit raised to the item count, so each claim finds a free seat with no close or settle
21
+ // between claims.
22
+ export async function createClaimBench(options: ClaimBenchOptions): Promise<ClaimBench> {
23
+ const stack = new AsyncDisposableStack();
24
+
25
+ const folder = await mkdtemp(join(tmpdir(), 'boxxy-claim-bench-'));
26
+
27
+ stack.defer(() => rm(folder, { recursive: true, force: true }));
28
+
29
+ const store = await createSQLiteClaimStore(join(folder, 'store.db'));
30
+
31
+ stack.defer(() => store.close());
32
+
33
+ const dispatch = await createTestDispatch({ store });
34
+
35
+ const rules = dispatch.rules();
36
+ const limits = rules.limits.map((limit) => ({ ...limit, max: options.itemCount }));
37
+
38
+ dispatch.setRules({ ...rules, limits });
39
+
40
+ await store.exclusive((tx) => tx.syncLimits(limits, dispatch.clock.now()));
41
+
42
+ for (let index = 0; index < options.itemCount; index += 1) {
43
+ await dispatch.seedItem({});
44
+ }
45
+
46
+ return {
47
+ claim: async () => {
48
+ const claim = await claimNext({
49
+ store,
50
+ runner: dispatch.runner,
51
+ rules: dispatch.rules(),
52
+ now: dispatch.clock.now(),
53
+ newId: dispatch.newId,
54
+ backoff: dispatch.backoff,
55
+ spawn: dispatch.spawn,
56
+ });
57
+
58
+ if (claim === null) {
59
+ throw new Error('the claim picked no item');
60
+ }
61
+
62
+ return claim.attempt.item;
63
+ },
64
+ [Symbol.asyncDispose]: () => stack.disposeAsync(),
65
+ };
66
+ }
@@ -0,0 +1,23 @@
1
+ import { onTestFinished } from 'bun:test';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { createSQLiteClaimStore } from '../create-sqlite-claim-store.ts';
6
+ import type { SQLiteClaimStore } from '../create-sqlite-claim-store.ts';
7
+
8
+ export async function createTestClaimStore(): Promise<SQLiteClaimStore> {
9
+ const cleanup = new AsyncDisposableStack();
10
+
11
+ onTestFinished(() => cleanup.disposeAsync());
12
+
13
+ // the process id keeps the folders of test runs that share a temp folder apart by name
14
+ const folder = await mkdtemp(join(tmpdir(), `boxxy-store-${process.pid}-`));
15
+
16
+ cleanup.defer(() => rm(folder, { recursive: true, force: true }));
17
+
18
+ const store = await createSQLiteClaimStore(join(folder, 'store.db'));
19
+
20
+ cleanup.defer(() => store.close());
21
+
22
+ return store;
23
+ }
package/src/types.ts ADDED
@@ -0,0 +1,173 @@
1
+ import type { Generated } from 'kysely';
2
+
3
+ export interface StoreDatabase {
4
+ items: {
5
+ id: string;
6
+ kind: string;
7
+ repo: string;
8
+ title: string;
9
+ outcome: string;
10
+ acceptance: string;
11
+ fields: string;
12
+ labels: string;
13
+ found_in_attempt: string | null;
14
+ filed_by: string;
15
+ filed_at: number;
16
+ closed_at: number | null;
17
+ close_reason: string | null;
18
+ kept_session: string | null;
19
+ };
20
+ relations: {
21
+ source: string;
22
+ target: string;
23
+ type: string;
24
+ added_by: string;
25
+ added_at: number;
26
+ removed_at: number | null;
27
+ };
28
+ levers: {
29
+ seq: number;
30
+ kind: string;
31
+ item: string;
32
+ until: number | null;
33
+ expires_at: number | null;
34
+ reason: string | null;
35
+ phase: string | null;
36
+ actor: string;
37
+ at: number;
38
+ };
39
+ attempts: {
40
+ id: string;
41
+ item: string;
42
+ phase: string;
43
+ number: number;
44
+ runner: string;
45
+ recipe_hash: string;
46
+ rules_version: string;
47
+ pool: string;
48
+ lease_gen: number;
49
+ spawn_request: string;
50
+ spawn_request_hash: string;
51
+ state: string;
52
+ failure: string | null;
53
+ atc_session: string | null;
54
+ caller: string | null;
55
+ claimed_at: number;
56
+ started_at: number | null;
57
+ ended_at: number | null;
58
+ fields: string;
59
+ mode: string;
60
+ round: number;
61
+ input: string | null;
62
+ hold_gen: number;
63
+ close_out: string | null;
64
+ close_out_hash: string | null;
65
+ close_out_fact: string | null;
66
+ closing: string | null;
67
+ closing_at: number | null;
68
+ suspend_requested_at: number | null;
69
+ slept_at: number | null;
70
+ wake_requested_at: number | null;
71
+ woken_at: number | null;
72
+ outcome: string | null;
73
+ returned_outcome: string | null;
74
+ strayed_at: number | null;
75
+ session_stopped_at: number | null;
76
+ session_forgotten_at: number | null;
77
+ };
78
+ attempt_sessions: {
79
+ attempt: string;
80
+ agent_session: string;
81
+ atc_session: string;
82
+ product: string;
83
+ source: string;
84
+ seen_at: number;
85
+ };
86
+ leases: {
87
+ item: string;
88
+ phase: string;
89
+ attempt: string;
90
+ gen: number;
91
+ taken_at: number;
92
+ released_at: number | null;
93
+ };
94
+ limits: {
95
+ name: string;
96
+ max: number;
97
+ counts: string;
98
+ };
99
+ limit_seats: {
100
+ limit: string;
101
+ attempt: string;
102
+ hold_gen: number;
103
+ over_cap: number;
104
+ taken_at: number;
105
+ released_at: number | null;
106
+ };
107
+ decisions: {
108
+ id: string;
109
+ attempt: string;
110
+ item: string;
111
+ kind: string;
112
+ pool: string;
113
+ rules_version: string;
114
+ snapshot_hash: string;
115
+ store_version: number;
116
+ pepys_offsets: string;
117
+ faults: string;
118
+ limits: string;
119
+ at: number;
120
+ };
121
+ evidence: {
122
+ fact_id: string;
123
+ attempt: string;
124
+ kind: string;
125
+ ref: string;
126
+ observed: string | null;
127
+ source: string;
128
+ at: number;
129
+ };
130
+ pull_requests: {
131
+ repo: string;
132
+ number: number;
133
+ attempt: string;
134
+ github_id: number;
135
+ url: string;
136
+ state: string;
137
+ merged_at: number | null;
138
+ merge_commit: string | null;
139
+ checked_at: number;
140
+ };
141
+ runners: {
142
+ id: string;
143
+ heartbeat_at: number;
144
+ paused: number;
145
+ };
146
+ notes: {
147
+ item: string;
148
+ seq: number;
149
+ text: string;
150
+ by: string;
151
+ at: number;
152
+ };
153
+ notices: {
154
+ id: Generated<number>;
155
+ item: string;
156
+ reason: string;
157
+ at: number;
158
+ tries: number;
159
+ sent_at: number | null;
160
+ };
161
+ outbox: {
162
+ seq: Generated<number>;
163
+ fact_id: string;
164
+ type: string;
165
+ entity: string;
166
+ actor: string;
167
+ at: number;
168
+ payload: string;
169
+ sent_at: number | null;
170
+ parked_at: number | null;
171
+ parked_reason: string | null;
172
+ };
173
+ }
@@ -0,0 +1,32 @@
1
+ import { SQLiteError } from 'bun:sqlite';
2
+
3
+ const retryDelayMs = 10;
4
+
5
+ // bun:sqlite waits on a busy lock synchronously, which stalls every other store in the process for
6
+ // the whole wait, so the statement fails at once and this waits between tries without blocking.
7
+ export async function withBusyRetry(waitLimitMs: number, work: () => unknown): Promise<void> {
8
+ const startMs = performance.now();
9
+
10
+ for (;;) {
11
+ try {
12
+ await work();
13
+
14
+ return;
15
+ } catch (error) {
16
+ if (!isBusy(error)) {
17
+ throw error;
18
+ }
19
+
20
+ await Bun.sleep(retryDelayMs);
21
+
22
+ // checked after the sleep, so no try starts once the limit has passed
23
+ if (performance.now() - startMs >= waitLimitMs) {
24
+ throw error;
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ function isBusy(error: unknown): boolean {
31
+ return error instanceof SQLiteError && error.code?.startsWith('SQLITE_BUSY') === true;
32
+ }
@@ -0,0 +1,29 @@
1
+ import { sql } from 'kysely';
2
+ import type { Kysely } from 'kysely';
3
+ import type { StoreDatabase } from './types.ts';
4
+
5
+ export async function withTransaction<T>(
6
+ db: Kysely<StoreDatabase>,
7
+ begin: (db: Kysely<StoreDatabase>) => Promise<unknown>,
8
+ work: () => Promise<T>,
9
+ ): Promise<T> {
10
+ await begin(db);
11
+
12
+ try {
13
+ const result = await work();
14
+
15
+ await sql`COMMIT`.execute(db);
16
+
17
+ return result;
18
+ } catch (error) {
19
+ // SQLite ends the transaction by itself after some errors, such as a full disk, and ROLLBACK
20
+ // then fails; its error joins the first one, never replaces it
21
+ await sql`ROLLBACK`.execute(db).catch((rollbackError: unknown) => {
22
+ throw new AggregateError([error, rollbackError], 'the work failed, and so did its rollback', {
23
+ cause: error,
24
+ });
25
+ });
26
+
27
+ throw error;
28
+ }
29
+ }
@@ -0,0 +1,223 @@
1
+ import { buildAppliedChange, parseNewFact, pickSeatTake, toJSONValue } from '@boxxy/model';
2
+ import type { ApplyResult, Instant, RowWrite, SeatTake, StoreChange } from '@boxxy/model';
3
+ import { sql } from 'kysely';
4
+ import type { Kysely, RawBuilder } from 'kysely';
5
+ import invariant from 'tiny-invariant';
6
+ import { match } from 'ts-pattern';
7
+ import { toStoreColumns } from '../codecs/to-store-columns.ts';
8
+ import type { StoreValue } from '../codecs/to-store-columns.ts';
9
+ import { buildSQLiteReader } from '../reads/build-sqlite-reader.ts';
10
+ import type { StoreDatabase } from '../types.ts';
11
+
12
+ // A savepoint, not the transaction, so a refusal keeps the caller's earlier changes in the same
13
+ // exclusive call. The caller runs one change at a time, so one savepoint name is enough.
14
+ export async function applySQLiteChange(
15
+ db: Kysely<StoreDatabase>,
16
+ change: StoreChange,
17
+ ): Promise<ApplyResult> {
18
+ const facts = change.facts.map((fact) => parseNewFact(fact));
19
+
20
+ await sql`SAVEPOINT apply_change`.execute(db);
21
+
22
+ try {
23
+ const result = await writeSQLiteChange(db, { ...change, facts });
24
+
25
+ if (result.kind !== 'applied') {
26
+ await sql`ROLLBACK TO apply_change`.execute(db);
27
+ }
28
+
29
+ await sql`RELEASE apply_change`.execute(db);
30
+
31
+ return result;
32
+ } catch (error) {
33
+ await removeSavepoint(db, error);
34
+
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ async function writeSQLiteChange(
40
+ db: Kysely<StoreDatabase>,
41
+ change: StoreChange,
42
+ ): Promise<ApplyResult> {
43
+ if (!(await probeLiveUnder(db, change.under))) {
44
+ return { kind: 'stale-generation' };
45
+ }
46
+
47
+ const lastLever = await db
48
+ .selectFrom('levers')
49
+ .select((builder) => builder.fn.max('seq').as('seq'))
50
+ .executeTakeFirst();
51
+
52
+ const lastNotice = await db
53
+ .selectFrom('notices')
54
+ .select((builder) => builder.fn.max('id').as('id'))
55
+ .executeTakeFirst();
56
+
57
+ const applied = buildAppliedChange(change, {
58
+ lastLeverSeq: lastLever?.seq ?? 0,
59
+ lastNoticeId: lastNotice?.id ?? 0,
60
+ });
61
+
62
+ for (const write of applied.writes) {
63
+ const refusal = await writeSQLiteRow(db, write, change.at);
64
+
65
+ if (refusal !== null) {
66
+ return refusal;
67
+ }
68
+ }
69
+
70
+ for (const fact of applied.facts) {
71
+ await db
72
+ .insertInto('outbox')
73
+ .values({
74
+ fact_id: fact.id,
75
+ type: fact.type,
76
+ entity: fact.entity,
77
+ actor: fact.actor,
78
+ at: change.at,
79
+ payload: JSON.stringify(toJSONValue(fact.payload)),
80
+ sent_at: null,
81
+ parked_at: null,
82
+ parked_reason: null,
83
+ })
84
+ .execute();
85
+ }
86
+
87
+ return { kind: 'applied' };
88
+ }
89
+
90
+ async function probeLiveUnder(
91
+ db: Kysely<StoreDatabase>,
92
+ under: StoreChange['under'],
93
+ ): Promise<boolean> {
94
+ if (under === undefined || under === null) {
95
+ return true;
96
+ }
97
+
98
+ const lease = await db
99
+ .selectFrom('leases')
100
+ .innerJoin('attempts', 'attempts.id', 'leases.attempt')
101
+ .select('attempts.hold_gen')
102
+ .where('leases.item', '=', under.item)
103
+ .where('leases.phase', '=', under.phase)
104
+ .where('leases.gen', '=', under.gen)
105
+ .where('leases.released_at', 'is', null)
106
+ .executeTakeFirst();
107
+
108
+ return lease !== undefined && (under.holdGen === undefined || lease.hold_gen === under.holdGen);
109
+ }
110
+
111
+ function writeSQLiteRow(
112
+ db: Kysely<StoreDatabase>,
113
+ write: RowWrite,
114
+ at: Instant,
115
+ ): Promise<ApplyResult | null> {
116
+ return match(write)
117
+ .with({ op: 'take-seats' }, (seats) => writeSeatTake(db, seats.take, at))
118
+ .with({ op: 'release-seats' }, async (seats) => {
119
+ await db
120
+ .updateTable('limit_seats')
121
+ .set({ released_at: at })
122
+ .where('attempt', '=', seats.release.attempt)
123
+ .where('hold_gen', '=', seats.release.holdGen)
124
+ .where('released_at', 'is', null)
125
+ .execute();
126
+
127
+ return null;
128
+ })
129
+ .with({ op: 'insert', table: 'leases' }, async (insert) => {
130
+ const columns = toStoreColumns(insert.table, insert.row);
131
+
132
+ // ON CONFLICT names the partial unique index, so SQLite skips a second live lease, not throws
133
+ const result = await sql`${buildInsert(insert.table, columns)}
134
+ ON CONFLICT (item, phase) WHERE released_at IS NULL DO NOTHING`.execute(db);
135
+
136
+ return result.numAffectedRows === 0n ? ({ kind: 'lease-held' } as const) : null;
137
+ })
138
+ .with({ op: 'insert' }, async (insert) => {
139
+ await buildInsert(insert.table, toStoreColumns(insert.table, insert.row)).execute(db);
140
+
141
+ return null;
142
+ })
143
+ .with({ op: 'update' }, async (update) => {
144
+ const set = toStoreColumns(update.table, update.set);
145
+
146
+ const guards = [
147
+ ...toStoreColumns(update.table, update.key),
148
+ ...toStoreColumns(update.table, update.expect),
149
+ ];
150
+
151
+ invariant(set.length > 0, 'an update sets at least one field');
152
+
153
+ const result = await sql`UPDATE ${sql.table(update.table)}
154
+ SET ${sql.join(set.map(([column, value]) => sql`${sql.ref(column)} = ${value}`))}
155
+ WHERE ${buildGuards(guards)}`.execute(db);
156
+
157
+ return result.numAffectedRows === 0n
158
+ ? ({ kind: 'guard-mismatch', table: update.table, key: toJSONValue(update.key) } as const)
159
+ : null;
160
+ })
161
+ .exhaustive();
162
+ }
163
+
164
+ // The write lock is held for the whole transaction, so the counts read here stay true until the
165
+ // seats are inserted.
166
+ async function writeSeatTake(
167
+ db: Kysely<StoreDatabase>,
168
+ take: SeatTake,
169
+ at: Instant,
170
+ ): Promise<ApplyResult | null> {
171
+ const limits = await buildSQLiteReader(db).listLimits();
172
+
173
+ const answer = pickSeatTake(take, limits, at);
174
+
175
+ if (answer.kind !== 'take') {
176
+ return { kind: answer.kind, limit: answer.limit };
177
+ }
178
+
179
+ for (const seat of answer.seats) {
180
+ await db
181
+ .insertInto('limit_seats')
182
+ .values({
183
+ limit: seat.limit,
184
+ attempt: seat.attempt,
185
+ hold_gen: seat.holdGen,
186
+ over_cap: seat.overCap ? 1 : 0,
187
+ taken_at: seat.takenAt,
188
+ released_at: seat.releasedAt,
189
+ })
190
+ .execute();
191
+ }
192
+
193
+ return null;
194
+ }
195
+
196
+ function buildInsert(table: string, columns: [string, StoreValue][]): RawBuilder<unknown> {
197
+ return sql`INSERT INTO ${sql.table(table)}
198
+ (${sql.join(columns.map(([column]) => sql.ref(column)))})
199
+ VALUES (${sql.join(columns.map(([, value]) => value))})`;
200
+ }
201
+
202
+ // IS compares NULL as a value, so an expect of null matches a NULL column
203
+ function buildGuards(guards: [string, StoreValue][]): RawBuilder<unknown> {
204
+ invariant(guards.length > 0, 'an update names its key');
205
+
206
+ return sql.join(
207
+ guards.map(([column, value]) => sql`${sql.ref(column)} IS ${value}`),
208
+ sql` AND `,
209
+ );
210
+ }
211
+
212
+ // SQLite rolls the whole transaction back by itself after some errors, such as a full disk, and the
213
+ // savepoint is gone; the rollback's own error then joins the first one, never replaces it.
214
+ async function removeSavepoint(db: Kysely<StoreDatabase>, cause: unknown): Promise<void> {
215
+ try {
216
+ await sql`ROLLBACK TO apply_change`.execute(db);
217
+ await sql`RELEASE apply_change`.execute(db);
218
+ } catch (rollbackError) {
219
+ throw new AggregateError([cause, rollbackError], 'the change failed, and so did its rollback', {
220
+ cause: rollbackError,
221
+ });
222
+ }
223
+ }