@boxxy/store-sqlite 0.1.1 → 0.1.4
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.
- package/README.md +8 -6
- package/dist/codecs/parse-json-text.d.ts +1 -0
- package/dist/constants.d.ts +1 -0
- package/dist/migrations/rename-relations-migration.d.ts +2 -0
- package/dist/{run-store-migrations.d.ts → migrations/run-store-migrations.d.ts} +1 -1
- package/dist/migrations/v1-tables-migration.d.ts +2 -0
- package/dist/{build-sqlite-reader.d.ts → reads/build-sqlite-reader.d.ts} +1 -1
- package/dist/reads/is-latest-of-session.d.ts +3 -0
- package/dist/{read-item-views.d.ts → reads/read-item-views.d.ts} +3 -3
- package/dist/reads/read-limit-fills.d.ts +4 -0
- package/dist/reads/read-wakes.d.ts +4 -0
- package/dist/test-utils/create-claim-bench.d.ts +8 -0
- package/dist/types.d.ts +60 -10
- package/dist/with-busy-retry.d.ts +1 -0
- package/dist/with-transaction.d.ts +3 -0
- package/dist/{apply-sqlite-change.d.ts → writes/apply-sqlite-change.d.ts} +1 -1
- package/dist/{create-sqlite-tx.d.ts → writes/create-sqlite-tx.d.ts} +1 -1
- package/dist/writes/take-write-lock.d.ts +3 -0
- package/package.json +15 -5
- package/src/codecs/parse-json-text.ts +5 -0
- package/src/{table-codecs.ts → codecs/table-codecs.ts} +52 -15
- package/src/constants.ts +1 -0
- package/src/create-sqlite-claim-store.ts +73 -51
- package/src/migrations/rename-relations-migration.ts +17 -0
- package/src/migrations/run-store-migrations.ts +42 -0
- package/src/migrations/v1-tables-migration.ts +184 -0
- package/src/reads/build-sqlite-reader.ts +484 -0
- package/src/reads/is-latest-of-session.ts +21 -0
- package/src/{read-item-views.ts → reads/read-item-views.ts} +9 -9
- package/src/reads/read-limit-fills.ts +32 -0
- package/src/reads/read-wakes.ts +34 -0
- package/src/test-utils/create-claim-bench.ts +66 -0
- package/src/test-utils/create-test-claim-store.ts +2 -1
- package/src/types.ts +60 -10
- package/src/with-busy-retry.ts +32 -0
- package/src/with-transaction.ts +29 -0
- package/src/{apply-sqlite-change.ts → writes/apply-sqlite-change.ts} +77 -74
- package/src/writes/create-sqlite-tx.ts +229 -0
- package/src/writes/take-write-lock.ts +8 -0
- package/src/build-sqlite-reader.ts +0 -321
- package/src/create-sqlite-tx.ts +0 -134
- package/src/run-store-migrations.ts +0 -20
- /package/dist/{parse-store-row.d.ts → codecs/parse-store-row.d.ts} +0 -0
- /package/dist/{table-codecs.d.ts → codecs/table-codecs.d.ts} +0 -0
- /package/dist/{to-column-name.d.ts → codecs/to-column-name.d.ts} +0 -0
- /package/dist/{to-store-columns.d.ts → codecs/to-store-columns.d.ts} +0 -0
- /package/dist/{create-tables-migration.d.ts → migrations/create-tables-migration.d.ts} +0 -0
- /package/src/{parse-store-row.ts → codecs/parse-store-row.ts} +0 -0
- /package/src/{to-column-name.ts → codecs/to-column-name.ts} +0 -0
- /package/src/{to-store-columns.ts → codecs/to-store-columns.ts} +0 -0
- /package/src/{create-tables-migration.ts → migrations/create-tables-migration.ts} +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Expression, ExpressionBuilder, SqlBool } from 'kysely';
|
|
2
|
+
import type { StoreDatabase } from '../types.ts';
|
|
3
|
+
|
|
4
|
+
// no later attempt carries the same atc session; claim time, then id, orders the attempts
|
|
5
|
+
export function isLatestOfSession(
|
|
6
|
+
builder: ExpressionBuilder<StoreDatabase, 'attempts'>,
|
|
7
|
+
): Expression<SqlBool> {
|
|
8
|
+
const later = builder
|
|
9
|
+
.selectFrom('attempts as later')
|
|
10
|
+
.select('later.id')
|
|
11
|
+
.whereRef('later.atc_session', '=', 'attempts.atc_session')
|
|
12
|
+
.where((inner) => {
|
|
13
|
+
const claimedAt = inner.ref('attempts.claimed_at');
|
|
14
|
+
const sameTime = inner('later.claimed_at', '=', claimedAt);
|
|
15
|
+
const higherId = inner('later.id', '>', inner.ref('attempts.id'));
|
|
16
|
+
|
|
17
|
+
return inner.or([inner('later.claimed_at', '>', claimedAt), inner.and([sameTime, higherId])]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return builder.not(builder.exists(later));
|
|
21
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { buildItemViews } from '@boxxy/model';
|
|
2
|
-
import type { Instant,
|
|
2
|
+
import type { Instant, ItemRules, ItemWithLevers } from '@boxxy/model';
|
|
3
3
|
import type { Expression, ExpressionBuilder, Kysely, SqlBool } from 'kysely';
|
|
4
|
-
import { parseStoreRow } from '
|
|
5
|
-
import type { StoreDatabase } from '
|
|
4
|
+
import { parseStoreRow } from '../codecs/parse-store-row.ts';
|
|
5
|
+
import type { StoreDatabase } from '../types.ts';
|
|
6
6
|
|
|
7
7
|
export type ItemScope = (builder: ExpressionBuilder<StoreDatabase, 'items'>) => Expression<SqlBool>;
|
|
8
8
|
|
|
@@ -11,12 +11,12 @@ export async function readItemViews(
|
|
|
11
11
|
db: Kysely<StoreDatabase>,
|
|
12
12
|
scope: ItemScope,
|
|
13
13
|
now: Instant,
|
|
14
|
-
|
|
14
|
+
rules: ItemRules,
|
|
15
15
|
): Promise<ItemWithLevers[]> {
|
|
16
16
|
const scoped = db.selectFrom('items').select('id').where(scope);
|
|
17
17
|
|
|
18
18
|
const blockers = db
|
|
19
|
-
.selectFrom('
|
|
19
|
+
.selectFrom('relations')
|
|
20
20
|
.select('source')
|
|
21
21
|
.where('removed_at', 'is', null)
|
|
22
22
|
.where('target', 'in', scoped);
|
|
@@ -40,8 +40,8 @@ export async function readItemViews(
|
|
|
40
40
|
|
|
41
41
|
const inScope = new Set(scopedRows.map((row) => row.id));
|
|
42
42
|
|
|
43
|
-
const
|
|
44
|
-
.selectFrom('
|
|
43
|
+
const relations = await db
|
|
44
|
+
.selectFrom('relations')
|
|
45
45
|
.selectAll()
|
|
46
46
|
.where('removed_at', 'is', null)
|
|
47
47
|
.where('target', 'in', loaded)
|
|
@@ -78,14 +78,14 @@ export async function readItemViews(
|
|
|
78
78
|
const views = buildItemViews(
|
|
79
79
|
{
|
|
80
80
|
items: items.map((row) => parseStoreRow('items', row)),
|
|
81
|
-
|
|
81
|
+
relations: relations.map((row) => parseStoreRow('relations', row)),
|
|
82
82
|
levers: levers.map((row) => parseStoreRow('levers', row)),
|
|
83
83
|
attempts: attempts.map((row) => parseStoreRow('attempts', row)),
|
|
84
84
|
liveLeases: leases.map((row) => parseStoreRow('leases', row)),
|
|
85
85
|
pullRequests: pullRequests.map((row) => parseStoreRow('pull_requests', row)),
|
|
86
86
|
},
|
|
87
87
|
now,
|
|
88
|
-
|
|
88
|
+
rules,
|
|
89
89
|
);
|
|
90
90
|
|
|
91
91
|
return views.filter((view) => inScope.has(view.id));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { limitSchema } from '@boxxy/model';
|
|
2
|
+
import type { LimitFill } from '@boxxy/model';
|
|
3
|
+
import type { Kysely } from 'kysely';
|
|
4
|
+
import { parseJSONText } from '../codecs/parse-json-text.ts';
|
|
5
|
+
import type { StoreDatabase } from '../types.ts';
|
|
6
|
+
|
|
7
|
+
// the limits of the last sync, by name, each with its held seats counted
|
|
8
|
+
export async function readLimitFills(db: Kysely<StoreDatabase>): Promise<LimitFill[]> {
|
|
9
|
+
const rows = await db
|
|
10
|
+
.selectFrom('limits')
|
|
11
|
+
.selectAll()
|
|
12
|
+
.select((builder) =>
|
|
13
|
+
builder
|
|
14
|
+
.selectFrom('limit_seats')
|
|
15
|
+
.select(builder.fn.countAll<number>().as('held'))
|
|
16
|
+
.whereRef('limit_seats.limit', '=', 'limits.name')
|
|
17
|
+
.where('limit_seats.released_at', 'is', null)
|
|
18
|
+
.as('held'),
|
|
19
|
+
)
|
|
20
|
+
.orderBy('name')
|
|
21
|
+
.execute();
|
|
22
|
+
|
|
23
|
+
return rows.map((row) => {
|
|
24
|
+
const limit = limitSchema.parse({
|
|
25
|
+
name: row.name,
|
|
26
|
+
max: row.max,
|
|
27
|
+
counts: parseJSONText(row.counts),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return { name: limit.name, max: limit.max, counts: limit.counts, held: row.held ?? 0 };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Wake } from '@boxxy/model';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
import { parseStoreRow } from '../codecs/parse-store-row.ts';
|
|
4
|
+
import type { StoreDatabase } from '../types.ts';
|
|
5
|
+
|
|
6
|
+
// the sleeping attempts whose wake was asked for, oldest request first
|
|
7
|
+
export async function readWakes(db: Kysely<StoreDatabase>): Promise<Wake[]> {
|
|
8
|
+
const rows = await db
|
|
9
|
+
.selectFrom('attempts')
|
|
10
|
+
.selectAll()
|
|
11
|
+
.where('state', '=', 'sleeping')
|
|
12
|
+
.where('wake_requested_at', 'is not', null)
|
|
13
|
+
.orderBy('wake_requested_at')
|
|
14
|
+
.orderBy('claimed_at')
|
|
15
|
+
.orderBy('id')
|
|
16
|
+
.execute();
|
|
17
|
+
|
|
18
|
+
return rows.flatMap((row) => {
|
|
19
|
+
const attempt = parseStoreRow('attempts', row);
|
|
20
|
+
|
|
21
|
+
return attempt.wakeRequestedAt === null
|
|
22
|
+
? []
|
|
23
|
+
: [
|
|
24
|
+
{
|
|
25
|
+
attempt: attempt.id,
|
|
26
|
+
item: attempt.item,
|
|
27
|
+
phase: attempt.phase,
|
|
28
|
+
pool: attempt.pool,
|
|
29
|
+
fields: attempt.fields,
|
|
30
|
+
requestedAt: attempt.wakeRequestedAt,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -10,7 +10,8 @@ export async function createTestClaimStore(): Promise<SQLiteClaimStore> {
|
|
|
10
10
|
|
|
11
11
|
onTestFinished(() => cleanup.disposeAsync());
|
|
12
12
|
|
|
13
|
-
|
|
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}-`));
|
|
14
15
|
|
|
15
16
|
cleanup.defer(() => rm(folder, { recursive: true, force: true }));
|
|
16
17
|
|
package/src/types.ts
CHANGED
|
@@ -8,13 +8,16 @@ export interface StoreDatabase {
|
|
|
8
8
|
title: string;
|
|
9
9
|
outcome: string;
|
|
10
10
|
acceptance: string;
|
|
11
|
+
fields: string;
|
|
12
|
+
labels: string;
|
|
11
13
|
found_in_attempt: string | null;
|
|
12
14
|
filed_by: string;
|
|
13
15
|
filed_at: number;
|
|
14
16
|
closed_at: number | null;
|
|
15
17
|
close_reason: string | null;
|
|
18
|
+
kept_session: string | null;
|
|
16
19
|
};
|
|
17
|
-
|
|
20
|
+
relations: {
|
|
18
21
|
source: string;
|
|
19
22
|
target: string;
|
|
20
23
|
type: string;
|
|
@@ -29,6 +32,7 @@ export interface StoreDatabase {
|
|
|
29
32
|
until: number | null;
|
|
30
33
|
expires_at: number | null;
|
|
31
34
|
reason: string | null;
|
|
35
|
+
phase: string | null;
|
|
32
36
|
actor: string;
|
|
33
37
|
at: number;
|
|
34
38
|
};
|
|
@@ -41,23 +45,42 @@ export interface StoreDatabase {
|
|
|
41
45
|
recipe_hash: string;
|
|
42
46
|
rules_version: string;
|
|
43
47
|
pool: string;
|
|
44
|
-
slot: number;
|
|
45
|
-
slot_gen: number;
|
|
46
48
|
lease_gen: number;
|
|
47
49
|
spawn_request: string;
|
|
48
50
|
spawn_request_hash: string;
|
|
49
51
|
state: string;
|
|
50
52
|
failure: string | null;
|
|
51
53
|
atc_session: string | null;
|
|
52
|
-
|
|
54
|
+
caller: string | null;
|
|
53
55
|
claimed_at: number;
|
|
54
56
|
started_at: number | null;
|
|
55
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;
|
|
56
77
|
};
|
|
57
78
|
attempt_sessions: {
|
|
58
79
|
attempt: string;
|
|
59
80
|
agent_session: string;
|
|
60
81
|
atc_session: string;
|
|
82
|
+
product: string;
|
|
83
|
+
source: string;
|
|
61
84
|
seen_at: number;
|
|
62
85
|
};
|
|
63
86
|
leases: {
|
|
@@ -68,23 +91,31 @@ export interface StoreDatabase {
|
|
|
68
91
|
taken_at: number;
|
|
69
92
|
released_at: number | null;
|
|
70
93
|
};
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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;
|
|
77
106
|
};
|
|
78
107
|
decisions: {
|
|
79
108
|
id: string;
|
|
80
109
|
attempt: string;
|
|
81
110
|
item: string;
|
|
111
|
+
kind: string;
|
|
82
112
|
pool: string;
|
|
83
113
|
rules_version: string;
|
|
84
114
|
snapshot_hash: string;
|
|
85
115
|
store_version: number;
|
|
86
116
|
pepys_offsets: string;
|
|
87
117
|
faults: string;
|
|
118
|
+
limits: string;
|
|
88
119
|
at: number;
|
|
89
120
|
};
|
|
90
121
|
evidence: {
|
|
@@ -100,6 +131,8 @@ export interface StoreDatabase {
|
|
|
100
131
|
repo: string;
|
|
101
132
|
number: number;
|
|
102
133
|
attempt: string;
|
|
134
|
+
github_id: number;
|
|
135
|
+
url: string;
|
|
103
136
|
state: string;
|
|
104
137
|
merged_at: number | null;
|
|
105
138
|
merge_commit: string | null;
|
|
@@ -110,6 +143,21 @@ export interface StoreDatabase {
|
|
|
110
143
|
heartbeat_at: number;
|
|
111
144
|
paused: number;
|
|
112
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
|
+
};
|
|
113
161
|
outbox: {
|
|
114
162
|
seq: Generated<number>;
|
|
115
163
|
fact_id: string;
|
|
@@ -119,5 +167,7 @@ export interface StoreDatabase {
|
|
|
119
167
|
at: number;
|
|
120
168
|
payload: string;
|
|
121
169
|
sent_at: number | null;
|
|
170
|
+
parked_at: number | null;
|
|
171
|
+
parked_reason: string | null;
|
|
122
172
|
};
|
|
123
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
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { buildAppliedChange,
|
|
2
|
-
import type { ApplyResult, RowWrite,
|
|
1
|
+
import { buildAppliedChange, parseNewFact, pickSeatTake, toJSONValue } from '@boxxy/model';
|
|
2
|
+
import type { ApplyResult, Instant, RowWrite, SeatTake, StoreChange } from '@boxxy/model';
|
|
3
3
|
import { sql } from 'kysely';
|
|
4
4
|
import type { Kysely, RawBuilder } from 'kysely';
|
|
5
5
|
import invariant from 'tiny-invariant';
|
|
6
6
|
import { match } from 'ts-pattern';
|
|
7
|
-
import { toStoreColumns } from '
|
|
8
|
-
import type { StoreValue } from '
|
|
9
|
-
import
|
|
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';
|
|
10
11
|
|
|
11
12
|
// A savepoint, not the transaction, so a refusal keeps the caller's earlier changes in the same
|
|
12
13
|
// exclusive call. The caller runs one change at a time, so one savepoint name is enough.
|
|
@@ -14,12 +15,12 @@ export async function applySQLiteChange(
|
|
|
14
15
|
db: Kysely<StoreDatabase>,
|
|
15
16
|
change: StoreChange,
|
|
16
17
|
): Promise<ApplyResult> {
|
|
17
|
-
|
|
18
|
+
const facts = change.facts.map((fact) => parseNewFact(fact));
|
|
18
19
|
|
|
19
20
|
await sql`SAVEPOINT apply_change`.execute(db);
|
|
20
21
|
|
|
21
22
|
try {
|
|
22
|
-
const result = await writeSQLiteChange(db, change);
|
|
23
|
+
const result = await writeSQLiteChange(db, { ...change, facts });
|
|
23
24
|
|
|
24
25
|
if (result.kind !== 'applied') {
|
|
25
26
|
await sql`ROLLBACK TO apply_change`.execute(db);
|
|
@@ -43,22 +44,23 @@ async function writeSQLiteChange(
|
|
|
43
44
|
return { kind: 'stale-generation' };
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
const take = findSlotTake(change.writes);
|
|
47
|
-
const taken = take === null ? null : await writeSlotTake(db, take);
|
|
48
|
-
|
|
49
|
-
if (take !== null && taken === null) {
|
|
50
|
-
return { kind: 'no-free-slot' };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
47
|
const lastLever = await db
|
|
54
48
|
.selectFrom('levers')
|
|
55
49
|
.select((builder) => builder.fn.max('seq').as('seq'))
|
|
56
50
|
.executeTakeFirst();
|
|
57
51
|
|
|
58
|
-
const
|
|
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
|
+
});
|
|
59
61
|
|
|
60
62
|
for (const write of applied.writes) {
|
|
61
|
-
const refusal = await writeSQLiteRow(db, write);
|
|
63
|
+
const refusal = await writeSQLiteRow(db, write, change.at);
|
|
62
64
|
|
|
63
65
|
if (refusal !== null) {
|
|
64
66
|
return refusal;
|
|
@@ -76,84 +78,53 @@ async function writeSQLiteChange(
|
|
|
76
78
|
at: change.at,
|
|
77
79
|
payload: JSON.stringify(toJSONValue(fact.payload)),
|
|
78
80
|
sent_at: null,
|
|
81
|
+
parked_at: null,
|
|
82
|
+
parked_reason: null,
|
|
79
83
|
})
|
|
80
84
|
.execute();
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return { kind: 'applied', slot };
|
|
87
|
+
return { kind: 'applied' };
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
async function probeLiveUnder(
|
|
89
91
|
db: Kysely<StoreDatabase>,
|
|
90
92
|
under: StoreChange['under'],
|
|
91
93
|
): Promise<boolean> {
|
|
92
|
-
if (under === undefined) {
|
|
94
|
+
if (under === undefined || under === null) {
|
|
93
95
|
return true;
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
const lease = await db
|
|
97
99
|
.selectFrom('leases')
|
|
98
|
-
.
|
|
99
|
-
.
|
|
100
|
-
.where('
|
|
101
|
-
.where('
|
|
102
|
-
.where('
|
|
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)
|
|
103
106
|
.executeTakeFirst();
|
|
104
107
|
|
|
105
|
-
|
|
106
|
-
return lease !== undefined;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const slot = await db
|
|
110
|
-
.selectFrom('pool_slots')
|
|
111
|
-
.select('gen')
|
|
112
|
-
.where('pool', '=', under.slot.pool)
|
|
113
|
-
.where('slot', '=', under.slot.slot)
|
|
114
|
-
.where('gen', '=', under.slot.gen)
|
|
115
|
-
.where('holder', 'is not', null)
|
|
116
|
-
.executeTakeFirst();
|
|
117
|
-
|
|
118
|
-
return slot !== undefined;
|
|
108
|
+
return lease !== undefined && (under.holdGen === undefined || lease.hold_gen === under.holdGen);
|
|
119
109
|
}
|
|
120
110
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
.where('holder', 'is', null)
|
|
127
|
-
.where('retired', '=', 0);
|
|
128
|
-
|
|
129
|
-
const row = await db
|
|
130
|
-
.updateTable('pool_slots')
|
|
131
|
-
.set((builder) => ({ holder: take.holder, gen: builder('gen', '+', 1) }))
|
|
132
|
-
.where('pool', '=', take.pool)
|
|
133
|
-
.where('slot', '=', lowest)
|
|
134
|
-
.where('holder', 'is', null)
|
|
135
|
-
.where('retired', '=', 0)
|
|
136
|
-
.returning(['slot', 'gen'])
|
|
137
|
-
.executeTakeFirst();
|
|
138
|
-
|
|
139
|
-
return row === undefined
|
|
140
|
-
? null
|
|
141
|
-
: { holder: take.holder, pool: take.pool, slot: row.slot, gen: row.gen };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function writeSQLiteRow(db: Kysely<StoreDatabase>, write: RowWrite): Promise<ApplyResult | null> {
|
|
111
|
+
function writeSQLiteRow(
|
|
112
|
+
db: Kysely<StoreDatabase>,
|
|
113
|
+
write: RowWrite,
|
|
114
|
+
at: Instant,
|
|
115
|
+
): Promise<ApplyResult | null> {
|
|
145
116
|
return match(write)
|
|
146
|
-
.with({ op: 'take-
|
|
147
|
-
.with({ op: 'release-
|
|
148
|
-
|
|
149
|
-
.updateTable('
|
|
150
|
-
.set({
|
|
151
|
-
.where('
|
|
152
|
-
.where('
|
|
153
|
-
.where('
|
|
154
|
-
.
|
|
155
|
-
|
|
156
|
-
return
|
|
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;
|
|
157
128
|
})
|
|
158
129
|
.with({ op: 'insert', table: 'leases' }, async (insert) => {
|
|
159
130
|
const columns = toStoreColumns(insert.table, insert.row);
|
|
@@ -190,6 +161,38 @@ function writeSQLiteRow(db: Kysely<StoreDatabase>, write: RowWrite): Promise<App
|
|
|
190
161
|
.exhaustive();
|
|
191
162
|
}
|
|
192
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
|
+
|
|
193
196
|
function buildInsert(table: string, columns: [string, StoreValue][]): RawBuilder<unknown> {
|
|
194
197
|
return sql`INSERT INTO ${sql.table(table)}
|
|
195
198
|
(${sql.join(columns.map(([column]) => sql.ref(column)))})
|