eventsourcing 9.3.5__py3-none-any.whl → 9.4.0a2__py3-none-any.whl

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.

Potentially problematic release.


This version of eventsourcing might be problematic. Click here for more details.

@@ -7,10 +7,12 @@ from collections import deque
7
7
  from dataclasses import dataclass
8
8
  from datetime import datetime
9
9
  from decimal import Decimal
10
- from threading import Condition, Event, Lock, Semaphore, Timer
11
- from time import time
10
+ from queue import Queue
11
+ from threading import Condition, Event, Lock, Semaphore, Thread, Timer
12
+ from time import monotonic, sleep, time
12
13
  from types import ModuleType
13
14
  from typing import (
15
+ TYPE_CHECKING,
14
16
  Any,
15
17
  Dict,
16
18
  Generic,
@@ -35,6 +37,9 @@ from eventsourcing.utils import (
35
37
  strtobool,
36
38
  )
37
39
 
40
+ if TYPE_CHECKING:
41
+ from typing_extensions import Self
42
+
38
43
 
39
44
  class Transcoding(ABC):
40
45
  """
@@ -58,17 +63,6 @@ class Transcoder(ABC):
58
63
  Abstract base class for transcoders.
59
64
  """
60
65
 
61
- def __init__(self) -> None:
62
- self.types: Dict[type, Transcoding] = {}
63
- self.names: Dict[str, Transcoding] = {}
64
-
65
- def register(self, transcoding: Transcoding) -> None:
66
- """
67
- Registers given transcoding with the transcoder.
68
- """
69
- self.types[transcoding.type] = transcoding
70
- self.names[transcoding.name] = transcoding
71
-
72
66
  @abstractmethod
73
67
  def encode(self, obj: Any) -> bytes:
74
68
  """Encodes obj as bytes."""
@@ -84,7 +78,8 @@ class JSONTranscoder(Transcoder):
84
78
  """
85
79
 
86
80
  def __init__(self) -> None:
87
- super().__init__()
81
+ self.types: Dict[type, Transcoding] = {}
82
+ self.names: Dict[str, Transcoding] = {}
88
83
  self.encoder = json.JSONEncoder(
89
84
  default=self._encode_obj,
90
85
  separators=(",", ":"),
@@ -92,6 +87,13 @@ class JSONTranscoder(Transcoder):
92
87
  )
93
88
  self.decoder = json.JSONDecoder(object_hook=self._decode_obj)
94
89
 
90
+ def register(self, transcoding: Transcoding) -> None:
91
+ """
92
+ Registers given transcoding with the transcoder.
93
+ """
94
+ self.types[transcoding.type] = transcoding
95
+ self.names[transcoding.name] = transcoding
96
+
95
97
  def encode(self, obj: Any) -> bytes:
96
98
  """
97
99
  Encodes given object as a bytes array.
@@ -200,19 +202,16 @@ class StoredEvent:
200
202
  Frozen dataclass that represents :class:`~eventsourcing.domain.DomainEvent`
201
203
  objects, such as aggregate :class:`~eventsourcing.domain.Aggregate.Event`
202
204
  objects and :class:`~eventsourcing.domain.Snapshot` objects.
203
-
204
- Constructor parameters:
205
-
206
- :param UUID originator_id: ID of the originating aggregate
207
- :param int originator_version: version of the originating aggregate
208
- :param str topic: topic of the domain event object class
209
- :param bytes state: serialised state of the domain event object
210
205
  """
211
206
 
212
207
  originator_id: uuid.UUID
208
+ """ID of the originating aggregate."""
213
209
  originator_version: int
210
+ """Position in an aggregate sequence."""
214
211
  topic: str
212
+ """Topic of a domain event object class."""
215
213
  state: bytes
214
+ """Serialised state of a domain event object."""
216
215
 
217
216
 
218
217
  class Compressor(ABC):
@@ -406,10 +405,19 @@ class NotSupportedError(DatabaseError):
406
405
  """
407
406
 
408
407
 
408
+ class WaitInterruptedError(PersistenceError):
409
+ """
410
+ Raised when waiting for a tracking record is interrupted.
411
+ """
412
+
413
+
414
+ class Recorder:
415
+ pass
416
+
417
+
409
418
  class AggregateRecorder(ABC):
410
419
  """
411
- Abstract base class for recorders that record and
412
- retrieve stored events for domain model aggregates.
420
+ Abstract base class for inserting and selecting stored events.
413
421
  """
414
422
 
415
423
  @abstractmethod
@@ -442,71 +450,135 @@ class Notification(StoredEvent):
442
450
  """
443
451
 
444
452
  id: int
453
+ """Position in an application sequence."""
445
454
 
446
455
 
447
456
  class ApplicationRecorder(AggregateRecorder):
448
457
  """
449
- Abstract base class for recorders that record and
450
- retrieve stored events for domain model aggregates.
451
-
452
- Extends the behaviour of aggregate recorders by
453
- recording aggregate events in a total order that
454
- allows the stored events also to be retrieved
455
- as event notifications.
458
+ Abstract base class for recording events in both aggregate
459
+ and application sequences.
456
460
  """
457
461
 
458
462
  @abstractmethod
459
463
  def select_notifications(
460
464
  self,
461
- start: int,
465
+ start: int | None,
462
466
  limit: int,
463
467
  stop: int | None = None,
464
468
  topics: Sequence[str] = (),
469
+ *,
470
+ inclusive_of_start: bool = True,
465
471
  ) -> List[Notification]:
466
472
  """
467
- Returns a list of event notifications
468
- from 'start', limited by 'limit' and
469
- optionally by 'stop'.
473
+ Returns a list of Notification objects representing events from an
474
+ application sequence. If `inclusive_of_start` is True (the default),
475
+ the returned Notification objects will have IDs greater than or equal
476
+ to `start` and less than or equal to `stop`. If `inclusive_of_start`
477
+ is False, the Notification objects will have IDs greater than `start`
478
+ and less than or equal to `stop`.
470
479
  """
471
480
 
472
481
  @abstractmethod
473
- def max_notification_id(self) -> int:
482
+ def max_notification_id(self) -> int | None:
483
+ """
484
+ Returns the largest notification ID in an application sequence,
485
+ or None if no stored events have been recorded.
474
486
  """
475
- Returns the maximum notification ID.
487
+
488
+ @abstractmethod
489
+ def subscribe(self, gt: int | None = None) -> Subscription[ApplicationRecorder]:
476
490
  """
491
+ Returns an iterator of Notification objects representing events from an
492
+ application sequence.
477
493
 
494
+ The iterator will block after the last recorded event has been yielded, but
495
+ will then continue yielding newly recorded events when they are recorded.
478
496
 
479
- class ProcessRecorder(ApplicationRecorder):
480
- """
481
- Abstract base class for recorders that record and
482
- retrieve stored events for domain model aggregates.
497
+ Notifications will have IDs greater than the optional `gt` argument.
498
+ """
483
499
 
484
- Extends the behaviour of applications recorders by
485
- recording aggregate events with tracking information
486
- that records the position of a processed event
487
- notification in a notification log.
500
+
501
+ class TrackingRecorder(Recorder, ABC):
502
+ """
503
+ Abstract base class for recorders that record tracking
504
+ objects atomically with other state.
488
505
  """
489
506
 
490
507
  @abstractmethod
491
- def max_tracking_id(self, application_name: str) -> int:
508
+ def insert_tracking(self, tracking: Tracking) -> None:
509
+ """
510
+ Records a tracking object.
511
+ """
512
+
513
+ @abstractmethod
514
+ def max_tracking_id(self, application_name: str) -> int | None:
492
515
  """
493
- Returns the largest notification ID across all tracking records
494
- for the named application. Returns zero if there are no tracking
495
- records.
516
+ Returns the largest notification ID across all recorded tracking objects
517
+ for the named application, or None if no tracking objects have been recorded.
496
518
  """
497
519
 
498
520
  @abstractmethod
499
521
  def has_tracking_id(self, application_name: str, notification_id: int) -> bool:
500
522
  """
501
- Returns true if a tracking record with the given application name
502
- and notification ID exists, otherwise returns false.
523
+ Returns True if a tracking object with the given application name
524
+ and notification ID has been recorded, otherwise returns False.
503
525
  """
504
526
 
527
+ def wait(
528
+ self,
529
+ application_name: str,
530
+ notification_id: int,
531
+ timeout: float = 1.0,
532
+ interrupt: Event | None = None,
533
+ ) -> None:
534
+ """
535
+ Block until a tracking object with the given application name and
536
+ notification ID has been recorded.
537
+
538
+ Polls has_tracking_id() with exponential backoff until the timeout
539
+ is reached, or until the optional interrupt event is set.
540
+
541
+ The timeout argument should be a floating point number specifying a
542
+ timeout for the operation in seconds (or fractions thereof). The default
543
+ is 1.0 seconds.
544
+
545
+ Raises TimeoutError if the timeout is reached.
546
+
547
+ Raises WaitInterruptError if the `interrupt` is set before `timeout` is reached.
548
+ """
549
+ deadline = monotonic() + timeout
550
+ delay_ms = 1.0
551
+ while True:
552
+ if self.has_tracking_id(application_name, notification_id):
553
+ break
554
+ if interrupt:
555
+ if interrupt.wait(timeout=delay_ms / 1000):
556
+ raise WaitInterruptedError
557
+ else:
558
+ sleep(delay_ms / 1000)
559
+ delay_ms *= 2
560
+ remaining = deadline - monotonic()
561
+ if remaining < 0:
562
+ msg = (
563
+ f"Timed out waiting for notification {notification_id} "
564
+ f"from application '{application_name}' to be processed"
565
+ )
566
+ raise TimeoutError(msg)
567
+ delay_ms = min(delay_ms, remaining * 1000)
568
+
569
+
570
+ class ProcessRecorder(TrackingRecorder, ApplicationRecorder, ABC):
571
+ pass
572
+
505
573
 
506
574
  @dataclass(frozen=True)
507
575
  class Recording:
576
+ """Represents the recording of a domain event."""
577
+
508
578
  domain_event: DomainEventProtocol
579
+ """The domain event that has been recorded."""
509
580
  notification: Notification
581
+ """A Notification that represents the domain event in the application sequence."""
510
582
 
511
583
 
512
584
  class EventStore:
@@ -573,31 +645,39 @@ class EventStore:
573
645
 
574
646
 
575
647
  TInfrastructureFactory = TypeVar(
576
- "TInfrastructureFactory", bound="InfrastructureFactory"
648
+ "TInfrastructureFactory", bound="InfrastructureFactory[Any]"
577
649
  )
578
650
 
651
+ TTrackingRecorder = TypeVar("TTrackingRecorder", bound=TrackingRecorder)
652
+
579
653
 
580
- class InfrastructureFactory(ABC):
654
+ class InfrastructureFactory(ABC, Generic[TTrackingRecorder]):
581
655
  """
582
656
  Abstract base class for infrastructure factories.
583
657
  """
584
658
 
585
659
  PERSISTENCE_MODULE = "PERSISTENCE_MODULE"
660
+ TRANSCODER_TOPIC = "TRANSCODER_TOPIC"
586
661
  MAPPER_TOPIC = "MAPPER_TOPIC"
587
662
  CIPHER_TOPIC = "CIPHER_TOPIC"
588
663
  COMPRESSOR_TOPIC = "COMPRESSOR_TOPIC"
589
664
  IS_SNAPSHOTTING_ENABLED = "IS_SNAPSHOTTING_ENABLED"
665
+ APPLICATION_RECORDER_TOPIC = "APPLICATION_RECORDER_TOPIC"
666
+ TRACKING_RECORDER_TOPIC = "TRACKING_RECORDER_TOPIC"
667
+ PROCESS_RECORDER_TOPIC = "PROCESS_RECORDER_TOPIC"
590
668
 
591
669
  @classmethod
592
670
  def construct(
593
- cls: Type[TInfrastructureFactory], env: Environment
671
+ cls: Type[TInfrastructureFactory], env: Environment | None = None
594
672
  ) -> TInfrastructureFactory:
595
673
  """
596
674
  Constructs concrete infrastructure factory for given
597
675
  named application. Reads and resolves persistence
598
676
  topic from environment variable 'PERSISTENCE_MODULE'.
599
677
  """
600
- factory_cls: Type[InfrastructureFactory]
678
+ factory_cls: Type[InfrastructureFactory[TrackingRecorder]]
679
+ if env is None:
680
+ env = Environment()
601
681
  topic = (
602
682
  env.get(
603
683
  cls.PERSISTENCE_MODULE,
@@ -614,7 +694,9 @@ class InfrastructureFactory(ABC):
614
694
  or "eventsourcing.popo"
615
695
  )
616
696
  try:
617
- obj: Type[InfrastructureFactory] | ModuleType = resolve_topic(topic)
697
+ obj: Type[InfrastructureFactory[TrackingRecorder]] | ModuleType = (
698
+ resolve_topic(topic)
699
+ )
618
700
  except TopicError as e:
619
701
  msg = (
620
702
  "Failed to resolve persistence module topic: "
@@ -625,15 +707,15 @@ class InfrastructureFactory(ABC):
625
707
 
626
708
  if isinstance(obj, ModuleType):
627
709
  # Find the factory in the module.
628
- factory_classes: List[Type[InfrastructureFactory]] = [
629
- member
630
- for member in obj.__dict__.values()
710
+ factory_classes: List[Type[InfrastructureFactory[TrackingRecorder]]] = []
711
+ for member in obj.__dict__.values():
631
712
  if (
632
713
  member is not InfrastructureFactory
633
- and isinstance(member, type)
634
- and issubclass(member, InfrastructureFactory)
635
- )
636
- ]
714
+ and isinstance(member, type) # Look for classes...
715
+ and issubclass(member, InfrastructureFactory) # Ignore base class.
716
+ and member not in factory_classes # Forgive aliases.
717
+ ):
718
+ factory_classes.append(member)
637
719
  if len(factory_classes) == 1:
638
720
  factory_cls = factory_classes[0]
639
721
  else:
@@ -661,18 +743,27 @@ class InfrastructureFactory(ABC):
661
743
  """
662
744
  Constructs a transcoder.
663
745
  """
664
- # TODO: Implement support for TRANSCODER_TOPIC.
665
- return JSONTranscoder()
746
+ transcoder_topic = self.env.get(self.TRANSCODER_TOPIC)
747
+ if transcoder_topic:
748
+ transcoder_class: Type[Transcoder] = resolve_topic(transcoder_topic)
749
+ else:
750
+ transcoder_class = JSONTranscoder
751
+ return transcoder_class()
666
752
 
667
753
  def mapper(
668
- self, transcoder: Transcoder, mapper_class: Type[Mapper] = Mapper
754
+ self,
755
+ transcoder: Transcoder | None = None,
756
+ mapper_class: Type[Mapper] | None = None,
669
757
  ) -> Mapper:
670
758
  """
671
759
  Constructs a mapper.
672
760
  """
673
- # TODO: Implement support for MAPPER_TOPIC.
761
+ if mapper_class is None:
762
+ mapper_topic = self.env.get(self.MAPPER_TOPIC)
763
+ mapper_class = resolve_topic(mapper_topic) if mapper_topic else Mapper
764
+
674
765
  return mapper_class(
675
- transcoder=transcoder,
766
+ transcoder=transcoder or self.transcoder(),
676
767
  cipher=self.cipher(),
677
768
  compressor=self.compressor(),
678
769
  )
@@ -712,12 +803,18 @@ class InfrastructureFactory(ABC):
712
803
  compressor = compressor_cls
713
804
  return compressor
714
805
 
715
- @staticmethod
716
- def event_store(**kwargs: Any) -> EventStore:
806
+ def event_store(
807
+ self,
808
+ mapper: Mapper | None = None,
809
+ recorder: AggregateRecorder | None = None,
810
+ ) -> EventStore:
717
811
  """
718
812
  Constructs an event store.
719
813
  """
720
- return EventStore(**kwargs)
814
+ return EventStore(
815
+ mapper=mapper or self.mapper(),
816
+ recorder=recorder or self.application_recorder(),
817
+ )
721
818
 
722
819
  @abstractmethod
723
820
  def aggregate_recorder(self, purpose: str = "events") -> AggregateRecorder:
@@ -731,6 +828,14 @@ class InfrastructureFactory(ABC):
731
828
  Constructs an application recorder.
732
829
  """
733
830
 
831
+ @abstractmethod
832
+ def tracking_recorder(
833
+ self, tracking_recorder_class: Type[TTrackingRecorder] | None = None
834
+ ) -> TTrackingRecorder:
835
+ """
836
+ Constructs a tracking recorder.
837
+ """
838
+
734
839
  @abstractmethod
735
840
  def process_recorder(self) -> ProcessRecorder:
736
841
  """
@@ -747,7 +852,7 @@ class InfrastructureFactory(ABC):
747
852
 
748
853
  def close(self) -> None:
749
854
  """
750
- Closes any database connections, or anything else that needs closing.
855
+ Closes any database connections, and anything else that needs closing.
751
856
  """
752
857
 
753
858
 
@@ -1197,3 +1302,119 @@ class ConnectionPool(ABC, Generic[TConnection]):
1197
1302
 
1198
1303
  def __del__(self) -> None:
1199
1304
  self.close()
1305
+
1306
+
1307
+ TApplicationRecorder_co = TypeVar(
1308
+ "TApplicationRecorder_co", bound=ApplicationRecorder, covariant=True
1309
+ )
1310
+
1311
+
1312
+ class Subscription(Iterator[Notification], Generic[TApplicationRecorder_co]):
1313
+ def __init__(
1314
+ self, recorder: TApplicationRecorder_co, gt: int | None = None
1315
+ ) -> None:
1316
+ self._recorder = recorder
1317
+ self._last_notification_id = gt
1318
+ self._has_been_entered = False
1319
+ self._has_been_stopped = False
1320
+
1321
+ def __enter__(self) -> Self:
1322
+ if self._has_been_entered:
1323
+ msg = "Already entered subscription context manager"
1324
+ raise ProgrammingError(msg)
1325
+ self._has_been_entered = True
1326
+ return self
1327
+
1328
+ def __exit__(self, *args: object, **kwargs: Any) -> None:
1329
+ if not self._has_been_entered:
1330
+ msg = "Not already entered subscription context manager"
1331
+ raise ProgrammingError(msg)
1332
+ self.stop()
1333
+
1334
+ def stop(self) -> None:
1335
+ """
1336
+ Stops the subscription.
1337
+ """
1338
+ self._has_been_stopped = True
1339
+
1340
+ def __iter__(self) -> Self:
1341
+ return self
1342
+
1343
+ @abstractmethod
1344
+ def __next__(self) -> Notification:
1345
+ """
1346
+ Returns the next Notification object in the application sequence.
1347
+ """
1348
+
1349
+
1350
+ class ListenNotifySubscription(Subscription[TApplicationRecorder_co]):
1351
+ def __init__(
1352
+ self, recorder: TApplicationRecorder_co, gt: int | None = None
1353
+ ) -> None:
1354
+ super().__init__(recorder=recorder, gt=gt)
1355
+ self._select_limit = 500
1356
+ self._notifications: List[Notification] = []
1357
+ self._notifications_index: int = 0
1358
+ self._notifications_queue: Queue[List[Notification]] = Queue(maxsize=10)
1359
+ self._has_been_notified = Event()
1360
+ self._thread_error: BaseException | None = None
1361
+ self._pull_thread = Thread(target=self._loop_on_pull)
1362
+ self._pull_thread.start()
1363
+
1364
+ def __exit__(self, *args: object, **kwargs: Any) -> None:
1365
+ super().__exit__(*args, **kwargs)
1366
+ self._pull_thread.join()
1367
+
1368
+ def stop(self) -> None:
1369
+ """
1370
+ Stops the subscription.
1371
+ """
1372
+ super().stop()
1373
+ self._notifications_queue.put([])
1374
+ self._has_been_notified.set()
1375
+
1376
+ def __next__(self) -> Notification:
1377
+ # If necessary, get a new list of notifications from the recorder.
1378
+ if (
1379
+ self._notifications_index == len(self._notifications)
1380
+ and not self._has_been_stopped
1381
+ ):
1382
+ self._notifications = self._notifications_queue.get()
1383
+ self._notifications_index = 0
1384
+
1385
+ # Stop the iteration if necessary, maybe raise thread error.
1386
+ if self._has_been_stopped or not self._notifications:
1387
+ if self._thread_error is not None:
1388
+ raise self._thread_error
1389
+ raise StopIteration
1390
+
1391
+ # Return a notification from previously obtained list.
1392
+ notification = self._notifications[self._notifications_index]
1393
+ self._notifications_index += 1
1394
+ return notification
1395
+
1396
+ def _loop_on_pull(self) -> None:
1397
+ try:
1398
+ self._pull() # Already recorded events.
1399
+ while not self._has_been_stopped:
1400
+ self._has_been_notified.wait()
1401
+ self._pull() # Newly recorded events.
1402
+ except BaseException as e:
1403
+ if self._thread_error is None:
1404
+ self._thread_error = e
1405
+ self.stop()
1406
+
1407
+ def _pull(self) -> None:
1408
+ while not self._has_been_stopped:
1409
+ self._has_been_notified.clear()
1410
+ notifications = self._recorder.select_notifications(
1411
+ start=self._last_notification_id or 0,
1412
+ limit=self._select_limit,
1413
+ inclusive_of_start=False,
1414
+ )
1415
+ if len(notifications) > 0:
1416
+ # print("Putting", len(notifications), "notifications into queue")
1417
+ self._notifications_queue.put(notifications)
1418
+ self._last_notification_id = notifications[-1].id
1419
+ if len(notifications) < self._select_limit:
1420
+ break