eventsourcing 9.3.4__py3-none-any.whl → 9.4.0a1__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.
- eventsourcing/application.py +75 -26
- eventsourcing/cipher.py +1 -1
- eventsourcing/domain.py +29 -3
- eventsourcing/interface.py +23 -5
- eventsourcing/persistence.py +292 -71
- eventsourcing/popo.py +113 -32
- eventsourcing/postgres.py +265 -103
- eventsourcing/projection.py +157 -0
- eventsourcing/sqlite.py +143 -36
- eventsourcing/system.py +89 -44
- eventsourcing/tests/application.py +48 -12
- eventsourcing/tests/persistence.py +304 -75
- eventsourcing/utils.py +1 -1
- {eventsourcing-9.3.4.dist-info → eventsourcing-9.4.0a1.dist-info}/LICENSE +1 -1
- {eventsourcing-9.3.4.dist-info → eventsourcing-9.4.0a1.dist-info}/METADATA +2 -2
- eventsourcing-9.4.0a1.dist-info/RECORD +25 -0
- eventsourcing-9.3.4.dist-info/RECORD +0 -24
- {eventsourcing-9.3.4.dist-info → eventsourcing-9.4.0a1.dist-info}/AUTHORS +0 -0
- {eventsourcing-9.3.4.dist-info → eventsourcing-9.4.0a1.dist-info}/WHEEL +0 -0
eventsourcing/system.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import inspect
|
|
4
|
+
import threading
|
|
4
5
|
import traceback
|
|
5
6
|
from abc import ABC, abstractmethod
|
|
6
7
|
from collections import defaultdict
|
|
7
8
|
from queue import Full, Queue
|
|
8
|
-
from threading import Event, Lock, RLock, Thread
|
|
9
9
|
from types import FrameType, ModuleType
|
|
10
10
|
from typing import (
|
|
11
11
|
TYPE_CHECKING,
|
|
@@ -23,18 +23,17 @@ from typing import (
|
|
|
23
23
|
cast,
|
|
24
24
|
)
|
|
25
25
|
|
|
26
|
-
if TYPE_CHECKING: # pragma:
|
|
26
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
27
27
|
from typing_extensions import Self
|
|
28
28
|
|
|
29
29
|
from eventsourcing.application import (
|
|
30
30
|
Application,
|
|
31
31
|
NotificationLog,
|
|
32
32
|
ProcessingEvent,
|
|
33
|
-
RecordingEvent,
|
|
34
33
|
Section,
|
|
35
34
|
TApplication,
|
|
36
35
|
)
|
|
37
|
-
from eventsourcing.domain import DomainEventProtocol
|
|
36
|
+
from eventsourcing.domain import DomainEventProtocol, MutableOrImmutableAggregate
|
|
38
37
|
from eventsourcing.persistence import (
|
|
39
38
|
IntegrityError,
|
|
40
39
|
Mapper,
|
|
@@ -46,6 +45,20 @@ from eventsourcing.persistence import (
|
|
|
46
45
|
from eventsourcing.utils import EnvType, get_topic, resolve_topic
|
|
47
46
|
|
|
48
47
|
ProcessingJob = Tuple[DomainEventProtocol, Tracking]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RecordingEvent:
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
application_name: str,
|
|
54
|
+
recordings: List[Recording],
|
|
55
|
+
previous_max_notification_id: int | None,
|
|
56
|
+
):
|
|
57
|
+
self.application_name = application_name
|
|
58
|
+
self.recordings = recordings
|
|
59
|
+
self.previous_max_notification_id = previous_max_notification_id
|
|
60
|
+
|
|
61
|
+
|
|
49
62
|
ConvertingJob = Optional[Union[RecordingEvent, List[Notification]]]
|
|
50
63
|
|
|
51
64
|
|
|
@@ -66,7 +79,7 @@ class Follower(Application):
|
|
|
66
79
|
self.mappers: Dict[str, Mapper] = {}
|
|
67
80
|
self.recorder: ProcessRecorder
|
|
68
81
|
self.is_threading_enabled = False
|
|
69
|
-
self.processing_lock =
|
|
82
|
+
self.processing_lock = threading.Lock()
|
|
70
83
|
|
|
71
84
|
def construct_recorder(self) -> ProcessRecorder:
|
|
72
85
|
"""
|
|
@@ -99,9 +112,9 @@ class Follower(Application):
|
|
|
99
112
|
Pull and process new domain event notifications.
|
|
100
113
|
"""
|
|
101
114
|
if start is None:
|
|
102
|
-
start = self.recorder.max_tracking_id(leader_name)
|
|
115
|
+
start = self.recorder.max_tracking_id(leader_name)
|
|
103
116
|
for notifications in self.pull_notifications(
|
|
104
|
-
leader_name, start=start, stop=stop
|
|
117
|
+
leader_name, start=start, stop=stop, inclusive_of_start=False
|
|
105
118
|
):
|
|
106
119
|
notifications_iter = self.filter_received_notifications(notifications)
|
|
107
120
|
for domain_event, tracking in self.convert_notifications(
|
|
@@ -110,14 +123,22 @@ class Follower(Application):
|
|
|
110
123
|
self.process_event(domain_event, tracking)
|
|
111
124
|
|
|
112
125
|
def pull_notifications(
|
|
113
|
-
self,
|
|
126
|
+
self,
|
|
127
|
+
leader_name: str,
|
|
128
|
+
start: int | None,
|
|
129
|
+
stop: int | None = None,
|
|
130
|
+
*,
|
|
131
|
+
inclusive_of_start: bool = True,
|
|
114
132
|
) -> Iterator[List[Notification]]:
|
|
115
133
|
"""
|
|
116
134
|
Pulls batches of unseen :class:`~eventsourcing.persistence.Notification`
|
|
117
135
|
objects from the notification log reader of the named application.
|
|
118
136
|
"""
|
|
119
137
|
return self.readers[leader_name].select(
|
|
120
|
-
start=start,
|
|
138
|
+
start=start,
|
|
139
|
+
stop=stop,
|
|
140
|
+
topics=self.follow_topics,
|
|
141
|
+
inclusive_of_start=inclusive_of_start,
|
|
121
142
|
)
|
|
122
143
|
|
|
123
144
|
def filter_received_notifications(
|
|
@@ -230,6 +251,7 @@ class Leader(Application):
|
|
|
230
251
|
|
|
231
252
|
def __init__(self, env: EnvType | None = None) -> None:
|
|
232
253
|
super().__init__(env)
|
|
254
|
+
self.previous_max_notification_id: int | None = None
|
|
233
255
|
self.followers: List[RecordingEventReceiver] = []
|
|
234
256
|
|
|
235
257
|
def lead(self, follower: RecordingEventReceiver) -> None:
|
|
@@ -238,6 +260,15 @@ class Leader(Application):
|
|
|
238
260
|
"""
|
|
239
261
|
self.followers.append(follower)
|
|
240
262
|
|
|
263
|
+
def save(
|
|
264
|
+
self,
|
|
265
|
+
*objs: MutableOrImmutableAggregate | DomainEventProtocol | None,
|
|
266
|
+
**kwargs: Any,
|
|
267
|
+
) -> List[Recording]:
|
|
268
|
+
if self.previous_max_notification_id is None:
|
|
269
|
+
self.previous_max_notification_id = self.recorder.max_notification_id()
|
|
270
|
+
return super().save(*objs, **kwargs)
|
|
271
|
+
|
|
241
272
|
def _notify(self, recordings: List[Recording]) -> None:
|
|
242
273
|
"""
|
|
243
274
|
Calls :func:`receive_recording_event` on each follower
|
|
@@ -442,9 +473,9 @@ class SingleThreadedRunner(Runner, RecordingEventReceiver):
|
|
|
442
473
|
super().__init__(system=system, env=env)
|
|
443
474
|
self.apps: Dict[str, Application] = {}
|
|
444
475
|
self._recording_events_received: List[RecordingEvent] = []
|
|
445
|
-
self._prompted_names_lock = Lock()
|
|
476
|
+
self._prompted_names_lock = threading.Lock()
|
|
446
477
|
self._prompted_names: set[str] = set()
|
|
447
|
-
self._processing_lock = Lock()
|
|
478
|
+
self._processing_lock = threading.Lock()
|
|
448
479
|
|
|
449
480
|
# Construct followers.
|
|
450
481
|
for name in self.system.followers:
|
|
@@ -550,8 +581,8 @@ class NewSingleThreadedRunner(Runner, RecordingEventReceiver):
|
|
|
550
581
|
super().__init__(system=system, env=env)
|
|
551
582
|
self.apps: Dict[str, Application] = {}
|
|
552
583
|
self._recording_events_received: List[RecordingEvent] = []
|
|
553
|
-
self._recording_events_received_lock = Lock()
|
|
554
|
-
self._processing_lock = Lock()
|
|
584
|
+
self._recording_events_received_lock = threading.Lock()
|
|
585
|
+
self._processing_lock = threading.Lock()
|
|
555
586
|
self._previous_max_notification_ids: Dict[str, int] = {}
|
|
556
587
|
|
|
557
588
|
# Construct followers.
|
|
@@ -642,9 +673,7 @@ class NewSingleThreadedRunner(Runner, RecordingEventReceiver):
|
|
|
642
673
|
for follower_name in self.system.leads[leader_name]:
|
|
643
674
|
follower = self.apps[follower_name]
|
|
644
675
|
assert isinstance(follower, Follower)
|
|
645
|
-
start = (
|
|
646
|
-
follower.recorder.max_tracking_id(leader_name) + 1
|
|
647
|
-
)
|
|
676
|
+
start = follower.recorder.max_tracking_id(leader_name)
|
|
648
677
|
stop = recording_event.recordings[0].notification.id - 1
|
|
649
678
|
follower.pull_and_process(
|
|
650
679
|
leader_name=leader_name,
|
|
@@ -700,7 +729,7 @@ class MultiThreadedRunner(Runner):
|
|
|
700
729
|
super().__init__(system=system, env=env)
|
|
701
730
|
self.apps: Dict[str, Application] = {}
|
|
702
731
|
self.threads: Dict[str, MultiThreadedRunnerThread] = {}
|
|
703
|
-
self.has_errored = Event()
|
|
732
|
+
self.has_errored = threading.Event()
|
|
704
733
|
|
|
705
734
|
# Construct followers.
|
|
706
735
|
for follower_name in self.system.followers:
|
|
@@ -784,7 +813,7 @@ class MultiThreadedRunner(Runner):
|
|
|
784
813
|
return app
|
|
785
814
|
|
|
786
815
|
|
|
787
|
-
class MultiThreadedRunnerThread(RecordingEventReceiver, Thread):
|
|
816
|
+
class MultiThreadedRunnerThread(RecordingEventReceiver, threading.Thread):
|
|
788
817
|
"""
|
|
789
818
|
Runs one :class:`~eventsourcing.system.Follower` application in
|
|
790
819
|
a :class:`~eventsourcing.system.MultiThreadedRunner`.
|
|
@@ -793,18 +822,18 @@ class MultiThreadedRunnerThread(RecordingEventReceiver, Thread):
|
|
|
793
822
|
def __init__(
|
|
794
823
|
self,
|
|
795
824
|
follower: Follower,
|
|
796
|
-
has_errored: Event,
|
|
825
|
+
has_errored: threading.Event,
|
|
797
826
|
):
|
|
798
827
|
super().__init__(daemon=True)
|
|
799
828
|
self.follower = follower
|
|
800
829
|
self.has_errored = has_errored
|
|
801
830
|
self.error: Exception | None = None
|
|
802
|
-
self.is_stopping = Event()
|
|
803
|
-
self.has_started = Event()
|
|
804
|
-
self.is_prompted = Event()
|
|
831
|
+
self.is_stopping = threading.Event()
|
|
832
|
+
self.has_started = threading.Event()
|
|
833
|
+
self.is_prompted = threading.Event()
|
|
805
834
|
self.prompted_names: List[str] = []
|
|
806
|
-
self.prompted_names_lock = Lock()
|
|
807
|
-
self.is_running = Event()
|
|
835
|
+
self.prompted_names_lock = threading.Lock()
|
|
836
|
+
self.is_running = threading.Event()
|
|
808
837
|
|
|
809
838
|
def run(self) -> None:
|
|
810
839
|
"""
|
|
@@ -866,7 +895,7 @@ class NewMultiThreadedRunner(Runner, RecordingEventReceiver):
|
|
|
866
895
|
self.pulling_threads: Dict[str, List[PullingThread]] = {}
|
|
867
896
|
self.processing_queues: Dict[str, Queue[List[ProcessingJob] | None]] = {}
|
|
868
897
|
self.all_threads: List[PullingThread | ConvertingThread | ProcessingThread] = []
|
|
869
|
-
self.has_errored = Event()
|
|
898
|
+
self.has_errored = threading.Event()
|
|
870
899
|
|
|
871
900
|
# Construct followers.
|
|
872
901
|
for follower_name in self.system.followers:
|
|
@@ -991,7 +1020,7 @@ class NewMultiThreadedRunner(Runner, RecordingEventReceiver):
|
|
|
991
1020
|
pulling_thread.receive_recording_event(recording_event)
|
|
992
1021
|
|
|
993
1022
|
|
|
994
|
-
class PullingThread(Thread):
|
|
1023
|
+
class PullingThread(threading.Thread):
|
|
995
1024
|
"""
|
|
996
1025
|
Receives or pulls notifications from the given leader, and
|
|
997
1026
|
puts them on a queue for conversion into processing jobs.
|
|
@@ -1002,19 +1031,19 @@ class PullingThread(Thread):
|
|
|
1002
1031
|
converting_queue: Queue[ConvertingJob],
|
|
1003
1032
|
follower: Follower,
|
|
1004
1033
|
leader_name: str,
|
|
1005
|
-
has_errored: Event,
|
|
1034
|
+
has_errored: threading.Event,
|
|
1006
1035
|
):
|
|
1007
1036
|
super().__init__(daemon=True)
|
|
1008
|
-
self.overflow_event = Event()
|
|
1037
|
+
self.overflow_event = threading.Event()
|
|
1009
1038
|
self.recording_event_queue: Queue[RecordingEvent | None] = Queue(maxsize=100)
|
|
1010
1039
|
self.converting_queue = converting_queue
|
|
1011
|
-
self.receive_lock = Lock()
|
|
1040
|
+
self.receive_lock = threading.Lock()
|
|
1012
1041
|
self.follower = follower
|
|
1013
1042
|
self.leader_name = leader_name
|
|
1014
1043
|
self.error: Exception | None = None
|
|
1015
1044
|
self.has_errored = has_errored
|
|
1016
|
-
self.is_stopping = Event()
|
|
1017
|
-
self.has_started = Event()
|
|
1045
|
+
self.is_stopping = threading.Event()
|
|
1046
|
+
self.has_started = threading.Event()
|
|
1018
1047
|
self.mapper = self.follower.mappers[self.leader_name]
|
|
1019
1048
|
self.previous_max_notification_id = self.follower.recorder.max_tracking_id(
|
|
1020
1049
|
application_name=self.leader_name
|
|
@@ -1031,6 +1060,7 @@ class PullingThread(Thread):
|
|
|
1031
1060
|
# Ignore recording event if already seen a subsequent.
|
|
1032
1061
|
if (
|
|
1033
1062
|
recording_event.previous_max_notification_id is not None
|
|
1063
|
+
and self.previous_max_notification_id is not None
|
|
1034
1064
|
and recording_event.previous_max_notification_id
|
|
1035
1065
|
< self.previous_max_notification_id
|
|
1036
1066
|
):
|
|
@@ -1039,13 +1069,17 @@ class PullingThread(Thread):
|
|
|
1039
1069
|
# Catch up if there is a gap in sequence of recording events.
|
|
1040
1070
|
if (
|
|
1041
1071
|
recording_event.previous_max_notification_id is None
|
|
1072
|
+
or self.previous_max_notification_id is None
|
|
1042
1073
|
or recording_event.previous_max_notification_id
|
|
1043
1074
|
> self.previous_max_notification_id
|
|
1044
1075
|
):
|
|
1045
|
-
start = self.previous_max_notification_id
|
|
1076
|
+
start = self.previous_max_notification_id
|
|
1046
1077
|
stop = recording_event.recordings[0].notification.id - 1
|
|
1047
1078
|
for notifications in self.follower.pull_notifications(
|
|
1048
|
-
self.leader_name,
|
|
1079
|
+
self.leader_name,
|
|
1080
|
+
start=start,
|
|
1081
|
+
stop=stop,
|
|
1082
|
+
inclusive_of_start=False,
|
|
1049
1083
|
):
|
|
1050
1084
|
self.converting_queue.put(notifications)
|
|
1051
1085
|
self.previous_max_notification_id = notifications[-1].id
|
|
@@ -1069,7 +1103,7 @@ class PullingThread(Thread):
|
|
|
1069
1103
|
self.recording_event_queue.put(None)
|
|
1070
1104
|
|
|
1071
1105
|
|
|
1072
|
-
class ConvertingThread(Thread):
|
|
1106
|
+
class ConvertingThread(threading.Thread):
|
|
1073
1107
|
"""
|
|
1074
1108
|
Converts notifications into processing jobs.
|
|
1075
1109
|
"""
|
|
@@ -1080,7 +1114,7 @@ class ConvertingThread(Thread):
|
|
|
1080
1114
|
processing_queue: Queue[List[ProcessingJob] | None],
|
|
1081
1115
|
follower: Follower,
|
|
1082
1116
|
leader_name: str,
|
|
1083
|
-
has_errored: Event,
|
|
1117
|
+
has_errored: threading.Event,
|
|
1084
1118
|
):
|
|
1085
1119
|
super().__init__(daemon=True)
|
|
1086
1120
|
self.converting_queue = converting_queue
|
|
@@ -1089,8 +1123,8 @@ class ConvertingThread(Thread):
|
|
|
1089
1123
|
self.leader_name = leader_name
|
|
1090
1124
|
self.error: Exception | None = None
|
|
1091
1125
|
self.has_errored = has_errored
|
|
1092
|
-
self.is_stopping = Event()
|
|
1093
|
-
self.has_started = Event()
|
|
1126
|
+
self.is_stopping = threading.Event()
|
|
1127
|
+
self.has_started = threading.Event()
|
|
1094
1128
|
self.mapper = self.follower.mappers[self.leader_name]
|
|
1095
1129
|
|
|
1096
1130
|
def run(self) -> None:
|
|
@@ -1139,7 +1173,7 @@ class ConvertingThread(Thread):
|
|
|
1139
1173
|
self.converting_queue.put(None)
|
|
1140
1174
|
|
|
1141
1175
|
|
|
1142
|
-
class ProcessingThread(Thread):
|
|
1176
|
+
class ProcessingThread(threading.Thread):
|
|
1143
1177
|
"""
|
|
1144
1178
|
A processing thread gets events from a processing queue, and
|
|
1145
1179
|
calls the application's process_event() method.
|
|
@@ -1149,15 +1183,15 @@ class ProcessingThread(Thread):
|
|
|
1149
1183
|
self,
|
|
1150
1184
|
processing_queue: Queue[List[ProcessingJob] | None],
|
|
1151
1185
|
follower: Follower,
|
|
1152
|
-
has_errored: Event,
|
|
1186
|
+
has_errored: threading.Event,
|
|
1153
1187
|
):
|
|
1154
1188
|
super().__init__(daemon=True)
|
|
1155
1189
|
self.processing_queue = processing_queue
|
|
1156
1190
|
self.follower = follower
|
|
1157
1191
|
self.error: Exception | None = None
|
|
1158
1192
|
self.has_errored = has_errored
|
|
1159
|
-
self.is_stopping = Event()
|
|
1160
|
-
self.has_started = Event()
|
|
1193
|
+
self.is_stopping = threading.Event()
|
|
1194
|
+
self.has_started = threading.Event()
|
|
1161
1195
|
|
|
1162
1196
|
def run(self) -> None:
|
|
1163
1197
|
self.has_started.set()
|
|
@@ -1224,7 +1258,12 @@ class NotificationLogReader:
|
|
|
1224
1258
|
section_id = section.next_id
|
|
1225
1259
|
|
|
1226
1260
|
def select(
|
|
1227
|
-
self,
|
|
1261
|
+
self,
|
|
1262
|
+
*,
|
|
1263
|
+
start: int | None,
|
|
1264
|
+
stop: int | None = None,
|
|
1265
|
+
topics: Sequence[str] = (),
|
|
1266
|
+
inclusive_of_start: bool = True,
|
|
1228
1267
|
) -> Iterator[List[Notification]]:
|
|
1229
1268
|
"""
|
|
1230
1269
|
Returns a generator that yields lists of event notifications
|
|
@@ -1240,12 +1279,18 @@ class NotificationLogReader:
|
|
|
1240
1279
|
"""
|
|
1241
1280
|
while True:
|
|
1242
1281
|
notifications = self.notification_log.select(
|
|
1243
|
-
start=start,
|
|
1282
|
+
start=start,
|
|
1283
|
+
stop=stop,
|
|
1284
|
+
limit=self.section_size,
|
|
1285
|
+
topics=topics,
|
|
1286
|
+
inclusive_of_start=inclusive_of_start,
|
|
1244
1287
|
)
|
|
1245
1288
|
# Stop if zero notifications.
|
|
1246
1289
|
if len(notifications) == 0:
|
|
1247
1290
|
break
|
|
1248
1291
|
|
|
1249
1292
|
# Otherwise, yield and continue.
|
|
1293
|
+
start = notifications[-1].id
|
|
1294
|
+
if inclusive_of_start:
|
|
1295
|
+
start += 1
|
|
1250
1296
|
yield notifications
|
|
1251
|
-
start = notifications[-1].id + 1
|
|
@@ -19,7 +19,8 @@ from eventsourcing.domain import Aggregate
|
|
|
19
19
|
from eventsourcing.persistence import (
|
|
20
20
|
InfrastructureFactory,
|
|
21
21
|
IntegrityError,
|
|
22
|
-
|
|
22
|
+
JSONTranscoder,
|
|
23
|
+
Tracking,
|
|
23
24
|
Transcoding,
|
|
24
25
|
)
|
|
25
26
|
from eventsourcing.tests.domain import BankAccount, EmailAddress
|
|
@@ -36,7 +37,6 @@ class ExampleApplicationTestCase(TestCase):
|
|
|
36
37
|
|
|
37
38
|
def test_example_application(self):
|
|
38
39
|
app = BankAccounts(env={"IS_SNAPSHOTTING_ENABLED": "y"})
|
|
39
|
-
max_notification_id = app.recorder.max_notification_id()
|
|
40
40
|
|
|
41
41
|
self.assertEqual(get_topic(type(app.factory)), self.expected_factory_topic)
|
|
42
42
|
|
|
@@ -75,9 +75,7 @@ class ExampleApplicationTestCase(TestCase):
|
|
|
75
75
|
)
|
|
76
76
|
|
|
77
77
|
sleep(1) # Added to make eventsourcing-axon tests work, perhaps not necessary.
|
|
78
|
-
section = app.notification_log[
|
|
79
|
-
f"{max_notification_id + 1},{max_notification_id + 10}"
|
|
80
|
-
]
|
|
78
|
+
section = app.notification_log["1,10"]
|
|
81
79
|
self.assertEqual(len(section.items), 4)
|
|
82
80
|
|
|
83
81
|
# Take snapshot (specify version).
|
|
@@ -198,7 +196,7 @@ class EmailAddressAsStr(Transcoding):
|
|
|
198
196
|
class BankAccounts(Application):
|
|
199
197
|
is_snapshotting_enabled = True
|
|
200
198
|
|
|
201
|
-
def register_transcodings(self, transcoder:
|
|
199
|
+
def register_transcodings(self, transcoder: JSONTranscoder) -> None:
|
|
202
200
|
super().register_transcodings(transcoder)
|
|
203
201
|
transcoder.register(EmailAddressAsStr())
|
|
204
202
|
|
|
@@ -291,20 +289,18 @@ class ApplicationTestCase(TestCase):
|
|
|
291
289
|
recordings = app.save(None)
|
|
292
290
|
self.assertEqual(recordings, [])
|
|
293
291
|
|
|
294
|
-
max_id = app.recorder.max_notification_id()
|
|
295
|
-
|
|
296
292
|
recordings = app.save(Aggregate())
|
|
297
293
|
self.assertEqual(len(recordings), 1)
|
|
298
|
-
self.assertEqual(recordings[0].notification.id, 1
|
|
294
|
+
self.assertEqual(recordings[0].notification.id, 1)
|
|
299
295
|
|
|
300
296
|
recordings = app.save(Aggregate())
|
|
301
297
|
self.assertEqual(len(recordings), 1)
|
|
302
|
-
self.assertEqual(recordings[0].notification.id, 2
|
|
298
|
+
self.assertEqual(recordings[0].notification.id, 2)
|
|
303
299
|
|
|
304
300
|
recordings = app.save(Aggregate(), Aggregate())
|
|
305
301
|
self.assertEqual(len(recordings), 2)
|
|
306
|
-
self.assertEqual(recordings[0].notification.id, 3
|
|
307
|
-
self.assertEqual(recordings[1].notification.id, 4
|
|
302
|
+
self.assertEqual(recordings[0].notification.id, 3)
|
|
303
|
+
self.assertEqual(recordings[1].notification.id, 4)
|
|
308
304
|
|
|
309
305
|
def test_take_snapshot_raises_assertion_error_if_snapshotting_not_enabled(self):
|
|
310
306
|
app = Application()
|
|
@@ -494,3 +490,43 @@ class ApplicationTestCase(TestCase):
|
|
|
494
490
|
self.assertEqual(
|
|
495
491
|
"'log' is deprecated, use 'notifications' instead", w[-1].message.args[0]
|
|
496
492
|
)
|
|
493
|
+
|
|
494
|
+
def test_catchup_subscription(self):
|
|
495
|
+
app = Application()
|
|
496
|
+
|
|
497
|
+
max_notification_id = app.recorder.max_notification_id()
|
|
498
|
+
|
|
499
|
+
aggregate = Aggregate()
|
|
500
|
+
aggregate.trigger_event(Aggregate.Event)
|
|
501
|
+
aggregate.trigger_event(Aggregate.Event)
|
|
502
|
+
aggregate.trigger_event(Aggregate.Event)
|
|
503
|
+
app.save(aggregate)
|
|
504
|
+
|
|
505
|
+
subscription = app.subscribe(gt=max_notification_id)
|
|
506
|
+
|
|
507
|
+
# Catch up.
|
|
508
|
+
for domain_event, tracking in subscription:
|
|
509
|
+
self.assertIsInstance(domain_event, Aggregate.Event)
|
|
510
|
+
self.assertIsInstance(tracking, Tracking)
|
|
511
|
+
self.assertEqual(tracking.application_name, app.name)
|
|
512
|
+
if max_notification_id is not None:
|
|
513
|
+
self.assertGreater(tracking.notification_id, max_notification_id)
|
|
514
|
+
if tracking.notification_id == app.recorder.max_notification_id():
|
|
515
|
+
break
|
|
516
|
+
|
|
517
|
+
max_notification_id = app.recorder.max_notification_id()
|
|
518
|
+
|
|
519
|
+
aggregate.trigger_event(Aggregate.Event)
|
|
520
|
+
aggregate.trigger_event(Aggregate.Event)
|
|
521
|
+
aggregate.trigger_event(Aggregate.Event)
|
|
522
|
+
app.save(aggregate)
|
|
523
|
+
|
|
524
|
+
# Continue.
|
|
525
|
+
for domain_event, tracking in subscription:
|
|
526
|
+
self.assertIsInstance(domain_event, Aggregate.Event)
|
|
527
|
+
self.assertIsInstance(tracking, Tracking)
|
|
528
|
+
self.assertEqual(tracking.application_name, app.name)
|
|
529
|
+
if max_notification_id is not None:
|
|
530
|
+
self.assertGreater(tracking.notification_id, max_notification_id)
|
|
531
|
+
if tracking.notification_id == app.recorder.max_notification_id():
|
|
532
|
+
break
|