@boxxy/dispatch 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.
- package/README.md +4 -1
- package/dist/attempts/attempt-machine.d.ts +804 -0
- package/dist/attempts/attempt-moves.d.ts +2 -0
- package/dist/attempts/bind-attempt-session.d.ts +14 -0
- package/dist/attempts/build-attempt-change.d.ts +73 -0
- package/dist/attempts/build-attempt-fact.d.ts +3 -0
- package/dist/attempts/end-item-attempts.d.ts +18 -0
- package/dist/attempts/is-same-guard.d.ts +5 -0
- package/dist/attempts/pick-call-attempt.d.ts +10 -0
- package/dist/attempts/stop-stray-session.d.ts +11 -0
- package/dist/attempts/types.d.ts +142 -0
- package/dist/attempts/write-attempt-move.d.ts +7 -0
- package/dist/attempts/write-session-bind.d.ts +3 -0
- package/dist/claims/build-snapshot-hash.d.ts +2 -0
- package/dist/claims/claim-next.d.ts +6 -0
- package/dist/claims/pick-kept-session.d.ts +9 -0
- package/dist/claims/request-wake.d.ts +3 -0
- package/dist/claims/resume-claim.d.ts +3 -0
- package/dist/claims/start-claim.d.ts +3 -0
- package/dist/claims/types.d.ts +44 -0
- package/dist/closeouts/build-settle-plans.d.ts +17 -0
- package/dist/closeouts/close-out-attempt.d.ts +3 -0
- package/dist/closeouts/effect-handlers.d.ts +2 -0
- package/dist/closeouts/pick-settlement.d.ts +14 -0
- package/dist/closeouts/settle-attempt.d.ts +3 -0
- package/dist/closeouts/types.d.ts +50 -0
- package/dist/index.d.ts +38 -0
- package/dist/notices/constants.d.ts +4 -0
- package/dist/notices/create-null-notifier.d.ts +2 -0
- package/dist/notices/send-notices.d.ts +2 -0
- package/dist/notices/types.d.ts +11 -0
- package/dist/outbox/build-backoff-ms.d.ts +2 -0
- package/dist/outbox/build-initial-sender-state.d.ts +2 -0
- package/dist/outbox/build-outbox-status.d.ts +15 -0
- package/dist/outbox/constants.d.ts +2 -0
- package/dist/outbox/measure-wire-bytes.d.ts +2 -0
- package/dist/outbox/pick-sender-move.d.ts +10 -0
- package/dist/outbox/run-sender-once.d.ts +2 -0
- package/dist/outbox/take-batch.d.ts +3 -0
- package/dist/outbox/types.d.ts +61 -0
- package/dist/pull-requests/build-link-change.d.ts +11 -0
- package/dist/pull-requests/build-state-fact.d.ts +8 -0
- package/dist/pull-requests/collect-attempt-trailers.d.ts +2 -0
- package/dist/pull-requests/collect-seen-state.d.ts +7 -0
- package/dist/pull-requests/constants.d.ts +3 -0
- package/dist/pull-requests/link-pull-request.d.ts +2 -0
- package/dist/pull-requests/parse-pull-request-url.d.ts +2 -0
- package/dist/pull-requests/types.d.ts +37 -0
- package/dist/pull-requests/watch-pull-requests.d.ts +2 -0
- package/dist/readings/act-on-reading.d.ts +3 -0
- package/dist/readings/find-waiting-since.d.ts +9 -0
- package/dist/readings/pick-reading-action.d.ts +2 -0
- package/dist/readings/types.d.ts +79 -0
- package/dist/sessions/build-keep-writes.d.ts +9 -0
- package/dist/sessions/build-kept-clear.d.ts +2 -0
- package/dist/sessions/forget-sessions.d.ts +2 -0
- package/dist/sessions/is-kept-due.d.ts +2 -0
- package/dist/sessions/resume-kept-session.d.ts +6 -0
- package/dist/sessions/types.d.ts +19 -0
- package/dist/test-utils/apply-memory-change.d.ts +3 -0
- package/dist/test-utils/build-memory-reader.d.ts +3 -0
- package/dist/test-utils/build-mock-bind-change.d.ts +7 -0
- package/dist/test-utils/build-mock-claim-change.d.ts +2 -0
- package/dist/test-utils/build-mock-file-item-change.d.ts +2 -0
- package/dist/test-utils/build-mock-lever-change.d.ts +2 -0
- package/dist/test-utils/build-mock-new-id.d.ts +2 -0
- package/dist/test-utils/build-mock-note-change.d.ts +2 -0
- package/dist/test-utils/build-mock-pause-change.d.ts +2 -0
- package/dist/test-utils/build-mock-pull-request-change.d.ts +8 -0
- package/dist/test-utils/build-mock-relation-change.d.ts +7 -0
- package/dist/test-utils/build-mock-running-reading.d.ts +6 -0
- package/dist/test-utils/build-mock-settle-change.d.ts +7 -0
- package/dist/test-utils/build-mock-start-change.d.ts +2 -0
- package/dist/test-utils/claim-store-attempt-reader-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-claim-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-close-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-contract.d.ts +2 -0
- package/dist/test-utils/claim-store-copy-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-exclusive-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-guard-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-item-reader-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-outbox-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-race-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-rollback-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-seat-cases.d.ts +2 -0
- package/dist/test-utils/claim-store-session-cases.d.ts +2 -0
- package/dist/test-utils/collect-memory-limit-fills.d.ts +3 -0
- package/dist/test-utils/compare-text.d.ts +1 -0
- package/dist/test-utils/create-fake-backoff.d.ts +2 -0
- package/dist/test-utils/create-fake-clock.d.ts +5 -0
- package/dist/test-utils/create-fake-fact-sink.d.ts +8 -0
- package/dist/test-utils/create-fake-forge.d.ts +17 -0
- package/dist/test-utils/create-fake-worker.d.ts +42 -0
- package/dist/test-utils/create-memory-claim-store.d.ts +2 -0
- package/dist/test-utils/create-test-dispatch.d.ts +46 -0
- package/dist/test-utils/forge-contract.d.ts +15 -0
- package/dist/test-utils/is-same-held-seat.d.ts +2 -0
- package/dist/test-utils/types.d.ts +19 -0
- package/dist/test-utils/worker-contract.d.ts +14 -0
- package/dist/timeouts.d.ts +2 -0
- package/dist/types.d.ts +13 -0
- package/package.json +15 -2
- package/src/attempts/attempt-machine.ts +147 -0
- package/src/attempts/attempt-moves.ts +218 -0
- package/src/attempts/bind-attempt-session.ts +61 -0
- package/src/attempts/build-attempt-change.ts +540 -0
- package/src/attempts/build-attempt-fact.ts +135 -0
- package/src/attempts/end-item-attempts.ts +95 -0
- package/src/attempts/is-same-guard.ts +22 -0
- package/src/attempts/pick-call-attempt.ts +32 -0
- package/src/attempts/stop-stray-session.ts +48 -0
- package/src/attempts/types.ts +231 -0
- package/src/attempts/write-attempt-move.ts +41 -0
- package/src/attempts/write-session-bind.ts +47 -0
- package/src/claims/build-snapshot-hash.ts +9 -0
- package/src/claims/claim-next.ts +306 -0
- package/src/claims/pick-kept-session.ts +41 -0
- package/src/claims/request-wake.ts +17 -0
- package/src/claims/resume-claim.ts +56 -0
- package/src/claims/start-claim.ts +99 -0
- package/src/claims/types.ts +67 -0
- package/src/closeouts/build-settle-plans.ts +173 -0
- package/src/closeouts/close-out-attempt.ts +156 -0
- package/src/closeouts/effect-handlers.ts +232 -0
- package/src/closeouts/pick-settlement.ts +80 -0
- package/src/closeouts/settle-attempt.ts +161 -0
- package/src/closeouts/types.ts +87 -0
- package/src/index.ts +61 -0
- package/src/notices/constants.ts +7 -0
- package/src/notices/create-null-notifier.ts +6 -0
- package/src/notices/send-notices.ts +41 -0
- package/src/notices/types.ts +14 -0
- package/src/outbox/build-backoff-ms.ts +14 -0
- package/src/outbox/build-initial-sender-state.ts +14 -0
- package/src/outbox/build-outbox-status.ts +43 -0
- package/src/outbox/constants.ts +15 -0
- package/src/outbox/measure-wire-bytes.ts +31 -0
- package/src/outbox/pick-sender-move.ts +37 -0
- package/src/outbox/run-sender-once.ts +174 -0
- package/src/outbox/take-batch.ts +35 -0
- package/src/outbox/types.ts +76 -0
- package/src/pull-requests/build-link-change.ts +95 -0
- package/src/pull-requests/build-state-fact.ts +39 -0
- package/src/pull-requests/collect-attempt-trailers.ts +20 -0
- package/src/pull-requests/collect-seen-state.ts +19 -0
- package/src/pull-requests/constants.ts +5 -0
- package/src/pull-requests/link-pull-request.ts +119 -0
- package/src/pull-requests/parse-pull-request-url.ts +30 -0
- package/src/pull-requests/types.ts +63 -0
- package/src/pull-requests/watch-pull-requests.ts +308 -0
- package/src/readings/act-on-reading.ts +318 -0
- package/src/readings/find-waiting-since.ts +46 -0
- package/src/readings/pick-reading-action.ts +315 -0
- package/src/readings/types.ts +98 -0
- package/src/sessions/build-keep-writes.ts +55 -0
- package/src/sessions/build-kept-clear.ts +12 -0
- package/src/sessions/forget-sessions.ts +106 -0
- package/src/sessions/is-kept-due.ts +27 -0
- package/src/sessions/resume-kept-session.ts +25 -0
- package/src/sessions/types.ts +39 -0
- package/src/test-utils/apply-memory-change.ts +252 -0
- package/src/test-utils/build-memory-reader.ts +364 -0
- package/src/test-utils/build-mock-bind-change.ts +43 -0
- package/src/test-utils/build-mock-claim-change.ts +52 -0
- package/src/test-utils/build-mock-file-item-change.ts +20 -0
- package/src/test-utils/build-mock-lever-change.ts +18 -0
- package/src/test-utils/build-mock-new-id.ts +12 -0
- package/src/test-utils/build-mock-note-change.ts +18 -0
- package/src/test-utils/build-mock-pause-change.ts +19 -0
- package/src/test-utils/build-mock-pull-request-change.ts +63 -0
- package/src/test-utils/build-mock-relation-change.ts +40 -0
- package/src/test-utils/build-mock-running-reading.ts +19 -0
- package/src/test-utils/build-mock-settle-change.ts +70 -0
- package/src/test-utils/build-mock-start-change.ts +38 -0
- package/src/test-utils/claim-store-attempt-reader-cases.ts +357 -0
- package/src/test-utils/claim-store-claim-cases.ts +314 -0
- package/src/test-utils/claim-store-close-cases.ts +95 -0
- package/src/test-utils/claim-store-contract.ts +28 -0
- package/src/test-utils/claim-store-copy-cases.ts +100 -0
- package/src/test-utils/claim-store-exclusive-cases.ts +243 -0
- package/src/test-utils/claim-store-guard-cases.ts +383 -0
- package/src/test-utils/claim-store-item-reader-cases.ts +471 -0
- package/src/test-utils/claim-store-outbox-cases.ts +281 -0
- package/src/test-utils/claim-store-race-cases.ts +150 -0
- package/src/test-utils/claim-store-rollback-cases.ts +175 -0
- package/src/test-utils/claim-store-seat-cases.ts +270 -0
- package/src/test-utils/claim-store-session-cases.ts +289 -0
- package/src/test-utils/collect-memory-limit-fills.ts +15 -0
- package/src/test-utils/compare-text.ts +8 -0
- package/src/test-utils/create-fake-backoff.ts +16 -0
- package/src/test-utils/create-fake-clock.ts +20 -0
- package/src/test-utils/create-fake-fact-sink.ts +74 -0
- package/src/test-utils/create-fake-forge.ts +117 -0
- package/src/test-utils/create-fake-worker.ts +303 -0
- package/src/test-utils/create-memory-claim-store.ts +163 -0
- package/src/test-utils/create-test-dispatch.ts +597 -0
- package/src/test-utils/forge-contract.ts +171 -0
- package/src/test-utils/is-same-held-seat.ts +11 -0
- package/src/test-utils/types.ts +23 -0
- package/src/test-utils/worker-contract.ts +192 -0
- package/src/timeouts.ts +17 -0
- package/src/types.ts +32 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { brandSchemas } from '@boxxy/model';
|
|
2
|
+
import type { Instant, OutboxFact, SinkAnswer } from '@boxxy/model';
|
|
3
|
+
import { match } from 'ts-pattern';
|
|
4
|
+
import { buildBackoffMs } from './build-backoff-ms.ts';
|
|
5
|
+
import { pickSenderMove } from './pick-sender-move.ts';
|
|
6
|
+
import { takeBatch } from './take-batch.ts';
|
|
7
|
+
import type { SenderDeps, SenderMove, SenderState, SenderTurn } from './types.ts';
|
|
8
|
+
|
|
9
|
+
// One turn of the sender: sends one batch and does what the answer says. It takes the state that
|
|
10
|
+
// the last turn returned. The caller waits until `nextTryAt` after `retry` and `blocked`, and
|
|
11
|
+
// calls again at once after `stored`, `parked` and `split`.
|
|
12
|
+
export async function runSenderOnce(deps: SenderDeps, state: SenderState): Promise<SenderTurn> {
|
|
13
|
+
const queue = state.queue.length > 0 ? state.queue : await readQueue(deps);
|
|
14
|
+
|
|
15
|
+
if (queue === 'parked') {
|
|
16
|
+
return { step: 'parked', state: { ...state, phase: 'idle', nextTryAt: null } };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const [head, ...rest] = queue;
|
|
20
|
+
|
|
21
|
+
if (head === undefined) {
|
|
22
|
+
return { step: 'idle', state: { ...state, queue: [], phase: 'idle', nextTryAt: null } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const answer = await deps.sink.send(head);
|
|
26
|
+
|
|
27
|
+
const move = pickSenderMove({
|
|
28
|
+
answer,
|
|
29
|
+
batchSize: head.length,
|
|
30
|
+
parkRun: state.parkRun,
|
|
31
|
+
limits: deps.limits,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return runMove(deps, { state, batch: head, rest, answer, move, at: deps.clock.now() });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The next batch from the store. An oversize first row is parked and no batch is sent this turn.
|
|
38
|
+
async function readQueue(deps: SenderDeps): Promise<readonly (readonly OutboxFact[])[] | 'parked'> {
|
|
39
|
+
const rows = await deps.store.read((tx) => tx.readUnsentFacts(deps.limits.batchFacts));
|
|
40
|
+
|
|
41
|
+
const taken = takeBatch(rows, deps.limits);
|
|
42
|
+
|
|
43
|
+
if (taken.kind === 'batch') {
|
|
44
|
+
return taken.facts.length === 0 ? [] : [taken.facts];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const at = deps.clock.now();
|
|
48
|
+
|
|
49
|
+
await deps.store.exclusive((tx) => tx.parkFacts([taken.fact.factId], 'too-large', at));
|
|
50
|
+
|
|
51
|
+
return 'parked';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface MoveInput {
|
|
55
|
+
readonly state: SenderState;
|
|
56
|
+
readonly batch: readonly OutboxFact[];
|
|
57
|
+
readonly rest: readonly (readonly OutboxFact[])[];
|
|
58
|
+
readonly answer: SinkAnswer;
|
|
59
|
+
readonly move: SenderMove;
|
|
60
|
+
readonly at: Instant;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function runMove(deps: SenderDeps, input: MoveInput): Promise<SenderTurn> {
|
|
64
|
+
const state = input.state;
|
|
65
|
+
const failingSince = state.failingSince ?? input.at;
|
|
66
|
+
const lastError = buildLastError(input.answer, input.move, input.at);
|
|
67
|
+
|
|
68
|
+
return match(input.move)
|
|
69
|
+
.with({ kind: 'mark-sent' }, async (): Promise<SenderTurn> => {
|
|
70
|
+
const ids = input.batch.map((fact) => fact.factId);
|
|
71
|
+
|
|
72
|
+
await deps.store.exclusive((tx) => tx.markFactsSent(ids, input.at));
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
step: 'stored',
|
|
76
|
+
state: {
|
|
77
|
+
...state,
|
|
78
|
+
queue: input.rest,
|
|
79
|
+
retries: 0,
|
|
80
|
+
parkRun: 0,
|
|
81
|
+
phase: 'idle',
|
|
82
|
+
failingSince: null,
|
|
83
|
+
nextTryAt: null,
|
|
84
|
+
lastStoredAt: input.at,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
})
|
|
88
|
+
.with({ kind: 'retry-same' }, (): Promise<SenderTurn> => {
|
|
89
|
+
const retries = state.retries + 1;
|
|
90
|
+
const waitMs = buildBackoffMs(retries, deps.limits, deps.random());
|
|
91
|
+
|
|
92
|
+
return Promise.resolve({
|
|
93
|
+
step: 'retry',
|
|
94
|
+
state: {
|
|
95
|
+
...state,
|
|
96
|
+
queue: [input.batch, ...input.rest],
|
|
97
|
+
retries,
|
|
98
|
+
phase: 'backing-off',
|
|
99
|
+
failingSince,
|
|
100
|
+
nextTryAt: brandSchemas.instant.parse(input.at + waitMs),
|
|
101
|
+
lastError,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
})
|
|
105
|
+
.with({ kind: 'split' }, (): Promise<SenderTurn> => {
|
|
106
|
+
const middle = Math.ceil(input.batch.length / 2);
|
|
107
|
+
|
|
108
|
+
return Promise.resolve({
|
|
109
|
+
step: 'split',
|
|
110
|
+
state: {
|
|
111
|
+
...state,
|
|
112
|
+
queue: [input.batch.slice(0, middle), input.batch.slice(middle), ...input.rest],
|
|
113
|
+
phase: 'idle',
|
|
114
|
+
failingSince,
|
|
115
|
+
nextTryAt: null,
|
|
116
|
+
lastError,
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
})
|
|
120
|
+
.with({ kind: 'park' }, async (parked): Promise<SenderTurn> => {
|
|
121
|
+
const ids = input.batch.map((fact) => fact.factId);
|
|
122
|
+
|
|
123
|
+
await deps.store.exclusive((tx) => tx.parkFacts(ids, parked.reason, input.at));
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
step: 'parked',
|
|
127
|
+
state: {
|
|
128
|
+
...state,
|
|
129
|
+
queue: input.rest,
|
|
130
|
+
parkRun: state.parkRun + 1,
|
|
131
|
+
phase: 'idle',
|
|
132
|
+
failingSince,
|
|
133
|
+
nextTryAt: null,
|
|
134
|
+
lastError,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
})
|
|
138
|
+
.with({ kind: 'block' }, (): Promise<SenderTurn> =>
|
|
139
|
+
Promise.resolve({
|
|
140
|
+
step: 'blocked',
|
|
141
|
+
state: {
|
|
142
|
+
...state,
|
|
143
|
+
queue: [input.batch, ...input.rest],
|
|
144
|
+
phase: 'blocked',
|
|
145
|
+
failingSince,
|
|
146
|
+
nextTryAt: brandSchemas.instant.parse(input.at + deps.limits.blockedRetryMs),
|
|
147
|
+
lastError,
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
151
|
+
.exhaustive();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildLastError(
|
|
155
|
+
answer: SinkAnswer,
|
|
156
|
+
move: SenderMove,
|
|
157
|
+
at: Instant,
|
|
158
|
+
): SenderState['lastError'] {
|
|
159
|
+
return match(answer)
|
|
160
|
+
.with({ kind: 'stored' }, () => null)
|
|
161
|
+
.with({ kind: 'retry' }, (retry) => ({
|
|
162
|
+
kind: retry.kind,
|
|
163
|
+
status: null,
|
|
164
|
+
message: retry.reason,
|
|
165
|
+
at,
|
|
166
|
+
}))
|
|
167
|
+
.with({ kind: 'refused-batch' }, { kind: 'refused-sink' }, (refused) => ({
|
|
168
|
+
kind: refused.kind,
|
|
169
|
+
status: refused.status,
|
|
170
|
+
message: move.kind === 'block' ? move.reason : refused.reason,
|
|
171
|
+
at,
|
|
172
|
+
}))
|
|
173
|
+
.exhaustive();
|
|
174
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { OutboxFact } from '@boxxy/model';
|
|
2
|
+
import { measureWireBytes } from './measure-wire-bytes.ts';
|
|
3
|
+
import type { OutboxLimits, TakenBatch } from './types.ts';
|
|
4
|
+
|
|
5
|
+
// The longest prefix of the rows that holds `batchFacts` facts or fewer and stays inside
|
|
6
|
+
// `batchBytes`. A first row over `factBytes` comes back alone as `oversize`. The batch is empty
|
|
7
|
+
// only when no row is given, because one fact and its allowance fit `batchBytes`.
|
|
8
|
+
export function takeBatch(rows: readonly OutboxFact[], limits: OutboxLimits): TakenBatch {
|
|
9
|
+
const taken: OutboxFact[] = [];
|
|
10
|
+
let batchBytes = 0;
|
|
11
|
+
|
|
12
|
+
for (const row of rows) {
|
|
13
|
+
const wireBytes = measureWireBytes(row);
|
|
14
|
+
|
|
15
|
+
if (wireBytes > limits.factBytes) {
|
|
16
|
+
if (taken.length === 0) {
|
|
17
|
+
return { kind: 'oversize', fact: row };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const rowBytes = wireBytes + limits.envelopeBytes + 8;
|
|
24
|
+
|
|
25
|
+
if (taken.length >= limits.batchFacts || batchBytes + rowBytes > limits.batchBytes) {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
taken.push(row);
|
|
30
|
+
|
|
31
|
+
batchBytes += rowBytes;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { kind: 'batch', facts: taken };
|
|
35
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ClaimStore, Clock, FactSink, Instant, OutboxFact, OutboxStatus } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
export interface OutboxLimits {
|
|
4
|
+
// ingest stores 100 facts of one batch at most
|
|
5
|
+
readonly batchFacts: 100;
|
|
6
|
+
|
|
7
|
+
// one fact on the wire: the UTF-8 bytes of its JSON
|
|
8
|
+
readonly factBytes: 262_144;
|
|
9
|
+
|
|
10
|
+
// the sum over a batch of (wire bytes + `envelopeBytes` + 8)
|
|
11
|
+
readonly batchBytes: 900_000;
|
|
12
|
+
|
|
13
|
+
// what the stored envelope adds to a wire item
|
|
14
|
+
readonly envelopeBytes: 512;
|
|
15
|
+
readonly requestTimeoutMs: 10_000;
|
|
16
|
+
readonly backoff: {
|
|
17
|
+
readonly firstMs: 1000;
|
|
18
|
+
readonly factor: 2;
|
|
19
|
+
readonly maxMs: 300_000;
|
|
20
|
+
readonly jitter: 0.2;
|
|
21
|
+
};
|
|
22
|
+
readonly blockedRetryMs: 300_000;
|
|
23
|
+
|
|
24
|
+
// this many rows parked in a row, with none stored between, reads as "pepys refuses everything"
|
|
25
|
+
readonly parkRun: 25;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TakenBatch =
|
|
29
|
+
| { readonly kind: 'batch'; readonly facts: readonly OutboxFact[] }
|
|
30
|
+
| { readonly kind: 'oversize'; readonly fact: OutboxFact };
|
|
31
|
+
|
|
32
|
+
export type SenderMove =
|
|
33
|
+
| { readonly kind: 'mark-sent' }
|
|
34
|
+
| { readonly kind: 'retry-same' }
|
|
35
|
+
| { readonly kind: 'split' }
|
|
36
|
+
| { readonly kind: 'park'; readonly reason: string }
|
|
37
|
+
| { readonly kind: 'block'; readonly reason: string };
|
|
38
|
+
|
|
39
|
+
export type SenderStep = 'idle' | 'stored' | 'retry' | 'split' | 'parked' | 'blocked';
|
|
40
|
+
|
|
41
|
+
type SenderPhase = 'idle' | 'backing-off' | 'blocked';
|
|
42
|
+
|
|
43
|
+
type LastError = NonNullable<OutboxStatus['lastError']>;
|
|
44
|
+
|
|
45
|
+
// What the sender remembers between turns. A restart forgets all of it.
|
|
46
|
+
export interface SenderState {
|
|
47
|
+
// batches to send before the next read of the store, in order: the one that got `retry`, or the
|
|
48
|
+
// halves of a split
|
|
49
|
+
readonly queue: readonly (readonly OutboxFact[])[];
|
|
50
|
+
|
|
51
|
+
// consecutive `retry` answers
|
|
52
|
+
readonly retries: number;
|
|
53
|
+
|
|
54
|
+
// rows parked since the last `stored`
|
|
55
|
+
readonly parkRun: number;
|
|
56
|
+
readonly phase: SenderPhase;
|
|
57
|
+
readonly failingSince: Instant | null;
|
|
58
|
+
readonly nextTryAt: Instant | null;
|
|
59
|
+
readonly lastError: LastError | null;
|
|
60
|
+
readonly lastStoredAt: Instant | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface SenderDeps {
|
|
64
|
+
readonly store: ClaimStore;
|
|
65
|
+
readonly sink: FactSink;
|
|
66
|
+
readonly clock: Clock;
|
|
67
|
+
readonly limits: OutboxLimits;
|
|
68
|
+
|
|
69
|
+
// a number in [0, 1), for the jitter of the backoff
|
|
70
|
+
readonly random: () => number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SenderTurn {
|
|
74
|
+
readonly step: SenderStep;
|
|
75
|
+
readonly state: SenderState;
|
|
76
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActorRef,
|
|
3
|
+
AnyNewFact,
|
|
4
|
+
AttemptRow,
|
|
5
|
+
Instant,
|
|
6
|
+
NewId,
|
|
7
|
+
PullRequestReading,
|
|
8
|
+
PullRequestRef,
|
|
9
|
+
PullRequestRow,
|
|
10
|
+
StoreChange,
|
|
11
|
+
} from '@boxxy/model';
|
|
12
|
+
import { brandSchemas } from '@boxxy/model';
|
|
13
|
+
import { buildStateFact } from './build-state-fact.ts';
|
|
14
|
+
import { collectSeenState } from './collect-seen-state.ts';
|
|
15
|
+
|
|
16
|
+
export interface LinkChangeInput {
|
|
17
|
+
readonly attempt: AttemptRow;
|
|
18
|
+
readonly ref: PullRequestRef;
|
|
19
|
+
readonly reading: PullRequestReading;
|
|
20
|
+
readonly source: 'hook' | 'watch';
|
|
21
|
+
readonly actor: ActorRef;
|
|
22
|
+
readonly now: Instant;
|
|
23
|
+
readonly newId: NewId;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The change that links a pull request to an attempt: the pull request row, the evidence row
|
|
27
|
+
// and `boxxy.pull_request.linked` in one change. A pull request the forge already shows merged
|
|
28
|
+
// or closed also gets its merge or close fact, because the watch reads only open rows.
|
|
29
|
+
export function buildLinkChange(input: LinkChangeInput): StoreChange {
|
|
30
|
+
const attempt = input.attempt;
|
|
31
|
+
const ref = input.ref;
|
|
32
|
+
const reading = input.reading;
|
|
33
|
+
const now = input.now;
|
|
34
|
+
const seen = collectSeenState(reading);
|
|
35
|
+
const linkedFactId = brandSchemas.factId.parse(input.newId());
|
|
36
|
+
|
|
37
|
+
const row: PullRequestRow = {
|
|
38
|
+
repo: ref.repo,
|
|
39
|
+
number: ref.number,
|
|
40
|
+
attempt: attempt.id,
|
|
41
|
+
githubId: reading.githubId,
|
|
42
|
+
url: reading.url,
|
|
43
|
+
state: seen.state,
|
|
44
|
+
mergedAt: seen.mergedAt,
|
|
45
|
+
mergeCommit: seen.mergeCommit,
|
|
46
|
+
checkedAt: now,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const entity = `boxxy:item:${attempt.item}` as const;
|
|
50
|
+
|
|
51
|
+
const linked: AnyNewFact = {
|
|
52
|
+
id: linkedFactId,
|
|
53
|
+
type: 'boxxy.pull_request.linked',
|
|
54
|
+
entity,
|
|
55
|
+
actor: input.actor,
|
|
56
|
+
payload: {
|
|
57
|
+
attempt: attempt.id,
|
|
58
|
+
repo: ref.repo,
|
|
59
|
+
number: ref.number,
|
|
60
|
+
url: reading.url,
|
|
61
|
+
pullRequestId: String(reading.githubId),
|
|
62
|
+
source: input.source,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const stateFact = buildStateFact({
|
|
67
|
+
row,
|
|
68
|
+
item: attempt.item,
|
|
69
|
+
actor: input.actor,
|
|
70
|
+
id: brandSchemas.factId.parse(input.newId()),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const stateFacts = stateFact === null ? [] : [stateFact];
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
at: now,
|
|
77
|
+
writes: [
|
|
78
|
+
{ op: 'insert', table: 'pull_requests', row },
|
|
79
|
+
{
|
|
80
|
+
op: 'insert',
|
|
81
|
+
table: 'evidence',
|
|
82
|
+
row: {
|
|
83
|
+
factId: linkedFactId,
|
|
84
|
+
attempt: attempt.id,
|
|
85
|
+
kind: 'pull_request',
|
|
86
|
+
ref: reading.url,
|
|
87
|
+
observed: null,
|
|
88
|
+
source: input.source,
|
|
89
|
+
at: now,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
facts: [linked, ...stateFacts],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ActorRef, AnyNewFact, FactId, ItemId, PullRequestRow } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
export interface StateFactInput {
|
|
4
|
+
readonly row: PullRequestRow;
|
|
5
|
+
readonly item: ItemId;
|
|
6
|
+
readonly actor: ActorRef;
|
|
7
|
+
readonly id: FactId;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// The fact for a pull request row that is not open: its merge, or its close with no merge. An
|
|
11
|
+
// open row has none.
|
|
12
|
+
export function buildStateFact(input: StateFactInput): AnyNewFact | null {
|
|
13
|
+
const row = input.row;
|
|
14
|
+
|
|
15
|
+
const base = {
|
|
16
|
+
id: input.id,
|
|
17
|
+
entity: `boxxy:item:${input.item}`,
|
|
18
|
+
actor: input.actor,
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
const shared = {
|
|
22
|
+
attempt: row.attempt,
|
|
23
|
+
repo: row.repo,
|
|
24
|
+
number: row.number,
|
|
25
|
+
pullRequestId: String(row.githubId),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (row.state === 'merged' && row.mergedAt !== null && row.mergeCommit !== null) {
|
|
29
|
+
return {
|
|
30
|
+
...base,
|
|
31
|
+
type: 'boxxy.pull_request.merged',
|
|
32
|
+
payload: { ...shared, mergedAt: row.mergedAt, mergeCommit: row.mergeCommit },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return row.state === 'closed'
|
|
37
|
+
? { ...base, type: 'boxxy.pull_request.closed', payload: shared }
|
|
38
|
+
: null;
|
|
39
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AttemptId } from '@boxxy/model';
|
|
2
|
+
import { brandSchemas } from '@boxxy/model';
|
|
3
|
+
|
|
4
|
+
const trailerPattern = /^Boxxy-Attempt:[ \t]*(?<attempt>\S+)[ \t]*$/gimu;
|
|
5
|
+
|
|
6
|
+
// The attempt IDs that the `Boxxy-Attempt` trailers of a commit message carry, in order. A
|
|
7
|
+
// value that is not an attempt ID is left out.
|
|
8
|
+
export function collectAttemptTrailers(message: string): AttemptId[] {
|
|
9
|
+
const attempts: AttemptId[] = [];
|
|
10
|
+
|
|
11
|
+
for (const match of message.matchAll(trailerPattern)) {
|
|
12
|
+
const parsed = brandSchemas.attemptId.safeParse(match.groups?.['attempt']);
|
|
13
|
+
|
|
14
|
+
if (parsed.success) {
|
|
15
|
+
attempts.push(parsed.data);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return attempts;
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CommitSha, Instant, PullRequestReading, PullRequestRow } from '@boxxy/model';
|
|
2
|
+
|
|
3
|
+
export interface SeenState {
|
|
4
|
+
readonly state: PullRequestRow['state'];
|
|
5
|
+
readonly mergedAt: Instant | null;
|
|
6
|
+
readonly mergeCommit: CommitSha | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// The state the watch records for a reading. A merged reading with no merge time or no merge
|
|
10
|
+
// commit reads as open, so the next pass sees the merge once the forge shows it whole.
|
|
11
|
+
export function collectSeenState(reading: PullRequestReading): SeenState {
|
|
12
|
+
if (reading.state === 'merged') {
|
|
13
|
+
return reading.mergedAt !== null && reading.mergeCommit !== null
|
|
14
|
+
? { state: 'merged', mergedAt: reading.mergedAt, mergeCommit: reading.mergeCommit }
|
|
15
|
+
: { state: 'open', mergedAt: null, mergeCommit: null };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return { state: reading.state, mergedAt: null, mergeCommit: null };
|
|
19
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ItemId, PullRequestReading, PullRequestRef, StoreReader } from '@boxxy/model';
|
|
2
|
+
import invariant from 'tiny-invariant';
|
|
3
|
+
import { pickCallAttempt } from '../attempts/pick-call-attempt.ts';
|
|
4
|
+
import { buildLinkChange } from './build-link-change.ts';
|
|
5
|
+
import { parsePullRequestUrl } from './parse-pull-request-url.ts';
|
|
6
|
+
import type { LinkPullRequestInput, LinkPullRequestResult } from './types.ts';
|
|
7
|
+
|
|
8
|
+
// It checks the call before the forge read and again in the write's lock, because a newer attempt
|
|
9
|
+
// can take the session while the forge answers.
|
|
10
|
+
export async function linkPullRequest(input: LinkPullRequestInput): Promise<LinkPullRequestResult> {
|
|
11
|
+
const ref = parsePullRequestUrl(input.url);
|
|
12
|
+
|
|
13
|
+
if (ref === null) {
|
|
14
|
+
return { kind: 'refused', reason: 'not-a-pull-request-url' };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const latest = await input.store.read((reader) =>
|
|
18
|
+
reader.findAttemptBySession(input.call.session),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const picked = pickCallAttempt(latest, input.call);
|
|
22
|
+
|
|
23
|
+
if (picked.kind === 'refused') {
|
|
24
|
+
return picked;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const attempt = picked.attempt;
|
|
28
|
+
|
|
29
|
+
const item = await input.store.read((reader) =>
|
|
30
|
+
reader.readItem(attempt.item, input.now, input.rules),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
invariant(item !== null, 'the attempt of a call has its item');
|
|
34
|
+
|
|
35
|
+
if (item.repo.toLowerCase() !== ref.repo.toLowerCase()) {
|
|
36
|
+
return { kind: 'refused', reason: 'not-a-pull-request-url' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const known = await input.store.read((reader) => readExisting(reader, ref, item.id));
|
|
40
|
+
|
|
41
|
+
if (known !== 'none') {
|
|
42
|
+
return toAnswer(known, ref);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const reading = await readOrNull(input, ref);
|
|
46
|
+
|
|
47
|
+
if (reading === null) {
|
|
48
|
+
return { kind: 'retry' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return input.store.exclusive(async (tx) => {
|
|
52
|
+
const latestInLock = await tx.findAttemptBySession(input.call.session);
|
|
53
|
+
|
|
54
|
+
const current = pickCallAttempt(latestInLock, input.call);
|
|
55
|
+
|
|
56
|
+
if (current.kind === 'refused') {
|
|
57
|
+
return current;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const raced = await readExisting(tx, ref, item.id);
|
|
61
|
+
|
|
62
|
+
if (raced !== 'none') {
|
|
63
|
+
return toAnswer(raced, ref);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const applied = await tx.applyChange(
|
|
67
|
+
buildLinkChange({
|
|
68
|
+
attempt: current.attempt,
|
|
69
|
+
ref,
|
|
70
|
+
reading,
|
|
71
|
+
source: 'hook',
|
|
72
|
+
actor: `boxxy:hook:${current.attempt.id}`,
|
|
73
|
+
now: input.now,
|
|
74
|
+
newId: input.newId,
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return applied.kind === 'applied'
|
|
79
|
+
? { kind: 'linked', repo: ref.repo, number: ref.number, repeat: false }
|
|
80
|
+
: { kind: 'retry' };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Existing = 'none' | 'repeat' | 'elsewhere';
|
|
85
|
+
|
|
86
|
+
async function readExisting(
|
|
87
|
+
reader: StoreReader,
|
|
88
|
+
ref: PullRequestRef,
|
|
89
|
+
item: ItemId,
|
|
90
|
+
): Promise<Existing> {
|
|
91
|
+
const row = await reader.readPullRequestRow(ref);
|
|
92
|
+
|
|
93
|
+
if (row === null) {
|
|
94
|
+
return 'none';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const owner = await reader.readAttempt(row.attempt);
|
|
98
|
+
|
|
99
|
+
return owner?.item === item ? 'repeat' : 'elsewhere';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function toAnswer(existing: Exclude<Existing, 'none'>, ref: PullRequestRef): LinkPullRequestResult {
|
|
103
|
+
return existing === 'repeat'
|
|
104
|
+
? { kind: 'linked', repo: ref.repo, number: ref.number, repeat: true }
|
|
105
|
+
: { kind: 'refused', reason: 'linked-elsewhere' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readOrNull(
|
|
109
|
+
input: LinkPullRequestInput,
|
|
110
|
+
ref: PullRequestRef,
|
|
111
|
+
): Promise<PullRequestReading | null> {
|
|
112
|
+
try {
|
|
113
|
+
const reading = await input.forge.readPullRequest(ref);
|
|
114
|
+
|
|
115
|
+
return reading === 'not-found' ? null : reading;
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PullRequestRef } from '@boxxy/model';
|
|
2
|
+
import { brandSchemas } from '@boxxy/model';
|
|
3
|
+
|
|
4
|
+
const pathPattern = /^\/(?<owner>[^/]+)\/(?<name>[^/]+)\/pull\/(?<number>\d+)(?:\/.*)?$/u;
|
|
5
|
+
|
|
6
|
+
// The repo and number a pull request URL names, or null for any other URL. The host is not
|
|
7
|
+
// checked: the forge decides which host it serves, and the stored URL comes from the forge.
|
|
8
|
+
export function parsePullRequestUrl(url: string): PullRequestRef | null {
|
|
9
|
+
const parsed = URL.parse(url);
|
|
10
|
+
|
|
11
|
+
if (parsed === null || (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const match = pathPattern.exec(parsed.pathname);
|
|
16
|
+
|
|
17
|
+
if (match === null) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const repo = brandSchemas.repoName.safeParse(
|
|
22
|
+
`${match.groups?.['owner']}/${match.groups?.['name']}`,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const number = Number(match.groups?.['number']);
|
|
26
|
+
|
|
27
|
+
return repo.success && Number.isSafeInteger(number) && number > 0
|
|
28
|
+
? { repo: repo.data, number }
|
|
29
|
+
: null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClaimStore,
|
|
3
|
+
Forge,
|
|
4
|
+
Instant,
|
|
5
|
+
ItemRules,
|
|
6
|
+
NewId,
|
|
7
|
+
RepoName,
|
|
8
|
+
RunnerId,
|
|
9
|
+
} from '@boxxy/model';
|
|
10
|
+
import type { CallRef } from '../closeouts/types.ts';
|
|
11
|
+
|
|
12
|
+
export interface LinkPullRequestInput {
|
|
13
|
+
readonly store: ClaimStore;
|
|
14
|
+
readonly forge: Forge;
|
|
15
|
+
readonly rules: ItemRules;
|
|
16
|
+
|
|
17
|
+
// the call from the hook; the link reaches only the current attempt of the call's session
|
|
18
|
+
readonly call: CallRef;
|
|
19
|
+
|
|
20
|
+
// the URL the hook read from the output of `gh pr create`
|
|
21
|
+
readonly url: string;
|
|
22
|
+
readonly now: Instant;
|
|
23
|
+
readonly newId: NewId;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type LinkPullRequestResult =
|
|
27
|
+
| {
|
|
28
|
+
readonly kind: 'linked';
|
|
29
|
+
readonly repo: RepoName;
|
|
30
|
+
readonly number: number;
|
|
31
|
+
|
|
32
|
+
// the pull request was already linked to this item and nothing was written
|
|
33
|
+
readonly repeat: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// the forge did not answer, or does not know the pull request yet; nothing was written
|
|
37
|
+
| { readonly kind: 'retry' }
|
|
38
|
+
|
|
39
|
+
// `not-a-pull-request-url`: no pull request URL, or one of a repo other than the item's;
|
|
40
|
+
// `no-attempt` and `not-current`: the call names no current attempt of its session
|
|
41
|
+
| {
|
|
42
|
+
readonly kind: 'refused';
|
|
43
|
+
readonly reason: 'not-a-pull-request-url' | 'linked-elsewhere' | 'no-attempt' | 'not-current';
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export interface WatchPullRequestsInput {
|
|
47
|
+
readonly store: ClaimStore;
|
|
48
|
+
readonly forge: Forge;
|
|
49
|
+
readonly rules: ItemRules;
|
|
50
|
+
readonly runner: RunnerId;
|
|
51
|
+
readonly now: Instant;
|
|
52
|
+
readonly newId: NewId;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// What one pass of the watch did. `unanswered` counts forge calls that threw; each is tried
|
|
56
|
+
// again on the next pass.
|
|
57
|
+
export interface WatchReport {
|
|
58
|
+
readonly linked: number;
|
|
59
|
+
readonly commitsLinked: number;
|
|
60
|
+
readonly merged: number;
|
|
61
|
+
readonly closed: number;
|
|
62
|
+
readonly unanswered: number;
|
|
63
|
+
}
|