@livestore/common 0.3.0-dev.8 → 0.3.0-dev.9
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/dist/.tsbuildinfo +1 -1
- package/dist/devtools/devtools-messages.d.ts +39 -39
- package/dist/leader-thread/LeaderSyncProcessor.d.ts +16 -26
- package/dist/leader-thread/LeaderSyncProcessor.d.ts.map +1 -1
- package/dist/leader-thread/LeaderSyncProcessor.js +146 -157
- package/dist/leader-thread/LeaderSyncProcessor.js.map +1 -1
- package/dist/leader-thread/apply-mutation.js +1 -1
- package/dist/leader-thread/apply-mutation.js.map +1 -1
- package/dist/leader-thread/pull-queue-set.d.ts.map +1 -1
- package/dist/leader-thread/types.d.ts +9 -3
- package/dist/leader-thread/types.d.ts.map +1 -1
- package/dist/leader-thread/types.js.map +1 -1
- package/dist/schema/MutationEvent.d.ts +6 -5
- package/dist/schema/MutationEvent.d.ts.map +1 -1
- package/dist/schema/MutationEvent.js.map +1 -1
- package/dist/sync/ClientSessionSyncProcessor.d.ts +1 -1
- package/dist/sync/ClientSessionSyncProcessor.d.ts.map +1 -1
- package/dist/sync/ClientSessionSyncProcessor.js +5 -3
- package/dist/sync/ClientSessionSyncProcessor.js.map +1 -1
- package/dist/sync/syncstate.d.ts +39 -17
- package/dist/sync/syncstate.d.ts.map +1 -1
- package/dist/sync/syncstate.js +56 -12
- package/dist/sync/syncstate.js.map +1 -1
- package/dist/sync/syncstate.test.js +110 -51
- package/dist/sync/syncstate.test.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
- package/src/leader-thread/LeaderSyncProcessor.ts +196 -205
- package/src/leader-thread/apply-mutation.ts +1 -1
- package/src/leader-thread/types.ts +10 -2
- package/src/schema/MutationEvent.ts +5 -1
- package/src/sync/ClientSessionSyncProcessor.ts +6 -4
- package/src/sync/syncstate.test.ts +110 -51
- package/src/sync/syncstate.ts +21 -19
- package/src/version.ts +1 -1
@@ -1,18 +1,17 @@
|
|
1
|
-
import { shouldNeverHappen, TRACE_VERBOSE } from '@livestore/utils'
|
1
|
+
import { isNotUndefined, shouldNeverHappen, TRACE_VERBOSE } from '@livestore/utils'
|
2
2
|
import type { HttpClient, Scope } from '@livestore/utils/effect'
|
3
3
|
import {
|
4
4
|
BucketQueue,
|
5
5
|
Deferred,
|
6
6
|
Effect,
|
7
7
|
Exit,
|
8
|
-
Fiber,
|
9
8
|
FiberHandle,
|
10
9
|
Option,
|
11
10
|
OtelTracer,
|
12
11
|
ReadonlyArray,
|
13
|
-
Ref,
|
14
12
|
Schema,
|
15
13
|
Stream,
|
14
|
+
Subscribable,
|
16
15
|
SubscriptionRef,
|
17
16
|
} from '@livestore/utils/effect'
|
18
17
|
import type * as otel from '@opentelemetry/api'
|
@@ -37,58 +36,28 @@ import { getBackendHeadFromDb, getLocalHeadFromDb, getMutationEventsSince, updat
|
|
37
36
|
import type { InitialBlockingSyncContext, InitialSyncInfo, LeaderSyncProcessor } from './types.js'
|
38
37
|
import { LeaderThreadCtx } from './types.js'
|
39
38
|
|
40
|
-
type ProcessorStateInit = {
|
41
|
-
_tag: 'init'
|
42
|
-
}
|
43
|
-
|
44
|
-
type ProcessorStateInSync = {
|
45
|
-
_tag: 'in-sync'
|
46
|
-
syncState: SyncState.SyncState
|
47
|
-
}
|
48
|
-
|
49
|
-
type ProcessorStateApplyingSyncStateAdvance = {
|
50
|
-
_tag: 'applying-syncstate-advance'
|
51
|
-
origin: 'pull' | 'push'
|
52
|
-
syncState: SyncState.SyncState
|
53
|
-
// TODO re-introduce this
|
54
|
-
// proccesHead: EventId
|
55
|
-
fiber: Fiber.RuntimeFiber<void, UnexpectedError>
|
56
|
-
}
|
57
|
-
|
58
|
-
type ProcessorState = ProcessorStateInit | ProcessorStateInSync | ProcessorStateApplyingSyncStateAdvance
|
59
|
-
|
60
39
|
/**
|
61
|
-
* The
|
62
|
-
*
|
63
|
-
* The leader sync processor is also responsible for
|
64
|
-
* - broadcasting mutations to client sessions via the pull queues.
|
65
|
-
* - pushing mutations to the sync backend
|
40
|
+
* The LeaderSyncProcessor manages synchronization of mutations between
|
41
|
+
* the local state and the sync backend, ensuring efficient and orderly processing.
|
66
42
|
*
|
67
|
-
* In the
|
43
|
+
* In the LeaderSyncProcessor, pulling always has precedence over pushing.
|
68
44
|
*
|
69
|
-
*
|
70
|
-
* -
|
71
|
-
* -
|
72
|
-
* -
|
45
|
+
* Responsibilities:
|
46
|
+
* - Queueing incoming local mutations in a localPushMailbox.
|
47
|
+
* - Broadcasting mutations to client sessions via pull queues.
|
48
|
+
* - Pushing mutations to the sync backend.
|
73
49
|
*
|
74
|
-
*
|
75
|
-
* - in-sync: fully synced with remote, now idling
|
76
|
-
* - applying-syncstate-advance (with pointer to current progress in case of rebase interrupt)
|
50
|
+
* Notes:
|
77
51
|
*
|
78
|
-
*
|
79
|
-
* -
|
80
|
-
*
|
81
|
-
*
|
52
|
+
* local push processing:
|
53
|
+
* - localPushMailbox:
|
54
|
+
* - Maintains events in ascending order.
|
55
|
+
* - Uses `Deferred` objects to resolve/reject events based on application success.
|
56
|
+
* - Processes events from the mailbox, applying mutations in batches.
|
57
|
+
* - Controlled by a `Latch` to manage execution flow.
|
58
|
+
* - The latch closes on pull receipt and re-opens post-pull completion.
|
59
|
+
* - Processes up to `maxBatchSize` events per cycle.
|
82
60
|
*
|
83
|
-
* Queuing vs interrupting behaviour:
|
84
|
-
* - Operations caused by pull can never be interrupted
|
85
|
-
* - Incoming pull can interrupt current push
|
86
|
-
* - Incoming pull needs to wait to previous pull to finish
|
87
|
-
* - Incoming push needs to wait to previous push to finish
|
88
|
-
*
|
89
|
-
* Backend pushing:
|
90
|
-
* - continously push to backend
|
91
|
-
* - only interrupted and restarted on rebase
|
92
61
|
*/
|
93
62
|
export const makeLeaderSyncProcessor = ({
|
94
63
|
schema,
|
@@ -105,9 +74,7 @@ export const makeLeaderSyncProcessor = ({
|
|
105
74
|
Effect.gen(function* () {
|
106
75
|
const syncBackendQueue = yield* BucketQueue.make<MutationEvent.EncodedWithMeta>()
|
107
76
|
|
108
|
-
const
|
109
|
-
|
110
|
-
const semaphore = yield* Effect.makeSemaphore(1)
|
77
|
+
const syncStateSref = yield* SubscriptionRef.make<SyncState.SyncState | undefined>(undefined)
|
111
78
|
|
112
79
|
const isLocalEvent = (mutationEventEncoded: MutationEvent.EncodedWithMeta) => {
|
113
80
|
const mutationDef = schema.mutations.get(mutationEventEncoded.mutation)!
|
@@ -115,105 +82,35 @@ export const makeLeaderSyncProcessor = ({
|
|
115
82
|
}
|
116
83
|
|
117
84
|
const spanRef = { current: undefined as otel.Span | undefined }
|
118
|
-
const applyMutationItemsRef = { current: undefined as ApplyMutationItems | undefined }
|
119
|
-
|
120
|
-
// TODO get rid of counters once Effect semaphore ordering is fixed
|
121
|
-
let counterRef = 0
|
122
|
-
let expectedCounter = 0
|
123
85
|
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
- Have a worker fiber that takes from the mailbox (wouldn't need a semaphore)
|
128
|
-
*/
|
86
|
+
const localPushesQueue = yield* BucketQueue.make<MutationEvent.EncodedWithMeta>()
|
87
|
+
const localPushesLatch = yield* Effect.makeLatch(true)
|
88
|
+
const pullLatch = yield* Effect.makeLatch(true)
|
129
89
|
|
130
|
-
const
|
90
|
+
const push: LeaderSyncProcessor['push'] = (newEvents, options) =>
|
131
91
|
Effect.gen(function* () {
|
132
|
-
// console.log('waitForSyncState: waiting for semaphore', counter)
|
133
|
-
yield* semaphore.take(1)
|
134
|
-
// NOTE this is a workaround to ensure the semaphore take-order is respected
|
135
|
-
// TODO this needs to be fixed upstream in Effect
|
136
|
-
if (counter !== expectedCounter) {
|
137
|
-
console.log(
|
138
|
-
`waitForSyncState: counter mismatch (expected: ${expectedCounter}, got: ${counter}), releasing semaphore`,
|
139
|
-
)
|
140
|
-
yield* semaphore.release(1)
|
141
|
-
yield* Effect.yieldNow()
|
142
|
-
// Retrying...
|
143
|
-
return yield* waitForSyncState(counter)
|
144
|
-
}
|
145
|
-
// console.log('waitForSyncState: took semaphore', counter)
|
146
|
-
const state = yield* Ref.get(stateRef)
|
147
|
-
if (state._tag !== 'in-sync') {
|
148
|
-
return shouldNeverHappen('Expected to be in-sync but got ' + state._tag)
|
149
|
-
}
|
150
|
-
expectedCounter = counter + 1
|
151
|
-
return state
|
152
|
-
}).pipe(Effect.withSpan(`@livestore/common:leader-thread:syncing:waitForSyncState(${counter})`))
|
153
|
-
|
154
|
-
const push = (newEvents: ReadonlyArray<MutationEvent.EncodedWithMeta>) =>
|
155
|
-
Effect.gen(function* () {
|
156
|
-
const counter = counterRef
|
157
|
-
counterRef++
|
158
92
|
// TODO validate batch
|
159
93
|
if (newEvents.length === 0) return
|
160
94
|
|
161
|
-
const
|
162
|
-
|
163
|
-
// TODO if there are multiple pending pushes, we should batch them together
|
164
|
-
const state = yield* waitForSyncState(counter)
|
95
|
+
const waitForProcessing = options?.waitForProcessing ?? false
|
165
96
|
|
166
|
-
const
|
167
|
-
|
168
|
-
|
169
|
-
isLocalEvent,
|
170
|
-
isEqualEvent: MutationEvent.isEqualEncoded,
|
171
|
-
})
|
97
|
+
const deferreds = waitForProcessing
|
98
|
+
? yield* Effect.forEach(newEvents, () => Deferred.make<void, InvalidPushError>())
|
99
|
+
: newEvents.map((_) => undefined)
|
172
100
|
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
_tag: 'LeaderAhead',
|
180
|
-
minimumExpectedId: updateResult.expectedMinimumId,
|
181
|
-
providedId: newEvents.at(0)!.id,
|
182
|
-
},
|
101
|
+
// TODO validate batch ordering
|
102
|
+
const mappedEvents = newEvents.map(
|
103
|
+
(mutationEventEncoded, i) =>
|
104
|
+
new MutationEvent.EncodedWithMeta({
|
105
|
+
...mutationEventEncoded,
|
106
|
+
meta: { deferred: deferreds[i] },
|
183
107
|
}),
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
const fiber = yield* applyMutationItemsRef.current!({ batchItems: updateResult.newEvents }).pipe(Effect.fork)
|
188
|
-
|
189
|
-
yield* Ref.set(stateRef, {
|
190
|
-
_tag: 'applying-syncstate-advance',
|
191
|
-
origin: 'push',
|
192
|
-
syncState: updateResult.newSyncState,
|
193
|
-
fiber,
|
194
|
-
})
|
195
|
-
|
196
|
-
// console.log('setRef:applying-syncstate-advance after push', counter)
|
197
|
-
|
198
|
-
yield* connectedClientSessionPullQueues.offer({
|
199
|
-
payload: { _tag: 'upstream-advance', newEvents: updateResult.newEvents },
|
200
|
-
remaining: 0,
|
201
|
-
})
|
202
|
-
|
203
|
-
spanRef.current?.addEvent('local-push', {
|
204
|
-
batchSize: newEvents.length,
|
205
|
-
updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
|
206
|
-
})
|
207
|
-
|
208
|
-
// Don't sync localOnly mutations
|
209
|
-
const filteredBatch = updateResult.newEvents.filter((mutationEventEncoded) => {
|
210
|
-
const mutationDef = schema.mutations.get(mutationEventEncoded.mutation)!
|
211
|
-
return mutationDef.options.localOnly === false
|
212
|
-
})
|
213
|
-
|
214
|
-
yield* BucketQueue.offerAll(syncBackendQueue, filteredBatch)
|
108
|
+
)
|
109
|
+
yield* BucketQueue.offerAll(localPushesQueue, mappedEvents)
|
215
110
|
|
216
|
-
|
111
|
+
if (waitForProcessing) {
|
112
|
+
yield* Effect.all(deferreds as ReadonlyArray<Deferred.Deferred<void, InvalidPushError>>)
|
113
|
+
}
|
217
114
|
}).pipe(
|
218
115
|
Effect.withSpan('@livestore/common:leader-thread:syncing:local-push', {
|
219
116
|
attributes: {
|
@@ -228,8 +125,8 @@ export const makeLeaderSyncProcessor = ({
|
|
228
125
|
|
229
126
|
const pushPartial: LeaderSyncProcessor['pushPartial'] = (mutationEventEncoded_) =>
|
230
127
|
Effect.gen(function* () {
|
231
|
-
const
|
232
|
-
if (
|
128
|
+
const syncState = yield* syncStateSref
|
129
|
+
if (syncState === undefined) return shouldNeverHappen('Not initialized')
|
233
130
|
|
234
131
|
const mutationDef =
|
235
132
|
schema.mutations.get(mutationEventEncoded_.mutation) ??
|
@@ -237,7 +134,7 @@ export const makeLeaderSyncProcessor = ({
|
|
237
134
|
|
238
135
|
const mutationEventEncoded = new MutationEvent.EncodedWithMeta({
|
239
136
|
...mutationEventEncoded_,
|
240
|
-
...EventId.nextPair(
|
137
|
+
...EventId.nextPair(syncState.localHead, mutationDef.options.localOnly),
|
241
138
|
})
|
242
139
|
|
243
140
|
yield* push([mutationEventEncoded])
|
@@ -263,18 +160,16 @@ export const makeLeaderSyncProcessor = ({
|
|
263
160
|
local: EventId.localDefault,
|
264
161
|
}).pipe(Effect.map(ReadonlyArray.map((_) => new MutationEvent.EncodedWithMeta(_))))
|
265
162
|
|
266
|
-
const initialSyncState = {
|
163
|
+
const initialSyncState = new SyncState.SyncState({
|
267
164
|
pending: pendingMutationEvents,
|
268
165
|
// On the leader we don't need a rollback tail beyond `pending` items
|
269
166
|
rollbackTail: [],
|
270
167
|
upstreamHead: { global: initialBackendHead, local: EventId.localDefault },
|
271
168
|
localHead: initialLocalHead,
|
272
|
-
}
|
169
|
+
})
|
273
170
|
|
274
171
|
/** State transitions need to happen atomically, so we use a Ref to track the state */
|
275
|
-
yield*
|
276
|
-
|
277
|
-
applyMutationItemsRef.current = yield* makeApplyMutationItems({ stateRef, semaphore })
|
172
|
+
yield* SubscriptionRef.set(syncStateSref, initialSyncState)
|
278
173
|
|
279
174
|
// Rehydrate sync queue
|
280
175
|
if (pendingMutationEvents.length > 0) {
|
@@ -288,6 +183,17 @@ export const makeLeaderSyncProcessor = ({
|
|
288
183
|
yield* BucketQueue.offerAll(syncBackendQueue, filteredBatch)
|
289
184
|
}
|
290
185
|
|
186
|
+
yield* backgroundApplyLocalPushes({
|
187
|
+
localPushesLatch,
|
188
|
+
localPushesQueue,
|
189
|
+
pullLatch,
|
190
|
+
syncStateSref,
|
191
|
+
syncBackendQueue,
|
192
|
+
schema,
|
193
|
+
isLocalEvent,
|
194
|
+
span,
|
195
|
+
}).pipe(Effect.tapCauseLogPretty, Effect.forkScoped)
|
196
|
+
|
291
197
|
const backendPushingFiberHandle = yield* FiberHandle.make()
|
292
198
|
|
293
199
|
yield* FiberHandle.run(
|
@@ -314,9 +220,9 @@ export const makeLeaderSyncProcessor = ({
|
|
314
220
|
backgroundBackendPushing({ dbReady, syncBackendQueue, span }).pipe(Effect.tapCauseLogPretty),
|
315
221
|
)
|
316
222
|
}),
|
317
|
-
|
318
|
-
|
319
|
-
|
223
|
+
syncStateSref,
|
224
|
+
localPushesLatch,
|
225
|
+
pullLatch,
|
320
226
|
span,
|
321
227
|
initialBlockingSyncContext,
|
322
228
|
}).pipe(Effect.tapCauseLogPretty, Effect.forkScoped)
|
@@ -326,26 +232,130 @@ export const makeLeaderSyncProcessor = ({
|
|
326
232
|
push,
|
327
233
|
pushPartial,
|
328
234
|
boot,
|
329
|
-
syncState:
|
330
|
-
|
331
|
-
|
332
|
-
|
235
|
+
syncState: Subscribable.make({
|
236
|
+
get: Effect.gen(function* () {
|
237
|
+
const syncState = yield* syncStateSref
|
238
|
+
if (syncState === undefined) return shouldNeverHappen('Not initialized')
|
239
|
+
return syncState
|
240
|
+
}),
|
241
|
+
changes: syncStateSref.changes.pipe(Stream.filter(isNotUndefined)),
|
333
242
|
}),
|
334
243
|
} satisfies LeaderSyncProcessor
|
335
244
|
})
|
336
245
|
|
246
|
+
const backgroundApplyLocalPushes = ({
|
247
|
+
localPushesLatch,
|
248
|
+
localPushesQueue,
|
249
|
+
pullLatch,
|
250
|
+
syncStateSref,
|
251
|
+
syncBackendQueue,
|
252
|
+
schema,
|
253
|
+
isLocalEvent,
|
254
|
+
span,
|
255
|
+
}: {
|
256
|
+
pullLatch: Effect.Latch
|
257
|
+
localPushesLatch: Effect.Latch
|
258
|
+
localPushesQueue: BucketQueue.BucketQueue<MutationEvent.EncodedWithMeta>
|
259
|
+
syncStateSref: SubscriptionRef.SubscriptionRef<SyncState.SyncState | undefined>
|
260
|
+
syncBackendQueue: BucketQueue.BucketQueue<MutationEvent.EncodedWithMeta>
|
261
|
+
schema: LiveStoreSchema
|
262
|
+
isLocalEvent: (mutationEventEncoded: MutationEvent.EncodedWithMeta) => boolean
|
263
|
+
span: otel.Span | undefined
|
264
|
+
}) =>
|
265
|
+
Effect.gen(function* () {
|
266
|
+
const { connectedClientSessionPullQueues } = yield* LeaderThreadCtx
|
267
|
+
|
268
|
+
const applyMutationItems = yield* makeApplyMutationItems
|
269
|
+
|
270
|
+
while (true) {
|
271
|
+
// TODO make this configurable
|
272
|
+
const newEvents = yield* BucketQueue.takeBetween(localPushesQueue, 1, 10)
|
273
|
+
|
274
|
+
// Wait for the backend pulling to finish
|
275
|
+
yield* localPushesLatch.await
|
276
|
+
|
277
|
+
// Prevent the backend pulling from starting until this local push is finished
|
278
|
+
yield* pullLatch.close
|
279
|
+
|
280
|
+
const syncState = yield* syncStateSref
|
281
|
+
if (syncState === undefined) return shouldNeverHappen('Not initialized')
|
282
|
+
|
283
|
+
const updateResult = SyncState.updateSyncState({
|
284
|
+
syncState,
|
285
|
+
payload: { _tag: 'local-push', newEvents },
|
286
|
+
isLocalEvent,
|
287
|
+
isEqualEvent: MutationEvent.isEqualEncoded,
|
288
|
+
})
|
289
|
+
|
290
|
+
if (updateResult._tag === 'rebase') {
|
291
|
+
return shouldNeverHappen('The leader thread should never have to rebase due to a local push')
|
292
|
+
} else if (updateResult._tag === 'reject') {
|
293
|
+
span?.addEvent('local-push:reject', {
|
294
|
+
batchSize: newEvents.length,
|
295
|
+
updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
|
296
|
+
})
|
297
|
+
|
298
|
+
const providedId = newEvents.at(0)!.id
|
299
|
+
const remainingEvents = yield* BucketQueue.takeAll(localPushesQueue)
|
300
|
+
const allEvents = [...newEvents, ...remainingEvents]
|
301
|
+
yield* Effect.forEach(allEvents, (mutationEventEncoded) =>
|
302
|
+
mutationEventEncoded.meta.deferred
|
303
|
+
? Deferred.fail(
|
304
|
+
mutationEventEncoded.meta.deferred,
|
305
|
+
InvalidPushError.make({
|
306
|
+
// TODO improve error handling so it differentiates between a push being rejected
|
307
|
+
// because of itself or because of another push
|
308
|
+
reason: {
|
309
|
+
_tag: 'LeaderAhead',
|
310
|
+
minimumExpectedId: updateResult.expectedMinimumId,
|
311
|
+
providedId,
|
312
|
+
},
|
313
|
+
}),
|
314
|
+
)
|
315
|
+
: Effect.void,
|
316
|
+
)
|
317
|
+
|
318
|
+
// Allow the backend pulling to start
|
319
|
+
yield* pullLatch.open
|
320
|
+
|
321
|
+
// In this case we're skipping state update and down/upstream processing
|
322
|
+
// We've cleared the local push queue and are now waiting for new local pushes / backend pulls
|
323
|
+
continue
|
324
|
+
}
|
325
|
+
|
326
|
+
yield* SubscriptionRef.set(syncStateSref, updateResult.newSyncState)
|
327
|
+
|
328
|
+
yield* connectedClientSessionPullQueues.offer({
|
329
|
+
payload: { _tag: 'upstream-advance', newEvents: updateResult.newEvents },
|
330
|
+
remaining: 0,
|
331
|
+
})
|
332
|
+
|
333
|
+
span?.addEvent('local-push', {
|
334
|
+
batchSize: newEvents.length,
|
335
|
+
updateResult: TRACE_VERBOSE ? JSON.stringify(updateResult) : undefined,
|
336
|
+
})
|
337
|
+
|
338
|
+
// Don't sync localOnly mutations
|
339
|
+
const filteredBatch = updateResult.newEvents.filter((mutationEventEncoded) => {
|
340
|
+
const mutationDef = schema.mutations.get(mutationEventEncoded.mutation)!
|
341
|
+
return mutationDef.options.localOnly === false
|
342
|
+
})
|
343
|
+
|
344
|
+
yield* BucketQueue.offerAll(syncBackendQueue, filteredBatch)
|
345
|
+
|
346
|
+
yield* applyMutationItems({ batchItems: newEvents })
|
347
|
+
|
348
|
+
// Allow the backend pulling to start
|
349
|
+
yield* pullLatch.open
|
350
|
+
}
|
351
|
+
})
|
352
|
+
|
337
353
|
type ApplyMutationItems = (_: {
|
338
354
|
batchItems: ReadonlyArray<MutationEvent.EncodedWithMeta>
|
339
355
|
}) => Effect.Effect<void, UnexpectedError>
|
340
356
|
|
341
357
|
// TODO how to handle errors gracefully
|
342
|
-
const makeApplyMutationItems =
|
343
|
-
stateRef,
|
344
|
-
semaphore,
|
345
|
-
}: {
|
346
|
-
stateRef: Ref.Ref<ProcessorState>
|
347
|
-
semaphore: Effect.Semaphore
|
348
|
-
}): Effect.Effect<ApplyMutationItems, UnexpectedError, LeaderThreadCtx | Scope.Scope> =>
|
358
|
+
const makeApplyMutationItems: Effect.Effect<ApplyMutationItems, UnexpectedError, LeaderThreadCtx | Scope.Scope> =
|
349
359
|
Effect.gen(function* () {
|
350
360
|
const leaderThreadCtx = yield* LeaderThreadCtx
|
351
361
|
const { db, dbLog } = leaderThreadCtx
|
@@ -354,12 +364,6 @@ const makeApplyMutationItems = ({
|
|
354
364
|
|
355
365
|
return ({ batchItems }) =>
|
356
366
|
Effect.gen(function* () {
|
357
|
-
const state = yield* Ref.get(stateRef)
|
358
|
-
if (state._tag !== 'applying-syncstate-advance') {
|
359
|
-
// console.log('applyMutationItems: counter', counter)
|
360
|
-
return shouldNeverHappen(`Expected to be applying-syncstate-advance but got ${state._tag}`)
|
361
|
-
}
|
362
|
-
|
363
367
|
db.execute('BEGIN TRANSACTION', undefined) // Start the transaction
|
364
368
|
dbLog.execute('BEGIN TRANSACTION', undefined) // Start the transaction
|
365
369
|
|
@@ -381,20 +385,12 @@ const makeApplyMutationItems = ({
|
|
381
385
|
if (meta?.deferred) {
|
382
386
|
yield* Deferred.succeed(meta.deferred, void 0)
|
383
387
|
}
|
384
|
-
|
385
|
-
// TODO re-introduce this
|
386
|
-
// if (i < batchItems.length - 1) {
|
387
|
-
// yield* Ref.set(stateRef, { ...state, proccesHead: batchItems[i + 1]!.id })
|
388
|
-
// }
|
389
388
|
}
|
390
389
|
|
391
390
|
db.execute('COMMIT', undefined) // Commit the transaction
|
392
391
|
dbLog.execute('COMMIT', undefined) // Commit the transaction
|
393
|
-
|
394
|
-
yield* Ref.set(stateRef, { _tag: 'in-sync', syncState: state.syncState })
|
395
|
-
// console.log('setRef:sync after applyMutationItems', counter)
|
396
|
-
yield* semaphore.release(1)
|
397
392
|
}).pipe(
|
393
|
+
Effect.uninterruptible,
|
398
394
|
Effect.scoped,
|
399
395
|
Effect.withSpan('@livestore/common:leader-thread:syncing:applyMutationItems', {
|
400
396
|
attributes: { count: batchItems.length },
|
@@ -410,9 +406,9 @@ const backgroundBackendPulling = ({
|
|
410
406
|
isLocalEvent,
|
411
407
|
restartBackendPushing,
|
412
408
|
span,
|
413
|
-
|
414
|
-
|
415
|
-
|
409
|
+
syncStateSref,
|
410
|
+
localPushesLatch,
|
411
|
+
pullLatch,
|
416
412
|
initialBlockingSyncContext,
|
417
413
|
}: {
|
418
414
|
dbReady: Deferred.Deferred<void>
|
@@ -422,9 +418,9 @@ const backgroundBackendPulling = ({
|
|
422
418
|
filteredRebasedPending: ReadonlyArray<MutationEvent.EncodedWithMeta>,
|
423
419
|
) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx | HttpClient.HttpClient>
|
424
420
|
span: otel.Span | undefined
|
425
|
-
|
426
|
-
|
427
|
-
|
421
|
+
syncStateSref: SubscriptionRef.SubscriptionRef<SyncState.SyncState | undefined>
|
422
|
+
localPushesLatch: Effect.Latch
|
423
|
+
pullLatch: Effect.Latch
|
428
424
|
initialBlockingSyncContext: InitialBlockingSyncContext
|
429
425
|
}) =>
|
430
426
|
Effect.gen(function* () {
|
@@ -434,30 +430,25 @@ const backgroundBackendPulling = ({
|
|
434
430
|
|
435
431
|
const cursorInfo = yield* getCursorInfo(initialBackendHead)
|
436
432
|
|
433
|
+
const applyMutationItems = yield* makeApplyMutationItems
|
434
|
+
|
437
435
|
const onNewPullChunk = (newEvents: MutationEvent.EncodedWithMeta[], remaining: number) =>
|
438
436
|
Effect.gen(function* () {
|
439
437
|
if (newEvents.length === 0) return
|
440
438
|
|
441
|
-
|
442
|
-
|
439
|
+
// Prevent more local pushes from being processed until this pull is finished
|
440
|
+
yield* localPushesLatch.close
|
443
441
|
|
444
|
-
//
|
442
|
+
// Wait for pending local pushes to finish
|
443
|
+
yield* pullLatch.await
|
445
444
|
|
446
|
-
|
447
|
-
|
448
|
-
yield* Fiber.interrupt(state.fiber)
|
449
|
-
// In theory we should force-take the semaphore here, but as it's still taken,
|
450
|
-
// it's already in the right state we want it to be in
|
451
|
-
} else {
|
452
|
-
// Wait for previous advance to finish
|
453
|
-
yield* semaphore.take(1)
|
454
|
-
}
|
455
|
-
}
|
445
|
+
const syncState = yield* syncStateSref
|
446
|
+
if (syncState === undefined) return shouldNeverHappen('Not initialized')
|
456
447
|
|
457
448
|
const trimRollbackUntil = newEvents.at(-1)!.id
|
458
449
|
|
459
450
|
const updateResult = SyncState.updateSyncState({
|
460
|
-
syncState
|
451
|
+
syncState,
|
461
452
|
payload: { _tag: 'upstream-advance', newEvents, trimRollbackUntil },
|
462
453
|
isLocalEvent,
|
463
454
|
isEqualEvent: MutationEvent.isEqualEncoded,
|
@@ -513,14 +504,14 @@ const backgroundBackendPulling = ({
|
|
513
504
|
|
514
505
|
trimChangesetRows(db, newBackendHead)
|
515
506
|
|
516
|
-
|
507
|
+
yield* applyMutationItems({ batchItems: updateResult.newEvents })
|
517
508
|
|
518
|
-
yield*
|
519
|
-
|
520
|
-
|
521
|
-
|
522
|
-
|
523
|
-
}
|
509
|
+
yield* SubscriptionRef.set(syncStateSref, updateResult.newSyncState)
|
510
|
+
|
511
|
+
if (remaining === 0) {
|
512
|
+
// Allow local pushes to be processed again
|
513
|
+
yield* localPushesLatch.open
|
514
|
+
}
|
524
515
|
})
|
525
516
|
|
526
517
|
yield* syncBackend.pull(cursorInfo).pipe(
|
@@ -101,7 +101,7 @@ export const makeApplyMutation: Effect.Effect<ApplyMutation, never, Scope.Scope
|
|
101
101
|
attributes: {
|
102
102
|
mutationName: mutationEventEncoded.mutation,
|
103
103
|
mutationId: mutationEventEncoded.id,
|
104
|
-
'span.label': mutationEventEncoded.mutation
|
104
|
+
'span.label': `(${mutationEventEncoded.id.global},${mutationEventEncoded.id.local}) ${mutationEventEncoded.mutation}`,
|
105
105
|
},
|
106
106
|
}),
|
107
107
|
// Effect.logDuration('@livestore/common:leader-thread:applyMutation'),
|
@@ -6,6 +6,7 @@ import type {
|
|
6
6
|
Option,
|
7
7
|
Queue,
|
8
8
|
Scope,
|
9
|
+
Subscribable,
|
9
10
|
SubscriptionRef,
|
10
11
|
WebChannel,
|
11
12
|
} from '@livestore/utils/effect'
|
@@ -111,13 +112,20 @@ export interface LeaderSyncProcessor {
|
|
111
112
|
push: (
|
112
113
|
/** `batch` needs to follow the same rules as `batch` in `SyncBackend.push` */
|
113
114
|
batch: ReadonlyArray<MutationEvent.EncodedWithMeta>,
|
114
|
-
|
115
|
+
options?: {
|
116
|
+
/**
|
117
|
+
* If true, the effect will only finish when the local push has been processed (i.e. succeeded or was rejected).
|
118
|
+
* @default false
|
119
|
+
*/
|
120
|
+
waitForProcessing?: boolean
|
121
|
+
},
|
122
|
+
) => Effect.Effect<void, InvalidPushError>
|
115
123
|
|
116
124
|
pushPartial: (mutationEvent: MutationEvent.PartialAnyEncoded) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx>
|
117
125
|
boot: (args: {
|
118
126
|
dbReady: Deferred.Deferred<void>
|
119
127
|
}) => Effect.Effect<void, UnexpectedError, LeaderThreadCtx | Scope.Scope | HttpClient.HttpClient>
|
120
|
-
syncState:
|
128
|
+
syncState: Subscribable.Subscribable<SyncState.SyncState>
|
121
129
|
}
|
122
130
|
|
123
131
|
export interface PullQueueSet {
|
@@ -2,6 +2,7 @@ import { memoizeByRef } from '@livestore/utils'
|
|
2
2
|
import type { Deferred } from '@livestore/utils/effect'
|
3
3
|
import { Schema } from '@livestore/utils/effect'
|
4
4
|
|
5
|
+
import type { InvalidPushError } from '../sync/sync.js'
|
5
6
|
import * as EventId from './EventId.js'
|
6
7
|
import type { MutationDef, MutationDefRecord } from './mutations.js'
|
7
8
|
import type { LiveStoreSchema } from './schema.js'
|
@@ -138,7 +139,10 @@ export class EncodedWithMeta extends Schema.Class<EncodedWithMeta>('MutationEven
|
|
138
139
|
id: EventId.EventId,
|
139
140
|
parentId: EventId.EventId,
|
140
141
|
meta: Schema.optionalWith(
|
141
|
-
Schema.Any as Schema.Schema<{
|
142
|
+
Schema.Any as Schema.Schema<{
|
143
|
+
deferred?: Deferred.Deferred<void, InvalidPushError>
|
144
|
+
sessionChangeset?: Uint8Array
|
145
|
+
}>,
|
142
146
|
{ default: () => ({}) },
|
143
147
|
),
|
144
148
|
}) {
|
@@ -7,8 +7,7 @@ import type { ClientSessionLeaderThreadProxy, UnexpectedError } from '../adapter
|
|
7
7
|
import * as EventId from '../schema/EventId.js'
|
8
8
|
import { type LiveStoreSchema } from '../schema/mod.js'
|
9
9
|
import * as MutationEvent from '../schema/MutationEvent.js'
|
10
|
-
import
|
11
|
-
import { updateSyncState } from './syncstate.js'
|
10
|
+
import { SyncState, updateSyncState } from './syncstate.js'
|
12
11
|
|
13
12
|
/**
|
14
13
|
* Rebase behaviour:
|
@@ -47,13 +46,13 @@ export const makeClientSessionSyncProcessor = ({
|
|
47
46
|
const mutationEventSchema = MutationEvent.makeMutationEventSchemaMemo(schema)
|
48
47
|
|
49
48
|
const syncStateRef = {
|
50
|
-
current: {
|
49
|
+
current: new SyncState({
|
51
50
|
localHead: initialLeaderHead,
|
52
51
|
upstreamHead: initialLeaderHead,
|
53
52
|
pending: [],
|
54
53
|
// TODO init rollbackTail from leader to be ready for backend rebasing
|
55
54
|
rollbackTail: [],
|
56
|
-
}
|
55
|
+
}),
|
57
56
|
}
|
58
57
|
|
59
58
|
const isLocalEvent = (mutationEventEncoded: MutationEvent.EncodedWithMeta) => {
|
@@ -103,6 +102,7 @@ export const makeClientSessionSyncProcessor = ({
|
|
103
102
|
mutationEvent.meta.sessionChangeset = res.sessionChangeset
|
104
103
|
}
|
105
104
|
|
105
|
+
// console.debug('pushToLeader', encodedMutationEvents.length, ...encodedMutationEvents.map((_) => _.toJSON()))
|
106
106
|
pushToLeader(encodedMutationEvents)
|
107
107
|
|
108
108
|
return { writeTables }
|
@@ -154,6 +154,8 @@ export const makeClientSessionSyncProcessor = ({
|
|
154
154
|
event.meta.sessionChangeset = undefined
|
155
155
|
}
|
156
156
|
}
|
157
|
+
|
158
|
+
pushToLeader(updateResult.newSyncState.pending)
|
157
159
|
} else {
|
158
160
|
span.addEvent('pull:advance', {
|
159
161
|
payloadTag: payload._tag,
|