|
1 | 1 | import '../events/continuum_event.dart'; |
| 2 | +import '../exceptions/concurrency_exception.dart'; |
2 | 3 | import '../exceptions/invalid_creation_event_exception.dart'; |
3 | 4 | import '../exceptions/stream_not_found_exception.dart'; |
4 | 5 | import '../exceptions/unsupported_event_exception.dart'; |
@@ -223,7 +224,33 @@ final class SessionImpl implements ContinuumSession { |
223 | 224 | } |
224 | 225 |
|
225 | 226 | @override |
226 | | - Future<void> saveChangesAsync() async { |
| 227 | + Future<void> saveChangesAsync({int maxRetries = 1}) async { |
| 228 | + // Attempt to persist, retrying on concurrency conflicts up to the |
| 229 | + // configured limit. Each retry reloads conflicting streams and |
| 230 | + // re-applies pending events on top of the latest persisted state. |
| 231 | + for (var attempt = 0; attempt <= maxRetries; attempt++) { |
| 232 | + try { |
| 233 | + await _persistPendingEventsAsync(); |
| 234 | + return; |
| 235 | + } on ConcurrencyException { |
| 236 | + final isLastAttempt = attempt == maxRetries; |
| 237 | + if (isLastAttempt) { |
| 238 | + // All retries exhausted — propagate the conflict to the caller. |
| 239 | + rethrow; |
| 240 | + } |
| 241 | + |
| 242 | + // Reload every stream that still has pending events so the next |
| 243 | + // attempt uses the latest persisted versions. |
| 244 | + await _reloadStreamsWithPendingEventsAsync(); |
| 245 | + } |
| 246 | + } |
| 247 | + } |
| 248 | + |
| 249 | + /// Core persistence logic extracted so [saveChangesAsync] can retry it. |
| 250 | + /// |
| 251 | + /// Serialises pending events, writes them to the store (atomically when |
| 252 | + /// possible), runs inline projections, and updates internal stream state. |
| 253 | + Future<void> _persistPendingEventsAsync() async { |
227 | 254 | final pendingEntries = <MapEntry<StreamId, _StreamState>>[]; |
228 | 255 | for (final entry in _streams.entries) { |
229 | 256 | if (entry.value.pendingEvents.isNotEmpty) { |
@@ -331,6 +358,137 @@ final class SessionImpl implements ContinuumSession { |
331 | 358 | } |
332 | 359 | } |
333 | 360 |
|
| 361 | + /// Reloads all streams that still carry pending events. |
| 362 | + /// |
| 363 | + /// For each such stream the method fetches the latest persisted events, |
| 364 | + /// reconstructs a fresh aggregate, re-applies the pending events on top, |
| 365 | + /// and replaces the internal [_StreamState] so the next save attempt |
| 366 | + /// uses the correct [ExpectedVersion]. |
| 367 | + /// |
| 368 | + /// New streams (those created via [startStream] with `loadedVersion == -1`) |
| 369 | + /// are skipped because their concurrency conflict is a duplicate-stream |
| 370 | + /// error, not a stale-version error, and reloading would not help. |
| 371 | + Future<void> _reloadStreamsWithPendingEventsAsync() async { |
| 372 | + final streamIdsToReload = <StreamId>[]; |
| 373 | + |
| 374 | + for (final entry in _streams.entries) { |
| 375 | + final hasPendingEvents = entry.value.pendingEvents.isNotEmpty; |
| 376 | + final isExistingStream = entry.value.loadedVersion != -1; |
| 377 | + |
| 378 | + // Only existing streams benefit from a reload. New streams that |
| 379 | + // conflict have a duplicate-stream problem, not a stale-version one. |
| 380 | + if (hasPendingEvents && isExistingStream) { |
| 381 | + streamIdsToReload.add(entry.key); |
| 382 | + } |
| 383 | + } |
| 384 | + |
| 385 | + for (final streamId in streamIdsToReload) { |
| 386 | + final currentState = _streams[streamId]!; |
| 387 | + |
| 388 | + // Snapshot the events we still need to persist. |
| 389 | + final pendingEvents = List<ContinuumEvent>.of(currentState.pendingEvents); |
| 390 | + |
| 391 | + // Fetch the latest persisted events from the store. |
| 392 | + final storedEvents = await _eventStore.loadStreamAsync(streamId); |
| 393 | + |
| 394 | + if (storedEvents.isEmpty) { |
| 395 | + throw StreamNotFoundException(streamId: streamId); |
| 396 | + } |
| 397 | + |
| 398 | + // Reconstruct the aggregate from all persisted events (including any |
| 399 | + // that were written by competing sessions since our last load). |
| 400 | + final freshAggregate = _reconstructAggregateByRuntimeType( |
| 401 | + storedEvents, |
| 402 | + currentState.aggregateType, |
| 403 | + ); |
| 404 | + final latestVersion = storedEvents.last.version; |
| 405 | + |
| 406 | + // Re-apply our pending events on top of the freshly rebuilt state. |
| 407 | + for (final event in pendingEvents) { |
| 408 | + _applyEventByRuntimeType(freshAggregate, event, currentState.aggregateType); |
| 409 | + } |
| 410 | + |
| 411 | + // Replace the stream state so the next persist attempt carries the |
| 412 | + // updated loadedVersion and the correctly-mutated aggregate. |
| 413 | + _streams[streamId] = _StreamState( |
| 414 | + aggregate: freshAggregate, |
| 415 | + aggregateType: currentState.aggregateType, |
| 416 | + loadedVersion: latestVersion, |
| 417 | + pendingEvents: pendingEvents, |
| 418 | + ); |
| 419 | + } |
| 420 | + } |
| 421 | + |
| 422 | + /// Reconstructs an aggregate using its runtime [Type] instead of a |
| 423 | + /// generic type parameter. |
| 424 | + /// |
| 425 | + /// This is used during concurrency retries where the concrete generic |
| 426 | + /// type is no longer available — only the [Type] captured at load time. |
| 427 | + Object _reconstructAggregateByRuntimeType( |
| 428 | + List<StoredEvent> events, |
| 429 | + Type aggregateType, |
| 430 | + ) { |
| 431 | + // Deserialize the creation event (first event in the stream). |
| 432 | + final creationStored = events.first; |
| 433 | + final creationEvent = _serializer.deserialize( |
| 434 | + eventType: creationStored.eventType, |
| 435 | + data: creationStored.data, |
| 436 | + storedMetadata: creationStored.metadata, |
| 437 | + ); |
| 438 | + |
| 439 | + // Look up the factory using the runtime type. |
| 440 | + final factory = _aggregateFactories.getFactory<Object>( |
| 441 | + aggregateType, |
| 442 | + creationEvent.runtimeType, |
| 443 | + ); |
| 444 | + |
| 445 | + if (factory == null) { |
| 446 | + throw InvalidCreationEventException( |
| 447 | + eventType: creationEvent.runtimeType, |
| 448 | + aggregateType: aggregateType, |
| 449 | + ); |
| 450 | + } |
| 451 | + |
| 452 | + final aggregate = factory(creationEvent); |
| 453 | + |
| 454 | + // Apply remaining mutation events. |
| 455 | + for (var i = 1; i < events.length; i++) { |
| 456 | + final storedEvent = events[i]; |
| 457 | + final domainEvent = _serializer.deserialize( |
| 458 | + eventType: storedEvent.eventType, |
| 459 | + data: storedEvent.data, |
| 460 | + storedMetadata: storedEvent.metadata, |
| 461 | + ); |
| 462 | + _applyEventByRuntimeType(aggregate, domainEvent, aggregateType); |
| 463 | + } |
| 464 | + |
| 465 | + return aggregate; |
| 466 | + } |
| 467 | + |
| 468 | + /// Applies a single event to an aggregate using the runtime [Type]. |
| 469 | + /// |
| 470 | + /// Counterpart to [_applyEvent] for use in retry paths where the |
| 471 | + /// generic type parameter is unavailable. |
| 472 | + void _applyEventByRuntimeType( |
| 473 | + Object aggregate, |
| 474 | + ContinuumEvent event, |
| 475 | + Type aggregateType, |
| 476 | + ) { |
| 477 | + final applier = _eventAppliers.getApplier<Object>( |
| 478 | + aggregateType, |
| 479 | + event.runtimeType, |
| 480 | + ); |
| 481 | + |
| 482 | + if (applier == null) { |
| 483 | + throw UnsupportedEventException( |
| 484 | + eventType: event.runtimeType, |
| 485 | + aggregateType: aggregateType, |
| 486 | + ); |
| 487 | + } |
| 488 | + |
| 489 | + applier(aggregate, event); |
| 490 | + } |
| 491 | + |
334 | 492 | @override |
335 | 493 | void discardStream(StreamId streamId) { |
336 | 494 | final state = _streams[streamId]; |
|
0 commit comments