eventsourcing 9.2.21__py3-none-any.whl → 9.3.0__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.

Files changed (145) hide show
  1. eventsourcing/__init__.py +1 -1
  2. eventsourcing/application.py +137 -132
  3. eventsourcing/cipher.py +17 -12
  4. eventsourcing/compressor.py +2 -0
  5. eventsourcing/dispatch.py +30 -56
  6. eventsourcing/domain.py +221 -227
  7. eventsourcing/examples/__init__.py +0 -0
  8. eventsourcing/examples/aggregate1/__init__.py +0 -0
  9. eventsourcing/examples/aggregate1/application.py +27 -0
  10. eventsourcing/examples/aggregate1/domainmodel.py +16 -0
  11. eventsourcing/examples/aggregate1/test_application.py +37 -0
  12. eventsourcing/examples/aggregate2/__init__.py +0 -0
  13. eventsourcing/examples/aggregate2/application.py +27 -0
  14. eventsourcing/examples/aggregate2/domainmodel.py +22 -0
  15. eventsourcing/examples/aggregate2/test_application.py +37 -0
  16. eventsourcing/examples/aggregate3/__init__.py +0 -0
  17. eventsourcing/examples/aggregate3/application.py +27 -0
  18. eventsourcing/examples/aggregate3/domainmodel.py +38 -0
  19. eventsourcing/examples/aggregate3/test_application.py +37 -0
  20. eventsourcing/examples/aggregate4/__init__.py +0 -0
  21. eventsourcing/examples/aggregate4/application.py +27 -0
  22. eventsourcing/examples/aggregate4/domainmodel.py +114 -0
  23. eventsourcing/examples/aggregate4/test_application.py +38 -0
  24. eventsourcing/examples/aggregate5/__init__.py +0 -0
  25. eventsourcing/examples/aggregate5/application.py +27 -0
  26. eventsourcing/examples/aggregate5/domainmodel.py +131 -0
  27. eventsourcing/examples/aggregate5/test_application.py +38 -0
  28. eventsourcing/examples/aggregate6/__init__.py +0 -0
  29. eventsourcing/examples/aggregate6/application.py +30 -0
  30. eventsourcing/examples/aggregate6/domainmodel.py +123 -0
  31. eventsourcing/examples/aggregate6/test_application.py +38 -0
  32. eventsourcing/examples/aggregate6a/__init__.py +0 -0
  33. eventsourcing/examples/aggregate6a/application.py +40 -0
  34. eventsourcing/examples/aggregate6a/domainmodel.py +149 -0
  35. eventsourcing/examples/aggregate6a/test_application.py +45 -0
  36. eventsourcing/examples/aggregate7/__init__.py +0 -0
  37. eventsourcing/examples/aggregate7/application.py +48 -0
  38. eventsourcing/examples/aggregate7/domainmodel.py +144 -0
  39. eventsourcing/examples/aggregate7/persistence.py +57 -0
  40. eventsourcing/examples/aggregate7/test_application.py +38 -0
  41. eventsourcing/examples/aggregate7/test_compression_and_encryption.py +45 -0
  42. eventsourcing/examples/aggregate7/test_snapshotting_intervals.py +67 -0
  43. eventsourcing/examples/aggregate7a/__init__.py +0 -0
  44. eventsourcing/examples/aggregate7a/application.py +56 -0
  45. eventsourcing/examples/aggregate7a/domainmodel.py +170 -0
  46. eventsourcing/examples/aggregate7a/test_application.py +46 -0
  47. eventsourcing/examples/aggregate7a/test_compression_and_encryption.py +45 -0
  48. eventsourcing/examples/aggregate8/__init__.py +0 -0
  49. eventsourcing/examples/aggregate8/application.py +47 -0
  50. eventsourcing/examples/aggregate8/domainmodel.py +65 -0
  51. eventsourcing/examples/aggregate8/persistence.py +57 -0
  52. eventsourcing/examples/aggregate8/test_application.py +37 -0
  53. eventsourcing/examples/aggregate8/test_compression_and_encryption.py +44 -0
  54. eventsourcing/examples/aggregate8/test_snapshotting_intervals.py +38 -0
  55. eventsourcing/examples/bankaccounts/__init__.py +0 -0
  56. eventsourcing/examples/bankaccounts/application.py +70 -0
  57. eventsourcing/examples/bankaccounts/domainmodel.py +56 -0
  58. eventsourcing/examples/bankaccounts/test.py +173 -0
  59. eventsourcing/examples/cargoshipping/__init__.py +0 -0
  60. eventsourcing/examples/cargoshipping/application.py +126 -0
  61. eventsourcing/examples/cargoshipping/domainmodel.py +330 -0
  62. eventsourcing/examples/cargoshipping/interface.py +143 -0
  63. eventsourcing/examples/cargoshipping/test.py +231 -0
  64. eventsourcing/examples/contentmanagement/__init__.py +0 -0
  65. eventsourcing/examples/contentmanagement/application.py +118 -0
  66. eventsourcing/examples/contentmanagement/domainmodel.py +69 -0
  67. eventsourcing/examples/contentmanagement/test.py +180 -0
  68. eventsourcing/examples/contentmanagement/utils.py +26 -0
  69. eventsourcing/examples/contentmanagementsystem/__init__.py +0 -0
  70. eventsourcing/examples/contentmanagementsystem/application.py +54 -0
  71. eventsourcing/examples/contentmanagementsystem/postgres.py +17 -0
  72. eventsourcing/examples/contentmanagementsystem/sqlite.py +17 -0
  73. eventsourcing/examples/contentmanagementsystem/system.py +14 -0
  74. eventsourcing/examples/contentmanagementsystem/test_system.py +180 -0
  75. eventsourcing/examples/searchablecontent/__init__.py +0 -0
  76. eventsourcing/examples/searchablecontent/application.py +45 -0
  77. eventsourcing/examples/searchablecontent/persistence.py +23 -0
  78. eventsourcing/examples/searchablecontent/postgres.py +118 -0
  79. eventsourcing/examples/searchablecontent/sqlite.py +136 -0
  80. eventsourcing/examples/searchablecontent/test_application.py +110 -0
  81. eventsourcing/examples/searchablecontent/test_recorder.py +68 -0
  82. eventsourcing/examples/searchabletimestamps/__init__.py +0 -0
  83. eventsourcing/examples/searchabletimestamps/application.py +32 -0
  84. eventsourcing/examples/searchabletimestamps/persistence.py +20 -0
  85. eventsourcing/examples/searchabletimestamps/postgres.py +110 -0
  86. eventsourcing/examples/searchabletimestamps/sqlite.py +99 -0
  87. eventsourcing/examples/searchabletimestamps/test_searchabletimestamps.py +94 -0
  88. eventsourcing/examples/test_invoice.py +176 -0
  89. eventsourcing/examples/test_parking_lot.py +206 -0
  90. eventsourcing/interface.py +4 -2
  91. eventsourcing/persistence.py +88 -82
  92. eventsourcing/popo.py +32 -31
  93. eventsourcing/postgres.py +388 -593
  94. eventsourcing/sqlite.py +100 -102
  95. eventsourcing/system.py +66 -71
  96. eventsourcing/tests/application.py +20 -32
  97. eventsourcing/tests/application_tests/__init__.py +0 -0
  98. eventsourcing/tests/application_tests/test_application_with_automatic_snapshotting.py +55 -0
  99. eventsourcing/tests/application_tests/test_application_with_popo.py +22 -0
  100. eventsourcing/tests/application_tests/test_application_with_postgres.py +75 -0
  101. eventsourcing/tests/application_tests/test_application_with_sqlite.py +72 -0
  102. eventsourcing/tests/application_tests/test_cache.py +134 -0
  103. eventsourcing/tests/application_tests/test_event_sourced_log.py +162 -0
  104. eventsourcing/tests/application_tests/test_notificationlog.py +232 -0
  105. eventsourcing/tests/application_tests/test_notificationlogreader.py +126 -0
  106. eventsourcing/tests/application_tests/test_processapplication.py +110 -0
  107. eventsourcing/tests/application_tests/test_processingpolicy.py +109 -0
  108. eventsourcing/tests/application_tests/test_repository.py +504 -0
  109. eventsourcing/tests/application_tests/test_snapshotting.py +68 -0
  110. eventsourcing/tests/application_tests/test_upcasting.py +459 -0
  111. eventsourcing/tests/docs_tests/__init__.py +0 -0
  112. eventsourcing/tests/docs_tests/test_docs.py +293 -0
  113. eventsourcing/tests/domain.py +1 -1
  114. eventsourcing/tests/domain_tests/__init__.py +0 -0
  115. eventsourcing/tests/domain_tests/test_aggregate.py +1180 -0
  116. eventsourcing/tests/domain_tests/test_aggregate_decorators.py +1604 -0
  117. eventsourcing/tests/domain_tests/test_domainevent.py +80 -0
  118. eventsourcing/tests/interface_tests/__init__.py +0 -0
  119. eventsourcing/tests/interface_tests/test_remotenotificationlog.py +258 -0
  120. eventsourcing/tests/persistence.py +52 -50
  121. eventsourcing/tests/persistence_tests/__init__.py +0 -0
  122. eventsourcing/tests/persistence_tests/test_aes.py +93 -0
  123. eventsourcing/tests/persistence_tests/test_connection_pool.py +722 -0
  124. eventsourcing/tests/persistence_tests/test_eventstore.py +72 -0
  125. eventsourcing/tests/persistence_tests/test_infrastructure_factory.py +21 -0
  126. eventsourcing/tests/persistence_tests/test_mapper.py +113 -0
  127. eventsourcing/tests/persistence_tests/test_noninterleaving_notification_ids.py +69 -0
  128. eventsourcing/tests/persistence_tests/test_popo.py +124 -0
  129. eventsourcing/tests/persistence_tests/test_postgres.py +1119 -0
  130. eventsourcing/tests/persistence_tests/test_sqlite.py +348 -0
  131. eventsourcing/tests/persistence_tests/test_transcoder.py +44 -0
  132. eventsourcing/tests/postgres_utils.py +7 -7
  133. eventsourcing/tests/system_tests/__init__.py +0 -0
  134. eventsourcing/tests/system_tests/test_runner.py +935 -0
  135. eventsourcing/tests/system_tests/test_system.py +284 -0
  136. eventsourcing/tests/utils_tests/__init__.py +0 -0
  137. eventsourcing/tests/utils_tests/test_utils.py +226 -0
  138. eventsourcing/utils.py +49 -50
  139. {eventsourcing-9.2.21.dist-info → eventsourcing-9.3.0.dist-info}/METADATA +30 -33
  140. eventsourcing-9.3.0.dist-info/RECORD +145 -0
  141. {eventsourcing-9.2.21.dist-info → eventsourcing-9.3.0.dist-info}/WHEEL +1 -2
  142. eventsourcing-9.2.21.dist-info/RECORD +0 -25
  143. eventsourcing-9.2.21.dist-info/top_level.txt +0 -1
  144. {eventsourcing-9.2.21.dist-info → eventsourcing-9.3.0.dist-info}/AUTHORS +0 -0
  145. {eventsourcing-9.2.21.dist-info → eventsourcing-9.3.0.dist-info}/LICENSE +0 -0
