@cratis/chronicle 6.51.0 → 6.51.1
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/ChronicleClient.ts +127 -99
- package/ChronicleOptions.ts +23 -0
- package/dist/ChronicleClient.d.ts +7 -0
- package/dist/ChronicleClient.d.ts.map +1 -1
- package/dist/ChronicleClient.js +115 -95
- package/dist/ChronicleClient.js.map +1 -1
- package/dist/ChronicleOptions.d.ts +14 -0
- package/dist/ChronicleOptions.d.ts.map +1 -1
- package/dist/ChronicleOptions.js +16 -0
- package/dist/ChronicleOptions.js.map +1 -1
- package/dist/eventSequences/EventSequence.d.ts +12 -0
- package/dist/eventSequences/EventSequence.d.ts.map +1 -1
- package/dist/eventSequences/EventSequence.js +202 -175
- package/dist/eventSequences/EventSequence.js.map +1 -1
- package/dist/eventSequences/PreparedBatchAppend.d.ts +4 -0
- package/dist/eventSequences/PreparedBatchAppend.d.ts.map +1 -0
- package/dist/eventSequences/PreparedBatchAppend.js +4 -0
- package/dist/eventSequences/PreparedBatchAppend.js.map +1 -0
- package/dist/eventSequences/PreparedSingleAppend.d.ts +4 -0
- package/dist/eventSequences/PreparedSingleAppend.d.ts.map +1 -0
- package/dist/eventSequences/PreparedSingleAppend.js +4 -0
- package/dist/eventSequences/PreparedSingleAppend.js.map +1 -0
- package/dist/eventSequences/WireBatchEvent.d.ts +6 -0
- package/dist/eventSequences/WireBatchEvent.d.ts.map +1 -0
- package/dist/eventSequences/WireBatchEvent.js +4 -0
- package/dist/eventSequences/WireBatchEvent.js.map +1 -0
- package/dist/eventSequences/toContractsCausation.d.ts +16 -0
- package/dist/eventSequences/toContractsCausation.d.ts.map +1 -0
- package/dist/eventSequences/toContractsCausation.js +15 -0
- package/dist/eventSequences/toContractsCausation.js.map +1 -0
- package/dist/reducers/ReducerObservation.d.ts +18 -0
- package/dist/reducers/ReducerObservation.d.ts.map +1 -0
- package/dist/reducers/ReducerObservation.js +4 -0
- package/dist/reducers/ReducerObservation.js.map +1 -0
- package/dist/reducers/ReducerObservationSession.d.ts +11 -0
- package/dist/reducers/ReducerObservationSession.d.ts.map +1 -0
- package/dist/reducers/ReducerObservationSession.js +4 -0
- package/dist/reducers/ReducerObservationSession.js.map +1 -0
- package/dist/reducers/Reducers.d.ts +9 -0
- package/dist/reducers/Reducers.d.ts.map +1 -1
- package/dist/reducers/Reducers.js +149 -154
- package/dist/reducers/Reducers.js.map +1 -1
- package/dist/reducers/ReductionOutcome.d.ts +25 -0
- package/dist/reducers/ReductionOutcome.d.ts.map +1 -0
- package/dist/reducers/ReductionOutcome.js +66 -0
- package/dist/reducers/ReductionOutcome.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/eventSequences/EventSequence.ts +265 -186
- package/eventSequences/PreparedBatchAppend.ts +7 -0
- package/eventSequences/PreparedSingleAppend.ts +7 -0
- package/eventSequences/WireBatchEvent.ts +7 -0
- package/eventSequences/toContractsCausation.ts +17 -0
- package/for_ChronicleClient/messageSizes.spec.ts +37 -0
- package/for_ChronicleOptions/when_configuring_message_sizes/with_message_sizes.spec.ts +29 -0
- package/package.json +1 -1
- package/reducers/ReducerObservation.ts +25 -0
- package/reducers/ReducerObservationSession.ts +15 -0
- package/reducers/Reducers.ts +185 -171
- package/reducers/ReductionOutcome.ts +75 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { chai, describe, it } from 'vitest';
|
|
5
|
+
import { ChronicleOptions } from '../../ChronicleOptions.js';
|
|
6
|
+
|
|
7
|
+
chai.should();
|
|
8
|
+
|
|
9
|
+
const hundredMegabytes = 100 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
describe('when configuring message sizes', () => {
|
|
12
|
+
it('should allow 100 MB in both directions by default, matching the other clients', () => {
|
|
13
|
+
const options = ChronicleOptions.fromConnectionString('chronicle://localhost:35000');
|
|
14
|
+
options.maxReceiveMessageSize!.should.equal(hundredMegabytes);
|
|
15
|
+
options.maxSendMessageSize!.should.equal(hundredMegabytes);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should use the configured sizes', () => {
|
|
19
|
+
const options = ChronicleOptions.development({ maxReceiveMessageSize: 200, maxSendMessageSize: 300 });
|
|
20
|
+
options.maxReceiveMessageSize!.should.equal(200);
|
|
21
|
+
options.maxSendMessageSize!.should.equal(300);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should keep null as null so the gRPC default can be asked for explicitly', () => {
|
|
25
|
+
const options = ChronicleOptions.development({ maxReceiveMessageSize: null, maxSendMessageSize: null });
|
|
26
|
+
(options.maxReceiveMessageSize === null).should.be.true;
|
|
27
|
+
(options.maxSendMessageSize === null).should.be.true;
|
|
28
|
+
});
|
|
29
|
+
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { Constructor } from '@cratis/fundamentals';
|
|
5
|
+
import { ReducerEventDispatcher } from './ReducerEventDispatcher.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Identifies a single reducer observation: the reducer, where it observes and how its events are dispatched.
|
|
9
|
+
*/
|
|
10
|
+
export interface ReducerObservation {
|
|
11
|
+
/** The reducer identifier. */
|
|
12
|
+
readonly id: string;
|
|
13
|
+
|
|
14
|
+
/** The reducer type. */
|
|
15
|
+
readonly reducerType: Constructor;
|
|
16
|
+
|
|
17
|
+
/** The event sequence the reducer observes. */
|
|
18
|
+
readonly eventSequenceId: string;
|
|
19
|
+
|
|
20
|
+
/** The identifier of the read model the reducer produces. */
|
|
21
|
+
readonly readModelName: string;
|
|
22
|
+
|
|
23
|
+
/** The dispatcher routing events to the reducer's handlers. */
|
|
24
|
+
readonly dispatcher: ReducerEventDispatcher;
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { ReducerObservation } from './ReducerObservation.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A {@link ReducerObservation} that is running against an open observation stream.
|
|
8
|
+
*/
|
|
9
|
+
export interface ReducerObservationSession extends ReducerObservation {
|
|
10
|
+
/** Aborted when the observation is stopped. */
|
|
11
|
+
readonly signal: AbortSignal;
|
|
12
|
+
|
|
13
|
+
/** The long-lived reducer instance, or undefined when instances are activated per delivery. */
|
|
14
|
+
readonly reducerInstance: Record<string, Function> | undefined;
|
|
15
|
+
}
|
package/reducers/Reducers.ts
CHANGED
|
@@ -5,7 +5,7 @@ import 'reflect-metadata';
|
|
|
5
5
|
import { createLogger } from '../logging/createLogger.js';
|
|
6
6
|
import { runInBackgroundContext } from '../telemetry/runInBackgroundContext.js';
|
|
7
7
|
import { Constructor } from '@cratis/fundamentals';
|
|
8
|
-
import {
|
|
8
|
+
import { AppendedEvent, ReadModelObserverType, ReduceOperationMessage, ReducerMessage, ReplayState } from '@cratis/chronicle.contracts';
|
|
9
9
|
import { IClientArtifactsProvider } from '../artifacts/index.js';
|
|
10
10
|
import { ChronicleConnection } from '../connection/index.js';
|
|
11
11
|
import { ConnectionLifecycle } from '../connection/ConnectionLifecycle.js';
|
|
@@ -19,6 +19,9 @@ import { notifyReplayLifecycle } from '../observation/notifyReplayLifecycle.js';
|
|
|
19
19
|
import { IReducers } from './IReducers.js';
|
|
20
20
|
import { getReducerMetadata } from './reducer.js';
|
|
21
21
|
import { ReducerEventDispatcher } from './ReducerEventDispatcher.js';
|
|
22
|
+
import { ReducerObservation } from './ReducerObservation.js';
|
|
23
|
+
import { ReducerObservationSession } from './ReducerObservationSession.js';
|
|
24
|
+
import { ReductionOutcome } from './ReductionOutcome.js';
|
|
22
25
|
import { getReadModelMetadata } from '../readModels/index.js';
|
|
23
26
|
import { getReadModelId } from '../readModels/readModel.js';
|
|
24
27
|
import { buildReadModelDefinition } from '../readModels/buildReadModelDefinition.js';
|
|
@@ -31,14 +34,10 @@ import type { ActivatedArtifact } from '../artifacts/ActivatedArtifact.js';
|
|
|
31
34
|
import { ArtifactKind } from '../artifacts/ArtifactKind.js';
|
|
32
35
|
import { ArtifactDelivery } from '../artifacts/ArtifactDelivery.js';
|
|
33
36
|
import { withActivatedArtifact, runActivated } from '../artifacts/withActivatedArtifact.js';
|
|
34
|
-
import { ArtifactCompletionFailed } from '../artifacts/ArtifactCompletionFailed.js';
|
|
35
37
|
|
|
36
38
|
/** Expression used to partition reducer observations by event source ID. */
|
|
37
39
|
const EVENT_SOURCE_ID_KEY = '$eventSourceId';
|
|
38
40
|
|
|
39
|
-
/** Sentinel sequence number sent back when no event was successfully processed. */
|
|
40
|
-
const SEQUENCE_NUMBER_UNAVAILABLE = 4294967295n;
|
|
41
|
-
|
|
42
41
|
/**
|
|
43
42
|
* A push-based async queue that implements {@link AsyncIterable} for use with nice-grpc
|
|
44
43
|
* bidirectional streaming. Values pushed via {@link send} are yielded in order to
|
|
@@ -259,24 +258,18 @@ export class Reducers implements IReducers {
|
|
|
259
258
|
handlers: dispatcher.handlers.map(e => e.methodName)
|
|
260
259
|
});
|
|
261
260
|
|
|
262
|
-
void this.runObservation(id, reducerType, eventSequenceId, readModelName, dispatcher);
|
|
261
|
+
void this.runObservation({ id, reducerType, eventSequenceId, readModelName, dispatcher });
|
|
263
262
|
});
|
|
264
263
|
}
|
|
265
264
|
|
|
266
|
-
private async runObservation(
|
|
267
|
-
id: string,
|
|
268
|
-
reducerType: Constructor,
|
|
269
|
-
eventSequenceId: string,
|
|
270
|
-
readModelName: string,
|
|
271
|
-
dispatcher: ReducerEventDispatcher
|
|
272
|
-
): Promise<void> {
|
|
265
|
+
private async runObservation(observation: ReducerObservation): Promise<void> {
|
|
273
266
|
try {
|
|
274
|
-
await this.observeReducer(
|
|
267
|
+
await this.observeReducer(observation);
|
|
275
268
|
} catch (error) {
|
|
276
|
-
this._logger.error('Reducer observation loop exited with error', { reducerId: id, error });
|
|
269
|
+
this._logger.error('Reducer observation loop exited with error', { reducerId: observation.id, error });
|
|
277
270
|
}
|
|
278
271
|
|
|
279
|
-
this.scheduleReobserve(id, reducerType, dispatcher);
|
|
272
|
+
this.scheduleReobserve(observation.id, observation.reducerType, observation.dispatcher);
|
|
280
273
|
}
|
|
281
274
|
|
|
282
275
|
/**
|
|
@@ -317,20 +310,51 @@ export class Reducers implements IReducers {
|
|
|
317
310
|
return (reducerType as Function).name;
|
|
318
311
|
}
|
|
319
312
|
|
|
320
|
-
private async observeReducer(
|
|
321
|
-
id
|
|
322
|
-
reducerType: Constructor,
|
|
323
|
-
eventSequenceId: string,
|
|
324
|
-
readModelName: string,
|
|
325
|
-
dispatcher: ReducerEventDispatcher
|
|
326
|
-
): Promise<void> {
|
|
313
|
+
private async observeReducer(observation: ReducerObservation): Promise<void> {
|
|
314
|
+
const { id, reducerType } = observation;
|
|
327
315
|
const queue = new AsyncQueue<ReducerMessage>();
|
|
328
316
|
const controller = new AbortController();
|
|
329
317
|
this._queues.set(id, queue);
|
|
330
318
|
this._observations.set(id, controller);
|
|
331
|
-
const isActive = getReducerMetadata(reducerType)?.isActive ?? true;
|
|
332
319
|
|
|
333
|
-
queue.send(
|
|
320
|
+
queue.send(this.createRegistrationMessage(observation));
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const reducerInstance = this._artifactActivator ? undefined : new (reducerType as new () => Record<string, Function>)();
|
|
324
|
+
if (reducerInstance) observation.dispatcher.validateInstance(reducerInstance);
|
|
325
|
+
const session: ReducerObservationSession = { ...observation, signal: controller.signal, reducerInstance };
|
|
326
|
+
|
|
327
|
+
for await (const operation of this._connection.reducers.observe(queue, { signal: controller.signal })) {
|
|
328
|
+
// Delivery steps are awaited here, not inside a wrapper async function, so that
|
|
329
|
+
// async-context updates made by handlers reach this loop as they did originally.
|
|
330
|
+
const outcome = this.beginReduction(session, operation);
|
|
331
|
+
await this.notifyReplayTransition(session, operation, outcome);
|
|
332
|
+
outcome.currentState = operation.InitialState
|
|
333
|
+
? JSON.parse(operation.InitialState) as unknown
|
|
334
|
+
: undefined;
|
|
335
|
+
await this.reduceEvents(session, operation, outcome);
|
|
336
|
+
queue.send(outcome.toMessage(operation.Partition));
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
if (!this._queues.has(id)) {
|
|
340
|
+
this._logger.debug('Reducer observation stream closed cleanly', { reducerId: id });
|
|
341
|
+
} else {
|
|
342
|
+
this._logger.error('Reducer observation stream ended unexpectedly', { reducerId: id, error: err });
|
|
343
|
+
}
|
|
344
|
+
} finally {
|
|
345
|
+
// Only retire our own queue: a reconnect can already have replaced it,
|
|
346
|
+
// and deleting the new one would leave that observation untracked and
|
|
347
|
+
// leak a duplicate stream on every reconnect.
|
|
348
|
+
if (this._queues.get(id) === queue) {
|
|
349
|
+
this._queues.delete(id);
|
|
350
|
+
this._observations.delete(id);
|
|
351
|
+
}
|
|
352
|
+
queue.complete();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private createRegistrationMessage({ id, reducerType, eventSequenceId, readModelName, dispatcher }: ReducerObservation): ReducerMessage {
|
|
357
|
+
return {
|
|
334
358
|
Content: {
|
|
335
359
|
Value0: {
|
|
336
360
|
ConnectionId: this._lifecycle.connectionId,
|
|
@@ -345,7 +369,7 @@ export class Reducers implements IReducers {
|
|
|
345
369
|
})),
|
|
346
370
|
ReadModel: readModelName,
|
|
347
371
|
Hash: '',
|
|
348
|
-
IsActive: isActive,
|
|
372
|
+
IsActive: getReducerMetadata(reducerType)?.isActive ?? true,
|
|
349
373
|
Tags: getTagsFor(reducerType).map(t => t.value),
|
|
350
374
|
Filters: {
|
|
351
375
|
FilterTags: getFilterTagsFor(reducerType).map(t => t.value),
|
|
@@ -356,162 +380,152 @@ export class Reducers implements IReducers {
|
|
|
356
380
|
},
|
|
357
381
|
Value1: undefined
|
|
358
382
|
}
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private beginReduction(session: ReducerObservationSession, operation: ReduceOperationMessage): ReductionOutcome {
|
|
387
|
+
this._logger.debug('Received reduce operation', {
|
|
388
|
+
reducerId: session.id,
|
|
389
|
+
count: operation.Events.length,
|
|
390
|
+
hasInitialState: operation.InitialState !== '',
|
|
391
|
+
replayState: operation.ReplayState
|
|
359
392
|
});
|
|
360
393
|
|
|
394
|
+
return new ReductionOutcome();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private async notifyReplayTransition(
|
|
398
|
+
session: ReducerObservationSession,
|
|
399
|
+
operation: ReduceOperationMessage,
|
|
400
|
+
outcome: ReductionOutcome
|
|
401
|
+
): Promise<void> {
|
|
361
402
|
try {
|
|
362
|
-
|
|
363
|
-
|
|
403
|
+
if (this._artifactActivator && operation.ReplayState !== ReplayState.REPLAY_STATE_None) {
|
|
404
|
+
await this.notifyActivatedReplayTransition(session, operation, this._artifactActivator);
|
|
405
|
+
} else if (session.reducerInstance) {
|
|
406
|
+
await notifyReplayLifecycle(session.reducerInstance, operation.ReplayState, operation.Partition);
|
|
407
|
+
}
|
|
408
|
+
} catch (err) {
|
|
409
|
+
this._logger.error('Error notifying reducer of replay lifecycle transition', { reducerId: session.id, error: err });
|
|
410
|
+
outcome.recordReplayNotificationFailure(err);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
364
413
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
414
|
+
private async notifyActivatedReplayTransition(
|
|
415
|
+
{ id, reducerType, eventSequenceId, dispatcher, signal }: ReducerObservationSession,
|
|
416
|
+
operation: ReduceOperationMessage,
|
|
417
|
+
activator: ClientArtifactsActivator
|
|
418
|
+
): Promise<void> {
|
|
419
|
+
const eventStore = this.requireEventStore();
|
|
420
|
+
await withActivatedArtifact(reducerType, {
|
|
421
|
+
kind: ArtifactKind.Reducer, artifactId: id, eventStore,
|
|
422
|
+
readModels: eventStore.readModels, eventSequenceId, partition: operation.Partition,
|
|
423
|
+
signal, delivery: ArtifactDelivery.ReplayNotification,
|
|
424
|
+
replayState: operation.ReplayState
|
|
425
|
+
}, activator, artifact => {
|
|
426
|
+
dispatcher.validateInstance(artifact.instance);
|
|
427
|
+
return runActivated(artifact, () =>
|
|
428
|
+
notifyReplayLifecycle(artifact.instance, operation.ReplayState, operation.Partition),
|
|
429
|
+
{ delivery: ArtifactDelivery.ReplayNotification, replayState: operation.ReplayState });
|
|
430
|
+
}, this._connection.logger);
|
|
431
|
+
}
|
|
378
432
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
kind: ArtifactKind.Reducer, artifactId: id, eventStore: this._eventStore,
|
|
384
|
-
readModels: this._eventStore.readModels, eventSequenceId, partition: operation.Partition,
|
|
385
|
-
signal: controller.signal, delivery: ArtifactDelivery.ReplayNotification,
|
|
386
|
-
replayState: operation.ReplayState
|
|
387
|
-
}, this._artifactActivator, artifact => {
|
|
388
|
-
dispatcher.validateInstance(artifact.instance);
|
|
389
|
-
return runActivated(artifact, () =>
|
|
390
|
-
notifyReplayLifecycle(artifact.instance, operation.ReplayState, operation.Partition),
|
|
391
|
-
{ delivery: ArtifactDelivery.ReplayNotification, replayState: operation.ReplayState });
|
|
392
|
-
}, this._connection.logger);
|
|
393
|
-
} else if (reducerInstance) {
|
|
394
|
-
await notifyReplayLifecycle(reducerInstance, operation.ReplayState, operation.Partition);
|
|
395
|
-
}
|
|
396
|
-
} catch (err) {
|
|
397
|
-
this._logger.error('Error notifying reducer of replay lifecycle transition', { reducerId: id, error: err });
|
|
398
|
-
if (err instanceof ArtifactCompletionFailed && err.processingError !== undefined) {
|
|
399
|
-
exceptionMessages.push(String(err.processingError));
|
|
400
|
-
}
|
|
401
|
-
exceptionMessages.push(String(err));
|
|
402
|
-
exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
|
|
403
|
-
state = ObservationState.Failed;
|
|
404
|
-
}
|
|
433
|
+
private requireEventStore(): IEventStore {
|
|
434
|
+
if (!this._eventStore) throw new Error('Reducer activation requires the owning event store.');
|
|
435
|
+
return this._eventStore;
|
|
436
|
+
}
|
|
405
437
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
438
|
+
private async reduceEvents(
|
|
439
|
+
session: ReducerObservationSession,
|
|
440
|
+
operation: ReduceOperationMessage,
|
|
441
|
+
outcome: ReductionOutcome
|
|
442
|
+
): Promise<void> {
|
|
443
|
+
const { id, reducerType, eventSequenceId, dispatcher, signal, reducerInstance } = session;
|
|
444
|
+
const firstInvocableEvent = this._artifactActivator && !outcome.failed
|
|
445
|
+
? operation.Events.find(event =>
|
|
446
|
+
!!event.Context?.EventType?.Id && !!dispatcher.handlerFor(event.Context.EventType.Id))
|
|
447
|
+
: undefined;
|
|
448
|
+
if (!firstInvocableEvent || !this._artifactActivator) {
|
|
449
|
+
await this.processEvents(session, operation, outcome, { instance: reducerInstance ?? {} });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
409
452
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const content = JSON.parse(event.Content) as Record<string, unknown>;
|
|
427
|
-
const context = toClientEventContext(event.Context!);
|
|
428
|
-
|
|
429
|
-
this._logger.info('Invoking reducer handler', {
|
|
430
|
-
reducerId: id,
|
|
431
|
-
method: entry.methodName,
|
|
432
|
-
sequenceNumber: event.Context!.SequenceNumber.toString(),
|
|
433
|
-
eventTypeId,
|
|
434
|
-
hasState: currentState !== undefined
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
currentState = await runActivated(artifact, () => dispatcher.invoke(artifact.instance, entry, content, currentState, context),
|
|
438
|
-
{ delivery: ArtifactDelivery.Events, eventContext: context, methodName: entry.methodName });
|
|
439
|
-
lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
|
|
440
|
-
} catch (err) {
|
|
441
|
-
this._logger.error('Error handling event in reducer', { reducerId: id, error: err });
|
|
442
|
-
exceptionMessages.push(String(err));
|
|
443
|
-
exceptionStackTrace = err instanceof Error ? (err.stack ?? '') : '';
|
|
444
|
-
state = ObservationState.Failed;
|
|
445
|
-
break;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
};
|
|
453
|
+
try {
|
|
454
|
+
const eventStore = this.requireEventStore();
|
|
455
|
+
await withActivatedArtifact(reducerType as new () => Record<string, Function>, {
|
|
456
|
+
kind: ArtifactKind.Reducer, artifactId: id, eventStore,
|
|
457
|
+
readModels: eventStore.readModels, eventSequenceId, partition: operation.Partition,
|
|
458
|
+
signal, delivery: ArtifactDelivery.Events,
|
|
459
|
+
eventContext: toClientEventContext(firstInvocableEvent.Context!)
|
|
460
|
+
}, this._artifactActivator, artifact => {
|
|
461
|
+
dispatcher.validateInstance(artifact.instance);
|
|
462
|
+
return this.processEvents(session, operation, outcome, artifact);
|
|
463
|
+
}, this._connection.logger);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
this._logger.error('Error activating reducer', { reducerId: id, error: err });
|
|
466
|
+
outcome.recordActivationFailure(err);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
449
469
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
exceptionMessages.push(String(err));
|
|
469
|
-
const completionStack = err instanceof Error ? (err.stack ?? '') : '';
|
|
470
|
-
exceptionStackTrace = err instanceof ArtifactCompletionFailed && exceptionStackTrace
|
|
471
|
-
? `${exceptionStackTrace}\n${completionStack}` : completionStack;
|
|
472
|
-
state = ObservationState.Failed;
|
|
473
|
-
if (err instanceof ArtifactCompletionFailed) {
|
|
474
|
-
lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
} else {
|
|
478
|
-
await processEvents({ instance: reducerInstance ?? {} });
|
|
479
|
-
}
|
|
470
|
+
private async processEvents(
|
|
471
|
+
session: ReducerObservationSession,
|
|
472
|
+
operation: ReduceOperationMessage,
|
|
473
|
+
outcome: ReductionOutcome,
|
|
474
|
+
artifact: ActivatedArtifact<Record<string, Function>>
|
|
475
|
+
): Promise<void> {
|
|
476
|
+
for (const event of outcome.failed ? [] : operation.Events) {
|
|
477
|
+
try {
|
|
478
|
+
// Skipped events stay synchronous; only an actual handler invocation is awaited.
|
|
479
|
+
const pending = this.processEvent(session, event, outcome, artifact);
|
|
480
|
+
if (pending) await pending;
|
|
481
|
+
} catch (err) {
|
|
482
|
+
this._logger.error('Error handling event in reducer', { reducerId: session.id, error: err });
|
|
483
|
+
outcome.recordFailure(err);
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
480
488
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
489
|
+
private processEvent(
|
|
490
|
+
{ id, dispatcher }: ReducerObservationSession,
|
|
491
|
+
event: AppendedEvent,
|
|
492
|
+
outcome: ReductionOutcome,
|
|
493
|
+
artifact: ActivatedArtifact<Record<string, Function>>
|
|
494
|
+
): Promise<void> | undefined {
|
|
495
|
+
const eventTypeId = event.Context?.EventType?.Id;
|
|
496
|
+
if (!eventTypeId) {
|
|
497
|
+
this._logger.warn('Event missing event type context', { reducerId: id });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
484
500
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
State: state,
|
|
491
|
-
LastSuccessfulObservation: lastSuccessfullyObservedEvent,
|
|
492
|
-
ExceptionMessages: exceptionMessages,
|
|
493
|
-
ExceptionStackTrace: exceptionStackTrace,
|
|
494
|
-
ReadModelState: readModelState ?? ''
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
}
|
|
499
|
-
} catch (err) {
|
|
500
|
-
if (!this._queues.has(id)) {
|
|
501
|
-
this._logger.debug('Reducer observation stream closed cleanly', { reducerId: id });
|
|
502
|
-
} else {
|
|
503
|
-
this._logger.error('Reducer observation stream ended unexpectedly', { reducerId: id, error: err });
|
|
504
|
-
}
|
|
505
|
-
} finally {
|
|
506
|
-
// Only retire our own queue: a reconnect can already have replaced it,
|
|
507
|
-
// and deleting the new one would leave that observation untracked and
|
|
508
|
-
// leak a duplicate stream on every reconnect.
|
|
509
|
-
if (this._queues.get(id) === queue) {
|
|
510
|
-
this._queues.delete(id);
|
|
511
|
-
this._observations.delete(id);
|
|
512
|
-
}
|
|
513
|
-
queue.complete();
|
|
501
|
+
const entry = dispatcher.handlerFor(eventTypeId);
|
|
502
|
+
if (!entry) {
|
|
503
|
+
this._logger.debug('No handler registered for event type — skipping', { reducerId: id, eventTypeId });
|
|
504
|
+
outcome.lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
|
|
505
|
+
return;
|
|
514
506
|
}
|
|
507
|
+
|
|
508
|
+
const content = JSON.parse(event.Content) as Record<string, unknown>;
|
|
509
|
+
const context = toClientEventContext(event.Context!);
|
|
510
|
+
|
|
511
|
+
this._logger.info('Invoking reducer handler', {
|
|
512
|
+
reducerId: id,
|
|
513
|
+
method: entry.methodName,
|
|
514
|
+
sequenceNumber: event.Context!.SequenceNumber.toString(),
|
|
515
|
+
eventTypeId,
|
|
516
|
+
hasState: outcome.currentState !== undefined
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
return this.invokeHandler(
|
|
520
|
+
() => runActivated(artifact, () => dispatcher.invoke(artifact.instance, entry, content, outcome.currentState, context),
|
|
521
|
+
{ delivery: ArtifactDelivery.Events, eventContext: context, methodName: entry.methodName }),
|
|
522
|
+
outcome,
|
|
523
|
+
event.Context!.SequenceNumber);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private async invokeHandler(invoke: () => Promise<unknown>, outcome: ReductionOutcome, sequenceNumber: bigint): Promise<void> {
|
|
527
|
+
outcome.currentState = await invoke();
|
|
528
|
+
outcome.lastSuccessfullyObservedEvent = sequenceNumber;
|
|
515
529
|
}
|
|
516
530
|
|
|
517
531
|
private disconnectAll(): void {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Copyright (c) Cratis. All rights reserved.
|
|
2
|
+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
|
3
|
+
|
|
4
|
+
import { ObservationState, ReducerMessage } from '@cratis/chronicle.contracts';
|
|
5
|
+
import { ArtifactCompletionFailed } from '../artifacts/ArtifactCompletionFailed.js';
|
|
6
|
+
|
|
7
|
+
/** Sentinel sequence number sent back when no event was successfully processed. */
|
|
8
|
+
export const SEQUENCE_NUMBER_UNAVAILABLE = 4294967295n;
|
|
9
|
+
|
|
10
|
+
const stackOf = (error: unknown): string => error instanceof Error ? (error.stack ?? '') : '';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Accumulates the result of reducing one reduce operation: the state, the failure details and the
|
|
14
|
+
* last event that was successfully reduced.
|
|
15
|
+
*/
|
|
16
|
+
export class ReductionOutcome {
|
|
17
|
+
state = ObservationState.Success;
|
|
18
|
+
lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
|
|
19
|
+
readonly exceptionMessages: string[] = [];
|
|
20
|
+
exceptionStackTrace = '';
|
|
21
|
+
currentState: unknown;
|
|
22
|
+
|
|
23
|
+
/** Gets whether the reduction has failed. */
|
|
24
|
+
get failed(): boolean {
|
|
25
|
+
return this.state === ObservationState.Failed;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Records a failure of the reduction. */
|
|
29
|
+
recordFailure(error: unknown): void {
|
|
30
|
+
this.exceptionMessages.push(String(error));
|
|
31
|
+
this.exceptionStackTrace = stackOf(error);
|
|
32
|
+
this.state = ObservationState.Failed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Records a failure to notify of a replay lifecycle transition. */
|
|
36
|
+
recordReplayNotificationFailure(error: unknown): void {
|
|
37
|
+
if (error instanceof ArtifactCompletionFailed && error.processingError !== undefined) {
|
|
38
|
+
this.exceptionMessages.push(String(error.processingError));
|
|
39
|
+
}
|
|
40
|
+
this.recordFailure(error);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Records a failure to activate the reducer or to complete its activation. */
|
|
44
|
+
recordActivationFailure(error: unknown): void {
|
|
45
|
+
this.exceptionMessages.push(String(error));
|
|
46
|
+
const completionStack = stackOf(error);
|
|
47
|
+
this.exceptionStackTrace = error instanceof ArtifactCompletionFailed && this.exceptionStackTrace
|
|
48
|
+
? `${this.exceptionStackTrace}\n${completionStack}` : completionStack;
|
|
49
|
+
this.state = ObservationState.Failed;
|
|
50
|
+
if (error instanceof ArtifactCompletionFailed) {
|
|
51
|
+
this.lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Creates the result message reported back to the kernel. */
|
|
56
|
+
toMessage(partition: string): ReducerMessage {
|
|
57
|
+
const readModelState = this.state === ObservationState.Success && this.currentState !== undefined
|
|
58
|
+
? JSON.stringify(this.currentState)
|
|
59
|
+
: undefined;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
Content: {
|
|
63
|
+
Value0: undefined,
|
|
64
|
+
Value1: {
|
|
65
|
+
Partition: partition,
|
|
66
|
+
State: this.state,
|
|
67
|
+
LastSuccessfulObservation: this.lastSuccessfullyObservedEvent,
|
|
68
|
+
ExceptionMessages: this.exceptionMessages,
|
|
69
|
+
ExceptionStackTrace: this.exceptionStackTrace,
|
|
70
|
+
ReadModelState: readModelState ?? ''
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|