@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.
Files changed (59) hide show
  1. package/ChronicleClient.ts +127 -99
  2. package/ChronicleOptions.ts +23 -0
  3. package/dist/ChronicleClient.d.ts +7 -0
  4. package/dist/ChronicleClient.d.ts.map +1 -1
  5. package/dist/ChronicleClient.js +115 -95
  6. package/dist/ChronicleClient.js.map +1 -1
  7. package/dist/ChronicleOptions.d.ts +14 -0
  8. package/dist/ChronicleOptions.d.ts.map +1 -1
  9. package/dist/ChronicleOptions.js +16 -0
  10. package/dist/ChronicleOptions.js.map +1 -1
  11. package/dist/eventSequences/EventSequence.d.ts +12 -0
  12. package/dist/eventSequences/EventSequence.d.ts.map +1 -1
  13. package/dist/eventSequences/EventSequence.js +202 -175
  14. package/dist/eventSequences/EventSequence.js.map +1 -1
  15. package/dist/eventSequences/PreparedBatchAppend.d.ts +4 -0
  16. package/dist/eventSequences/PreparedBatchAppend.d.ts.map +1 -0
  17. package/dist/eventSequences/PreparedBatchAppend.js +4 -0
  18. package/dist/eventSequences/PreparedBatchAppend.js.map +1 -0
  19. package/dist/eventSequences/PreparedSingleAppend.d.ts +4 -0
  20. package/dist/eventSequences/PreparedSingleAppend.d.ts.map +1 -0
  21. package/dist/eventSequences/PreparedSingleAppend.js +4 -0
  22. package/dist/eventSequences/PreparedSingleAppend.js.map +1 -0
  23. package/dist/eventSequences/WireBatchEvent.d.ts +6 -0
  24. package/dist/eventSequences/WireBatchEvent.d.ts.map +1 -0
  25. package/dist/eventSequences/WireBatchEvent.js +4 -0
  26. package/dist/eventSequences/WireBatchEvent.js.map +1 -0
  27. package/dist/eventSequences/toContractsCausation.d.ts +16 -0
  28. package/dist/eventSequences/toContractsCausation.d.ts.map +1 -0
  29. package/dist/eventSequences/toContractsCausation.js +15 -0
  30. package/dist/eventSequences/toContractsCausation.js.map +1 -0
  31. package/dist/reducers/ReducerObservation.d.ts +18 -0
  32. package/dist/reducers/ReducerObservation.d.ts.map +1 -0
  33. package/dist/reducers/ReducerObservation.js +4 -0
  34. package/dist/reducers/ReducerObservation.js.map +1 -0
  35. package/dist/reducers/ReducerObservationSession.d.ts +11 -0
  36. package/dist/reducers/ReducerObservationSession.d.ts.map +1 -0
  37. package/dist/reducers/ReducerObservationSession.js +4 -0
  38. package/dist/reducers/ReducerObservationSession.js.map +1 -0
  39. package/dist/reducers/Reducers.d.ts +9 -0
  40. package/dist/reducers/Reducers.d.ts.map +1 -1
  41. package/dist/reducers/Reducers.js +149 -154
  42. package/dist/reducers/Reducers.js.map +1 -1
  43. package/dist/reducers/ReductionOutcome.d.ts +25 -0
  44. package/dist/reducers/ReductionOutcome.d.ts.map +1 -0
  45. package/dist/reducers/ReductionOutcome.js +66 -0
  46. package/dist/reducers/ReductionOutcome.js.map +1 -0
  47. package/dist/tsconfig.tsbuildinfo +1 -1
  48. package/eventSequences/EventSequence.ts +265 -186
  49. package/eventSequences/PreparedBatchAppend.ts +7 -0
  50. package/eventSequences/PreparedSingleAppend.ts +7 -0
  51. package/eventSequences/WireBatchEvent.ts +7 -0
  52. package/eventSequences/toContractsCausation.ts +17 -0
  53. package/for_ChronicleClient/messageSizes.spec.ts +37 -0
  54. package/for_ChronicleOptions/when_configuring_message_sizes/with_message_sizes.spec.ts +29 -0
  55. package/package.json +1 -1
  56. package/reducers/ReducerObservation.ts +25 -0
  57. package/reducers/ReducerObservationSession.ts +15 -0
  58. package/reducers/Reducers.ts +185 -171
  59. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/chronicle",