@@ -0,0 +1,935 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shlex
5
+ import subprocess
6
+ from queue import Queue
7
+ from threading import Event
8
+ from time import sleep
9
+ from typing import (
10
+ TYPE_CHECKING,
11
+ ClassVar,
12
+ Generic,
13
+ Iterable,
14
+ Iterator,
15
+ List,
16
+ Sequence,
17
+ Tuple,
18
+ Type,
19
+ TypeVar,
20
+ )
21
+ from unittest.case import TestCase
22
+ from unittest.mock import MagicMock
23
+
24
+ from eventsourcing.application import ProcessingEvent, RecordingEvent
25
+ from eventsourcing.domain import Aggregate, AggregateEvent, event
26
+ from eventsourcing.persistence import Notification, ProgrammingError
27
+ from eventsourcing.postgres import PostgresDatastore
28
+ from eventsourcing.system import (
29
+ ConvertingThread,
30
+ EventProcessingError,
31
+ MultiThreadedRunner,
32
+ NewMultiThreadedRunner,
33
+ NewSingleThreadedRunner,
34
+ NotificationConvertingError,
35
+ NotificationPullingError,
36
+ ProcessApplication,
37
+ ProcessingJob,
38
+ PullingThread,
39
+ Runner,
40
+ RunnerAlreadyStartedError,
41
+ SingleThreadedRunner,
42
+ System,
43
+ )
44
+ from eventsourcing.tests.application import BankAccounts
45
+ from eventsourcing.tests.application_tests.test_processapplication import EmailProcess
46
+ from eventsourcing.tests.persistence import tmpfile_uris
47
+ from eventsourcing.tests.postgres_utils import drop_postgres_table
48
+ from eventsourcing.utils import clear_topic_cache, get_topic
49
+
50
+ if TYPE_CHECKING: # pragma: nocover
51
+ from uuid import UUID
52
+
53
+
54
+ class EmailProcess2(EmailProcess):
55
+ pass
56
+
57
+
58
+ TRunner = TypeVar("TRunner", bound=Runner)
59
+
60
+
61
+ class RunnerTestCase(TestCase, Generic[TRunner]):
62
+ runner_class: Type[TRunner]
63
+ runner: TRunner | None
64
+
65
+ def setUp(self) -> None:
66
+ self.runner: TRunner | None = None
67
+
68
+ def tearDown(self) -> None:
69
+ if self.runner:
70
+ try:
71
+ self.runner.stop()
72
+ except Exception as e:
73
+ raise Exception("Runner errored: " + str(e)) from e
74
+
75
+ def start_runner(self, system):
76
+ self.runner = self.runner_class(system)
77
+ self.runner.start()
78
+
79
+ def test_runner_constructed_with_env_has_apps_with_env(self):
80
+ system = System(pipes=[[BankAccounts, EmailProcess]])
81
+ env = {"MY_ENV_VAR": "my_env_val"}
82
+ self.runner = self.runner_class(system, env)
83
+ self.runner.start()
84
+
85
+ # Check leaders get the environment.
86
+ app = self.runner.get(BankAccounts)
87
+ self.assertEqual(app.env.get("MY_ENV_VAR"), "my_env_val")
88
+
89
+ # Check followers get the environment.
90
+ app = self.runner.get(EmailProcess)
91
+ self.assertEqual(app.env.get("MY_ENV_VAR"), "my_env_val")
92
+
93
+ # Stop the runner before we start another.
94
+ self.runner.stop()
95
+
96
+ # Check singles get the environment.
97
+ system = System(pipes=[[BankAccounts]])
98
+ env = {"MY_ENV_VAR": "my_env_val"}
99
+ self.runner = self.runner_class(system, env)
100
+ self.runner.start()
101
+ app = self.runner.get(BankAccounts)
102
+ self.assertEqual(app.env.get("MY_ENV_VAR"), "my_env_val")
103
+
104
+ def test_starts_with_single_app(self):
105
+ self.start_runner(System(pipes=[[BankAccounts]]))
106
+ app = self.runner.get(BankAccounts)
107
+ self.assertIsInstance(app, BankAccounts)
108
+
109
+ def test_calling_start_twice_raises_error(self):
110
+ self.start_runner(System(pipes=[[BankAccounts]]))
111
+ with self.assertRaises(RunnerAlreadyStartedError):
112
+ self.runner.start()
113
+
114
+ def test_system_with_one_edge(self):
115
+ self.start_runner(System(pipes=[[BankAccounts, EmailProcess]]))
116
+ accounts = self.runner.get(BankAccounts)
117
+ email_process = self.runner.get(EmailProcess)
118
+
119
+ section = email_process.notification_log["1,5"]
120
+ self.assertEqual(len(section.items), 0, section.items)
121
+
122
+ for _ in range(10):
123
+ accounts.open_account(
124
+ full_name="Alice",
125
+ email_address="alice@example.com",
126
+ )
127
+
128
+ self.wait_for_runner()
129
+
130
+ section = email_process.notification_log["1,10"]
131
+ self.assertEqual(len(section.items), 10)
132
+
133
+ def test_system_with_two_edges(self):
134
+ clear_topic_cache()
135
+
136
+ # Construct system and runner.
137
+ system = System(
138
+ pipes=[
139
+ [
140
+ BankAccounts,
141
+ EmailProcess,
142
+ ],
143
+ [
144
+ BankAccounts,
145
+ EmailProcess2,
146
+ ],
147
+ ]
148
+ )
149
+ self.start_runner(system)
150
+
151
+ # Get apps.
152
+ accounts = self.runner.get(BankAccounts)
153
+ email_process1 = self.runner.get(EmailProcess)
154
+ email_process2 = self.runner.get(EmailProcess2)
155
+
156
+ # Check we processed nothing.
157
+ section = email_process1.notification_log["1,5"]
158
+ self.assertEqual(len(section.items), 0, section.items)
159
+ section = email_process2.notification_log["1,5"]
160
+ self.assertEqual(len(section.items), 0, section.items)
161
+
162
+ # Create ten events.
163
+ for _ in range(10):
164
+ accounts.open_account(
165
+ full_name="Alice",
166
+ email_address="alice@example.com",
167
+ )
168
+
169
+ # Check we processed ten events.
170
+ self.wait_for_runner()
171
+ section = email_process1.notification_log["1,10"]
172
+ self.assertEqual(len(section.items), 10)
173
+ section = email_process2.notification_log["1,10"]
174
+ self.assertEqual(len(section.items), 10)
175
+
176
+ def test_system_with_processing_loop(self):
177
+ class Command(Aggregate):
178
+ def __init__(self, text: str):
179
+ self.text = text
180
+ self.output: str | None = None
181
+ self.error: str | None = None
182
+
183
+ @event
184
+ def done(self, output: str, error: str):
185
+ self.output = output
186
+ self.error = error
187
+
188
+ class Result(Aggregate):
189
+ def __init__(self, command_id: UUID, output: str, error: str):
190
+ self.command_id = command_id
191
+ self.output = output
192
+ self.error = error
193
+
194
+ class Commands(ProcessApplication):
195
+ def create_command(self, text: str) -> UUID:
196
+ command = Command(text=text)
197
+ self.save(command)
198
+ return command.id
199
+
200
+ def policy(
201
+ self,
202
+ domain_event: AggregateEvent,
203
+ processing_event: ProcessingEvent,
204
+ ) -> None:
205
+ if isinstance(domain_event, Result.Created):
206
+ command = self.repository.get(domain_event.command_id)
207
+ command.done(
208
+ output=domain_event.output,
209
+ error=domain_event.error,
210
+ )
211
+ processing_event.collect_events(command)
212
+
213
+ def get_result(self, command_id: UUID) -> Tuple[str, str]:
214
+ command = self.repository.get(command_id)
215
+ return command.output, command.error
216
+
217
+ class Results(ProcessApplication):
218
+ def policy(
219
+ self,
220
+ domain_event: AggregateEvent,
221
+ processing_event: ProcessingEvent,
222
+ ) -> None:
223
+ if isinstance(domain_event, Command.Created):
224
+ try:
225
+ openargs = shlex.split(domain_event.text)
226
+ output = subprocess.check_output(openargs) # noqa: S603
227
+ error = ""
228
+ except Exception as e:
229
+ error = str(e)
230
+ output = b""
231
+ result = Result(
232
+ command_id=domain_event.originator_id,
233
+ output=output.decode("utf8"),
234
+ error=error,
235
+ )
236
+ processing_event.collect_events(result)
237
+
238
+ self.start_runner(System([[Commands, Results, Commands]]))
239
+
240
+ commands = self.runner.get(Commands)
241
+ command_id1 = commands.create_command("echo 'Hello World'")
242
+ command_id2 = commands.create_command("notacommand")
243
+
244
+ self.wait_for_runner()
245
+ self.wait_for_runner()
246
+
247
+ for _ in range(10):
248
+ output, error = commands.get_result(command_id1)
249
+ if output is None:
250
+ sleep(0.1)
251
+ else:
252
+ break
253
+ else:
254
+ self.fail("No results from command")
255
+
256
+ self.assertEqual(output, "Hello World\n")
257
+ self.assertEqual(error, "")
258
+
259
+ for _ in range(10):
260
+ output, error = commands.get_result(command_id2)
261
+ if output is None:
262
+ sleep(0.1)
263
+ else:
264
+ break
265
+ else:
266
+ self.fail("No results from command")
267
+
268
+ self.assertEqual(output, "")
269
+ self.assertIn("No such file or directory: 'notacommand'", error)
270
+
271
+ def test_catches_up_with_outstanding_notifications(self):
272
+ # Construct system and runner.
273
+ system = System(pipes=[[BankAccounts, EmailProcess]])
274
+ self.runner = self.runner_class(system)
275
+
276
+ # Get apps.
277
+ accounts = self.runner.get(BankAccounts)
278
+ email_process1 = self.runner.get(EmailProcess)
279
+
280
+ # Create an event.
281
+ accounts.open_account(
282
+ full_name="Alice",
283
+ email_address="alice@example.com",
284
+ )
285
+
286
+ # Check we processed nothing.
287
+ self.assertEqual(email_process1.recorder.max_tracking_id("BankAccounts"), 0)
288
+
289
+ # Start the runner.
290
+ self.runner.start()
291
+
292
+ # Create another event.
293
+ accounts.open_account(
294
+ full_name="Alice",
295
+ email_address="alice@example.com",
296
+ )
297
+
298
+ # Check we processed two events.
299
+ self.wait_for_runner()
300
+ self.assertEqual(email_process1.recorder.max_tracking_id("BankAccounts"), 2)
301
+
302
+ def test_filters_notifications_by_follow_topics(self):
303
+ class MyEmailProcess(EmailProcess):
304
+ follow_topics: ClassVar[Sequence[str]] = [
305
+ get_topic(AggregateEvent)
306
+ ] # follow nothing
307
+
308
+ system = System(pipes=[[BankAccounts, MyEmailProcess]])
309
+ self.runner = self.runner_class(system)
310
+
311
+ accounts = self.runner.get(BankAccounts)
312
+ email_process = self.runner.get(MyEmailProcess)
313
+
314
+ accounts.open_account(
315
+ full_name="Alice",
316
+ email_address="alice@example.com",
317
+ )
318
+
319
+ self.runner.start()
320
+
321
+ accounts.open_account(
322
+ full_name="Alice",
323
+ email_address="alice@example.com",
324
+ )
325
+
326
+ self.wait_for_runner()
327
+
328
+ self.assertEqual(len(email_process.notification_log["1,10"].items), 0)
329
+
330
+ def wait_for_runner(self):
331
+ pass
332
+
333
+
334
+ class SingleThreadedRunnerFollowersOrderingMixin:
335
+ """Followers ordering tests for single-threaded runners."""
336
+
337
+ def test_followers_are_prompted_in_declaration_order(self):
338
+ """Validate the order in which followers are prompted by the runner.
339
+
340
+ This test can, by nature, show some flakiness. That is, we can
341
+ see false negatives at times when a random ordering would match
342
+ the expected ordering. We mitigate this problem by increasing
343
+ the number of followers to be ordered.
344
+ """
345
+ clear_topic_cache()
346
+ app_calls = []
347
+
348
+ class NameLogger(EmailProcess):
349
+ def policy(self, _, __):
350
+ app_calls.append(self.__class__.__name__)
351
+
352
+ def make_name_logger(n: int) -> type:
353
+ return type(f"NameLogger{n}", (NameLogger,), {})
354
+
355
+ # Construct system and runner.
356
+ system = System(
357
+ pipes=[
358
+ [BankAccounts, make_name_logger(3)],
359
+ [BankAccounts, make_name_logger(4)],
360
+ [BankAccounts, make_name_logger(1)],
361
+ [BankAccounts, make_name_logger(5)],
362
+ [BankAccounts, make_name_logger(2)],
363
+ ]
364
+ )
365
+ self.start_runner(system)
366
+
367
+ # Create an event.
368
+ self.runner.get(BankAccounts).open_account(
369
+ full_name="Alice",
370
+ email_address="alice@example.com",
371
+ )
372
+
373
+ self.wait_for_runner()
374
+
375
+ # Check the applications' policy were called in the right order.
376
+ self.assertEqual(
377
+ app_calls,
378
+ ["NameLogger3", "NameLogger4", "NameLogger1", "NameLogger5", "NameLogger2"],
379
+ )
380
+
381
+
382
+ class TestSingleThreadedRunner(
383
+ RunnerTestCase[SingleThreadedRunner], SingleThreadedRunnerFollowersOrderingMixin
384
+ ):
385
+ runner_class = SingleThreadedRunner
386
+
387
+
388
+ class TestNewSingleThreadedRunner(
389
+ RunnerTestCase[NewSingleThreadedRunner], SingleThreadedRunnerFollowersOrderingMixin
390
+ ):
391
+ runner_class = NewSingleThreadedRunner
392
+
393
+ def test_ignores_recording_event_if_seen_subsequent(self):
394
+ system = System(pipes=[[BankAccounts, EmailProcess]])
395
+ self.start_runner(system)
396
+
397
+ accounts = self.runner.get(BankAccounts)
398
+ email_process = self.runner.get(EmailProcess)
399
+
400
+ accounts.open_account(
401
+ full_name="Alice",
402
+ email_address="alice@example.com",
403
+ )
404
+ self.wait_for_runner()
405
+
406
+ self.assertEqual(len(email_process.notification_log["1,10"].items), 1)
407
+
408
+ # Reset this to break sequence.
409
+ accounts.previous_max_notification_id -= 1
410
+
411
+ accounts.open_account(
412
+ full_name="Alice",
413
+ email_address="alice@example.com",
414
+ )
415
+ self.wait_for_runner()
416
+
417
+ self.assertEqual(len(email_process.notification_log["1,10"].items), 1)
418
+
419
+ def test_received_notifications_accumulate(self):
420
+ self.start_runner(
421
+ System(
422
+ [
423
+ [
424
+ BankAccounts,
425
+ EmailProcess,
426
+ ]
427
+ ]
428
+ )
429
+ )
430
+
431
+ accounts = self.runner.get(BankAccounts)
432
+ # Need to get the lock, so that they aren't cleared.
433
+ with self.runner._processing_lock:
434
+ accounts.open_account("Alice", "alice@example.com")
435
+ self.assertEqual(len(self.runner._recording_events_received), 1)
436
+ accounts.open_account("Bob", "bob@example.com")
437
+ self.assertEqual(len(self.runner._recording_events_received), 2)
438
+
439
+
440
+ class TestPullingThread(TestCase):
441
+ def test_receive_recording_event_does_not_block(self):
442
+ thread = PullingThread(
443
+ converting_queue=Queue(),
444
+ follower=MagicMock(),
445
+ leader_name="BankAccounts",
446
+ has_errored=Event(),
447
+ )
448
+ thread.recording_event_queue.maxsize = 1
449
+ self.assertEqual(thread.recording_event_queue.qsize(), 0)
450
+ thread.receive_recording_event(
451
+ RecordingEvent(
452
+ application_name="BankAccounts",
453
+ recordings=[],
454
+ previous_max_notification_id=None,
455
+ )
456
+ )
457
+ self.assertEqual(thread.recording_event_queue.qsize(), 1)
458
+ self.assertFalse(thread.overflow_event.is_set())
459
+ thread.receive_recording_event(
460
+ RecordingEvent(
461
+ application_name="BankAccounts",
462
+ recordings=[],
463
+ previous_max_notification_id=1,
464
+ )
465
+ )
466
+ self.assertEqual(thread.recording_event_queue.qsize(), 1)
467
+ self.assertTrue(thread.overflow_event.is_set())
468
+
469
+ def test_stops_because_stopping_event_is_set(self):
470
+ thread = PullingThread(
471
+ converting_queue=Queue(),
472
+ follower=MagicMock(),
473
+ leader_name="BankAccounts",
474
+ has_errored=Event(),
475
+ )
476
+ self.assertEqual(thread.recording_event_queue.qsize(), 0)
477
+ thread.receive_recording_event(
478
+ RecordingEvent(
479
+ application_name="BankAccounts",
480
+ recordings=[],
481
+ previous_max_notification_id=None,
482
+ )
483
+ )
484
+ self.assertEqual(thread.recording_event_queue.qsize(), 1)
485
+ thread.stop() # Set 'is_stopping' event.
486
+ self.assertEqual(thread.recording_event_queue.qsize(), 2)
487
+ thread.start()
488
+ thread.join(timeout=1)
489
+ self.assertFalse(thread.is_alive())
490
+ self.assertEqual(thread.recording_event_queue.qsize(), 2)
491
+
492
+ def test_stops_because_recording_event_queue_was_poisoned(self):
493
+ thread = PullingThread(
494
+ converting_queue=Queue(),
495
+ follower=MagicMock(),
496
+ leader_name="BankAccounts",
497
+ has_errored=Event(),
498
+ )
499
+ self.assertEqual(thread.recording_event_queue.qsize(), 0)
500
+ thread.start()
501
+ thread.stop() # Poison queue.
502
+ thread.join(timeout=1)
503
+ self.assertFalse(thread.is_alive())
504
+ self.assertEqual(thread.recording_event_queue.qsize(), 0)
505
+
506
+
507
+ class TestMultiThreadedRunner(RunnerTestCase[MultiThreadedRunner]):
508
+ runner_class = MultiThreadedRunner
509
+
510
+ def test_ignores_recording_event_if_seen_subsequent(self):
511
+ # Skipping this because this runner doesn't take
512
+ # notice of attribute previous_max_notification_id.
513
+ pass
514
+
515
+ def wait_for_runner(self):
516
+ sleep(0.3)
517
+ try:
518
+ self.runner.reraise_thread_errors()
519
+ except Exception as e:
520
+ self.runner = None
521
+ raise Exception("Runner errored: " + str(e)) from e
522
+
523
+ class BrokenInitialisation(EmailProcess):
524
+ def __init__(self, *_, **__):
525
+ msg = "Just testing error handling when initialisation is broken"
526
+ raise ProgrammingError(msg)
527
+
528
+ class BrokenProcessing(EmailProcess):
529
+ def process_event(self, _, __):
530
+ msg = "Just testing error handling when processing is broken"
531
+ raise ProgrammingError(msg)
532
+
533
+ def test_stops_if_app_initialisation_is_broken(self):
534
+ system = System(
535
+ pipes=[
536
+ [
537
+ BankAccounts,
538
+ TestMultiThreadedRunner.BrokenInitialisation,
539
+ ],
540
+ ]
541
+ )
542
+
543
+ with self.assertRaises(Exception) as cm:
544
+ self.runner_class(system)
545
+
546
+ self.assertEqual(
547
+ cm.exception.args[0],
548
+ "Just testing error handling when initialisation is broken",
549
+ )
550
+
551
+ def test_stop_raises_if_event_processing_is_broken(self):
552
+ system = System(
553
+ pipes=[
554
+ [
555
+ BankAccounts,
556
+ TestMultiThreadedRunner.BrokenProcessing,
557
+ ],
558
+ ]
559
+ )
560
+ self.start_runner(system)
561
+
562
+ accounts = self.runner.get(BankAccounts)
563
+ accounts.open_account(
564
+ full_name="Alice",
565
+ email_address="alice@example.com",
566
+ )
567
+
568
+ # Wait for runner to stop.
569
+ self.assertTrue(self.runner.has_errored.wait(timeout=1))
570
+
571
+ # Check stop() raises exception.
572
+ with self.assertRaises(EventProcessingError) as cm:
573
+ self.runner.stop()
574
+ self.assertIn(
575
+ "Just testing error handling when processing is broken",
576
+ cm.exception.args[0],
577
+ )
578
+ self.runner = None
579
+
580
+ def test_watch_for_errors_raises_if_runner_errors(self):
581
+ system = System(
582
+ pipes=[
583
+ [
584
+ BankAccounts,
585
+ TestMultiThreadedRunner.BrokenProcessing,
586
+ ],
587
+ ]
588
+ )
589
+ # Create runner.
590
+ self.runner = self.runner_class(system)
591
+
592
+ # Create some notifications.
593
+ accounts = self.runner.get(BankAccounts)
594
+ accounts.open_account(
595
+ full_name="Alice",
596
+ email_address="alice@example.com",
597
+ )
598
+
599
+ # Start runner.
600
+ self.runner.start()
601
+
602
+ # Trigger pulling of notifications.
603
+ accounts = self.runner.get(BankAccounts)
604
+ accounts.open_account(
605
+ full_name="Alice",
606
+ email_address="alice@example.com",
607
+ )
608
+
609
+ # Check watch_for_errors() raises exception.
610
+ with self.assertRaises(EventProcessingError) as cm:
611
+ self.runner.watch_for_errors(timeout=1)
612
+ self.assertEqual(
613
+ cm.exception.args[0],
614
+ "Just testing error handling when processing is broken",
615
+ )
616
+ self.runner = None
617
+
618
+ def test_watch_for_errors_exits_without_raising_after_timeout(self):
619
+ # Construct system and start runner
620
+ system = System(
621
+ pipes=[
622
+ [
623
+ BankAccounts,
624
+ EmailProcess,
625
+ ],
626
+ ]
627
+ )
628
+ self.start_runner(system)
629
+
630
+ # Watch for error with a timeout. Check returns False.
631
+ self.assertFalse(self.runner.watch_for_errors(timeout=0.0))
632
+
633
+ def test_stops_if_app_processing_is_broken(self):
634
+ system = System(
635
+ pipes=[
636
+ [
637
+ BankAccounts,
638
+ TestMultiThreadedRunner.BrokenProcessing,
639
+ ],
640
+ ]
641
+ )
642
+
643
+ self.start_runner(system)
644
+
645
+ accounts = self.runner.get(BankAccounts)
646
+ accounts.open_account(
647
+ full_name="Alice",
648
+ email_address="alice@example.com",
649
+ )
650
+
651
+ # Check watch_for_errors() raises exception.
652
+ with self.assertRaises(EventProcessingError) as cm:
653
+ self.runner.watch_for_errors(timeout=1)
654
+ self.assertIn(
655
+ "Just testing error handling when processing is broken",
656
+ cm.exception.args[0],
657
+ )
658
+ self.runner = None
659
+
660
+
661
+ class TestMultiThreadedRunnerWithSQLiteFileBased(TestMultiThreadedRunner):
662
+ def setUp(self):
663
+ super().setUp()
664
+ os.environ["PERSISTENCE_MODULE"] = "eventsourcing.sqlite"
665
+ uris = tmpfile_uris()
666
+ os.environ[f"{BankAccounts.name.upper()}_SQLITE_DBNAME"] = next(uris)
667
+ os.environ[f"{EmailProcess.name.upper()}_SQLITE_DBNAME"] = next(uris)
668
+ os.environ[f"{EmailProcess.name.upper()}2_SQLITE_DBNAME"] = next(uris)
669
+ os.environ[f"MY{EmailProcess.name.upper()}_SQLITE_DBNAME"] = next(uris)
670
+ os.environ["BROKENPROCESSING_SQLITE_DBNAME"] = next(uris)
671
+ os.environ["BROKENCONVERTING_SQLITE_DBNAME"] = next(uris)
672
+ os.environ["BROKENPULLING_SQLITE_DBNAME"] = next(uris)
673
+ os.environ["COMMANDS_SQLITE_DBNAME"] = next(uris)
674
+ os.environ["RESULTS_SQLITE_DBNAME"] = next(uris)
675
+
676
+ def tearDown(self):
677
+ del os.environ["PERSISTENCE_MODULE"]
678
+ del os.environ[f"{BankAccounts.name.upper()}_SQLITE_DBNAME"]
679
+ del os.environ[f"{EmailProcess.name.upper()}_SQLITE_DBNAME"]
680
+ del os.environ[f"MY{EmailProcess.name.upper()}_SQLITE_DBNAME"]
681
+ del os.environ[f"{EmailProcess.name.upper()}2_SQLITE_DBNAME"]
682
+ del os.environ["BROKENPROCESSING_SQLITE_DBNAME"]
683
+ del os.environ["BROKENCONVERTING_SQLITE_DBNAME"]
684
+ del os.environ["BROKENPULLING_SQLITE_DBNAME"]
685
+ del os.environ["COMMANDS_SQLITE_DBNAME"]
686
+ del os.environ["RESULTS_SQLITE_DBNAME"]
687
+ super().tearDown()
688
+
689
+
690
+ class TestMultiThreadedRunnerWithSQLiteInMemory(TestMultiThreadedRunner):
691
+ def setUp(self):
692
+ super().setUp()
693
+ os.environ["PERSISTENCE_MODULE"] = "eventsourcing.sqlite"
694
+ os.environ[f"{BankAccounts.name.upper()}_SQLITE_DBNAME"] = (
695
+ f"file:{BankAccounts.name.lower()}?mode=memory&cache=shared"
696
+ )
697
+ os.environ[f"{EmailProcess.name.upper()}_SQLITE_DBNAME"] = (
698
+ f"file:{EmailProcess.name.lower()}?mode=memory&cache=shared"
699
+ )
700
+ os.environ[f"MY{EmailProcess.name.upper()}_SQLITE_DBNAME"] = (
701
+ f"file:{EmailProcess.name.lower()}?mode=memory&cache=shared"
702
+ )
703
+ os.environ[f"{EmailProcess.name.upper()}2_SQLITE_DBNAME"] = (
704
+ f"file:{EmailProcess.name.lower()}2?mode=memory&cache=shared"
705
+ )
706
+ os.environ["BROKENPROCESSING_SQLITE_DBNAME"] = (
707
+ "file:brokenprocessing?mode=memory&cache=shared"
708
+ )
709
+ os.environ["BROKENCONVERTING_SQLITE_DBNAME"] = (
710
+ "file:brokenconverting?mode=memory&cache=shared"
711
+ )
712
+ os.environ["BROKENPULLING_SQLITE_DBNAME"] = (
713
+ "file:brokenprocessing?mode=memory&cache=shared"
714
+ )
715
+ os.environ["COMMANDS_SQLITE_DBNAME"] = "file:commands?mode=memory&cache=shared"
716
+ os.environ["RESULTS_SQLITE_DBNAME"] = "file:results?mode=memory&cache=shared"
717
+
718
+ def tearDown(self):
719
+ del os.environ["PERSISTENCE_MODULE"]
720
+ del os.environ[f"{BankAccounts.name.upper()}_SQLITE_DBNAME"]
721
+ del os.environ[f"MY{EmailProcess.name.upper()}_SQLITE_DBNAME"]
722
+ del os.environ[f"{EmailProcess.name.upper()}_SQLITE_DBNAME"]
723
+ del os.environ[f"{EmailProcess.name.upper()}2_SQLITE_DBNAME"]
724
+ del os.environ["BROKENPROCESSING_SQLITE_DBNAME"]
725
+ del os.environ["BROKENCONVERTING_SQLITE_DBNAME"]
726
+ del os.environ["BROKENPULLING_SQLITE_DBNAME"]
727
+ del os.environ["COMMANDS_SQLITE_DBNAME"]
728
+ del os.environ["RESULTS_SQLITE_DBNAME"]
729
+ super().tearDown()
730
+
731
+
732
+ class TestMultiThreadedRunnerWithPostgres(TestMultiThreadedRunner):
733
+ def setUp(self):
734
+ super().setUp()
735
+ os.environ["POSTGRES_DBNAME"] = "eventsourcing"
736
+ os.environ["POSTGRES_HOST"] = "127.0.0.1"
737
+ os.environ["POSTGRES_PORT"] = "5432"
738
+ os.environ["POSTGRES_USER"] = "eventsourcing"
739
+ os.environ["POSTGRES_PASSWORD"] = "eventsourcing" # noqa: S105
740
+
741
+ with PostgresDatastore(
742
+ os.getenv("POSTGRES_DBNAME"),
743
+ os.getenv("POSTGRES_HOST"),
744
+ os.getenv("POSTGRES_PORT"),
745
+ os.getenv("POSTGRES_USER"),
746
+ os.getenv("POSTGRES_PASSWORD"),
747
+ ) as datastore:
748
+ drop_postgres_table(datastore, f"{BankAccounts.name.lower()}_events")
749
+ drop_postgres_table(datastore, f"{EmailProcess.name.lower()}_events")
750
+ drop_postgres_table(datastore, f"{EmailProcess.name.lower()}_tracking")
751
+ drop_postgres_table(datastore, f"{EmailProcess.name.lower()}2_events")
752
+ drop_postgres_table(datastore, f"{EmailProcess.name.lower()}2_tracking")
753
+ drop_postgres_table(datastore, "brokenprocessing_events")
754
+ drop_postgres_table(datastore, "brokenprocessing_tracking")
755
+ drop_postgres_table(datastore, "brokenconverting_events")
756
+ drop_postgres_table(datastore, "brokenconverting_tracking")
757
+ drop_postgres_table(datastore, "brokenpulling_events")
758
+ drop_postgres_table(datastore, "brokenpulling_tracking")
759
+ drop_postgres_table(datastore, "commands_events")
760
+ drop_postgres_table(datastore, "commands_tracking")
761
+ drop_postgres_table(datastore, "results_events")
762
+ drop_postgres_table(datastore, "results_tracking")
763
+
764
+ os.environ["PERSISTENCE_MODULE"] = "eventsourcing.postgres"
765
+
766
+ def tearDown(self):
767
+ del os.environ["PERSISTENCE_MODULE"]
768
+ del os.environ["POSTGRES_DBNAME"]
769
+ del os.environ["POSTGRES_HOST"]
770
+ del os.environ["POSTGRES_PORT"]
771
+ del os.environ["POSTGRES_USER"]
772
+ del os.environ["POSTGRES_PASSWORD"]
773
+ super().tearDown()
774
+
775
+ def wait_for_runner(self):
776
+ sleep(0.6)
777
+ super().wait_for_runner()
778
+
779
+
780
+ class TestNewMultiThreadedRunner(TestMultiThreadedRunner):
781
+ runner_class = NewMultiThreadedRunner
782
+
783
+ class BrokenPulling(EmailProcess):
784
+ def pull_notifications(
785
+ self, leader_name: str, start: int, stop: int | None = None
786
+ ) -> Iterator[List[Notification]]:
787
+ msg = "Just testing error handling when pulling is broken"
788
+ raise ProgrammingError(msg)
789
+
790
+ class BrokenConverting(EmailProcess):
791
+ def convert_notifications(
792
+ self, leader_name: str, notifications: Iterable[Notification]
793
+ ) -> List[ProcessingJob]:
794
+ msg = "Just testing error handling when converting is broken"
795
+ raise ProgrammingError(msg)
796
+
797
+ # This duplicates test method above.
798
+ def test_ignores_recording_event_if_seen_subsequent(self):
799
+ system = System(pipes=[[BankAccounts, EmailProcess]])
800
+ self.start_runner(system)
801
+
802
+ accounts = self.runner.get(BankAccounts)
803
+ email_process = self.runner.get(EmailProcess)
804
+
805
+ accounts.open_account(
806
+ full_name="Alice",
807
+ email_address="alice@example.com",
808
+ )
809
+ self.wait_for_runner()
810
+
811
+ self.assertEqual(len(email_process.notification_log["1,10"].items), 1)
812
+
813
+ # Reset this to break sequence.
814
+ accounts.previous_max_notification_id -= 1
815
+
816
+ accounts.open_account(
817
+ full_name="Alice",
818
+ email_address="alice@example.com",
819
+ )
820
+ self.wait_for_runner()
821
+
822
+ self.assertEqual(len(email_process.notification_log["1,10"].items), 1)
823
+
824
+ def test_queue_task_done_is_called(self):
825
+ system = System(pipes=[[BankAccounts, EmailProcess]])
826
+ self.start_runner(system)
827
+
828
+ accounts = self.runner.get(BankAccounts)
829
+ email_process1 = self.runner.get(EmailProcess)
830
+
831
+ accounts.open_account(
832
+ full_name="Alice",
833
+ email_address="alice@example.com",
834
+ )
835
+ sleep(0.1)
836
+ self.assertEqual(len(email_process1.notification_log["1,10"].items), 1)
837
+
838
+ for thread in self.runner.all_threads:
839
+ if isinstance(thread, ConvertingThread):
840
+ self.assertEqual(thread.converting_queue.unfinished_tasks, 0)
841
+ self.assertEqual(thread.processing_queue.unfinished_tasks, 0)
842
+
843
+ def test_stop_raises_if_notification_converting_is_broken(self):
844
+ system = System(
845
+ pipes=[
846
+ [
847
+ BankAccounts,
848
+ TestNewMultiThreadedRunner.BrokenConverting,
849
+ ],
850
+ ]
851
+ )
852
+
853
+ # Create runner.
854
+ self.runner = self.runner_class(system)
855
+
856
+ # Create some notifications.
857
+ accounts = self.runner.get(BankAccounts)
858
+ accounts.open_account(
859
+ full_name="Alice",
860
+ email_address="alice@example.com",
861
+ )
862
+
863
+ # Start runner.
864
+ self.runner.start()
865
+
866
+ # Trigger pulling of notifications.
867
+ accounts = self.runner.get(BankAccounts)
868
+ accounts.open_account(
869
+ full_name="Alice",
870
+ email_address="alice@example.com",
871
+ )
872
+
873
+ # Wait for runner to error.
874
+ self.assertTrue(self.runner.has_errored.wait(timeout=1000))
875
+
876
+ # Check stop() raises exception.
877
+ with self.assertRaises(NotificationConvertingError) as cm:
878
+ self.runner.stop()
879
+ self.assertIn(
880
+ "Just testing error handling when converting is broken",
881
+ cm.exception.args[0],
882
+ )
883
+ self.runner = None
884
+
885
+ def test_stop_raises_if_notification_pulling_is_broken(self):
886
+ system = System(
887
+ pipes=[
888
+ [
889
+ BankAccounts,
890
+ TestNewMultiThreadedRunner.BrokenPulling,
891
+ ],
892
+ ]
893
+ )
894
+ # Create runner.
895
+ self.runner = self.runner_class(system)
896
+
897
+ # Create some notifications.
898
+ accounts = self.runner.get(BankAccounts)
899
+ accounts.open_account(
900
+ full_name="Alice",
901
+ email_address="alice@example.com",
902
+ )
903
+
904
+ # Start runner.
905
+ self.runner.start()
906
+
907
+ # Trigger pulling of notifications.
908
+ accounts = self.runner.get(BankAccounts)
909
+ accounts.open_account(
910
+ full_name="Alice",
911
+ email_address="alice@example.com",
912
+ )
913
+
914
+ # Wait for runner to error.
915
+ self.assertTrue(self.runner.has_errored.wait(timeout=1))
916
+
917
+ # Check stop() raises exception.
918
+ with self.assertRaises(NotificationPullingError) as cm:
919
+ self.runner.stop()
920
+ self.assertIn(
921
+ "Just testing error handling when pulling is broken",
922
+ cm.exception.args[0],
923
+ )
924
+ self.runner = None
925
+
926
+ def wait_for_runner(self):
927
+ sleep(0.1)
928
+ try:
929
+ self.runner.reraise_thread_errors()
930
+ except Exception as e:
931
+ self.runner = None
932
+ raise Exception("Runner errored: " + str(e)) from e
933
+
934
+
935
+ del RunnerTestCase