3
- "version": "6.51.0",
3
+ "version": "6.51.1",
4
4
  "description": "Event sourcing for TypeScript and Node.js — the idiomatic client for Cratis Chronicle, the open-source event-sourcing database",
5
5
  "author": "Cratis",
6
6
  "license": "MIT",
@@ -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
+ }
@@ -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 { ObservationState, ReadModelObserverType, ReducerMessage, ReplayState } from '@cratis/chronicle.contracts';
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(id, reducerType, eventSequenceId, readModelName, dispatcher);
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: string,
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
- const reducerInstance = this._artifactActivator ? undefined : new (reducerType as new () => Record<string, Function>)();
363
- if (reducerInstance) dispatcher.validateInstance(reducerInstance);
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
- for await (const operation of this._connection.reducers.observe(queue, { signal: controller.signal })) {
366
- let lastSuccessfullyObservedEvent = SEQUENCE_NUMBER_UNAVAILABLE;
367
- let state = ObservationState.Success;
368
- const exceptionMessages: string[] = [];
369
- let exceptionStackTrace = '';
370
- let readModelState: string | undefined;
371
-
372
- this._logger.debug('Received reduce operation', {
373
- reducerId: id,
374
- count: operation.Events.length,
375
- hasInitialState: operation.InitialState !== '',
376
- replayState: operation.ReplayState
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
- try {
380
- if (this._artifactActivator && operation.ReplayState !== ReplayState.REPLAY_STATE_None) {
381
- if (!this._eventStore) throw new Error('Reducer activation requires the owning event store.');
382
- await withActivatedArtifact(reducerType, {
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
- let currentState: unknown = operation.InitialState
407
- ? JSON.parse(operation.InitialState) as unknown
408
- : undefined;
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
- const processEvents = async (artifact: ActivatedArtifact<Record<string, Function>>) => {
411
- for (const event of state === ObservationState.Failed ? [] : operation.Events) {
412
- try {
413
- const eventTypeId = event.Context?.EventType?.Id;
414
- if (!eventTypeId) {
415
- this._logger.warn('Event missing event type context', { reducerId: id });
416
- continue;
417
- }
418
-
419
- const entry = dispatcher.handlerFor(eventTypeId);
420
- if (!entry) {
421
- this._logger.debug('No handler registered for event type — skipping', { reducerId: id, eventTypeId });
422
- lastSuccessfullyObservedEvent = event.Context!.SequenceNumber;
423
- continue;
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
- const firstInvocableEvent = this._artifactActivator && state === ObservationState.Success
451
- ? operation.Events.find(event =>
452
- !!event.Context?.EventType?.Id && !!dispatcher.handlerFor(event.Context.EventType.Id))
453
- : undefined;
454
- if (firstInvocableEvent && this._artifactActivator) {
455
- try {
456
- if (!this._eventStore) throw new Error('Reducer activation requires the owning event store.');
457
- await withActivatedArtifact(reducerType as new () => Record<string, Function>, {
458
- kind: ArtifactKind.Reducer, artifactId: id, eventStore: this._eventStore,
459
- readModels: this._eventStore.readModels, eventSequenceId, partition: operation.Partition,
460
- signal: controller.signal, delivery: ArtifactDelivery.Events,
461
- eventContext: toClientEventContext(firstInvocableEvent.Context!)
462
- }, this._artifactActivator, artifact => {
463
- dispatcher.validateInstance(artifact.instance);
464
- return processEvents(artifact);
465
- }, this._connection.logger);
466
- } catch (err) {
467
- this._logger.error('Error activating reducer', { reducerId: id, error: err });
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
- if (state === ObservationState.Success && currentState !== undefined) {
482
- readModelState = JSON.stringify(currentState);
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
- queue.send({
486
- Content: {
487
- Value0: undefined,
488
- Value1: {
489
- Partition: operation.Partition,
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
+ }