eventsourcing 9.4.2__py3-none-any.whl → 9.4.4__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/dispatch.py CHANGED
@@ -63,7 +63,7 @@ class singledispatchmethod(_singledispatchmethod[_T]): # noqa: N801
63
63
 
64
64
  try:
65
65
  return self.dispatcher.register(cast("type[Any]", cls), func=method)
66
- except NameError:
66
+ except (NameError, TypeError): # NameError <= Py3.13, TypeError >= Py3.14
67
67
  self.deferred_registrations.append(
68
68
  (cls, method) # pyright: ignore [reportArgumentType]
69
69
  )
@@ -449,14 +449,17 @@ class TrackingRecorder(Recorder, ABC):
449
449
  for the named application, or None if no tracking objects have been recorded.
450
450
  """
451
451
 
452
- @abstractmethod
453
452
  def has_tracking_id(
454
453
  self, application_name: str, notification_id: int | None
455
454
  ) -> bool:
456
- """Returns True if a tracking object with the given application name
457
- and notification ID has been recorded, and True if given notification_id is
458
- None, otherwise returns False.
455
+ """Returns True if given notification_id is None or a tracking
456
+ object with the given application_name and a notification ID greater
457
+ than or equal to the given notification_id has been recorded.
459
458
  """
459
+ if notification_id is None:
460
+ return True
461
+ max_tracking_id = self.max_tracking_id(application_name)
462
+ return max_tracking_id is not None and max_tracking_id >= notification_id
460
463
 
461
464
  def wait(
462
465
  self,
@@ -483,10 +486,7 @@ class TrackingRecorder(Recorder, ABC):
483
486
  sleep_interval_ms = 100.0
484
487
  max_sleep_interval_ms = 800.0
485
488
  while True:
486
- max_tracking_id = self.max_tracking_id(application_name)
487
- if notification_id is None or (
488
- max_tracking_id is not None and max_tracking_id >= notification_id
489
- ):
489
+ if self.has_tracking_id(application_name, notification_id):
490
490
  break
491
491
  if interrupt:
492
492
  if interrupt.wait(timeout=sleep_interval_ms / 1000):
eventsourcing/popo.py CHANGED
@@ -193,14 +193,14 @@ class POPOSubscription(ListenNotifySubscription[POPOApplicationRecorder]):
193
193
  class POPOTrackingRecorder(POPORecorder, TrackingRecorder):
194
194
  def __init__(self) -> None:
195
195
  super().__init__()
196
- self._tracking_table: dict[str, set[int]] = defaultdict(set)
197
196
  self._max_tracking_ids: dict[str, int | None] = defaultdict(lambda: None)
198
197
 
199
198
  def _assert_tracking_uniqueness(self, tracking: Tracking) -> None:
200
- if tracking.notification_id in self._tracking_table[tracking.application_name]:
199
+ max_tracking_id = self._max_tracking_ids[tracking.application_name]
200
+ if max_tracking_id is not None and max_tracking_id >= tracking.notification_id:
201
201
  msg = (
202
- f"Already recorded notification ID {tracking.notification_id} "
203
- f"for application {tracking.application_name}"
202
+ f"Tracking notification ID {tracking.notification_id} "
203
+ f"not greater than current max tracking ID {max_tracking_id}"
204
204
  )
205
205
  raise IntegrityError(msg)
206
206
 
@@ -210,23 +210,12 @@ class POPOTrackingRecorder(POPORecorder, TrackingRecorder):
210
210
  self._insert_tracking(tracking)
211
211
 
212
212
  def _insert_tracking(self, tracking: Tracking) -> None:
213
- self._tracking_table[tracking.application_name].add(tracking.notification_id)
214
- max_tracking_id = self._max_tracking_ids[tracking.application_name]
215
- if max_tracking_id is None or max_tracking_id < tracking.notification_id:
216
- self._max_tracking_ids[tracking.application_name] = tracking.notification_id
213
+ self._max_tracking_ids[tracking.application_name] = tracking.notification_id
217
214
 
218
215
  def max_tracking_id(self, application_name: str) -> int | None:
219
216
  with self._database_lock:
220
217
  return self._max_tracking_ids[application_name]
221
218
 
222
- def has_tracking_id(
223
- self, application_name: str, notification_id: int | None
224
- ) -> bool:
225
- if notification_id is None:
226
- return True
227
- with self._database_lock:
228
- return notification_id in self._tracking_table[application_name]
229
-
230
219
 
231
220
  class POPOProcessRecorder(
232
221
  POPOTrackingRecorder, POPOApplicationRecorder, ProcessRecorder
eventsourcing/postgres.py CHANGED
@@ -94,10 +94,12 @@ class PostgresDatastore:
94
94
  schema: str = "",
95
95
  pool_open_timeout: int | None = None,
96
96
  get_password_func: Callable[[], str] | None = None,
97
+ single_row_tracking: bool = True,
97
98
  ):
98
99
  self.idle_in_transaction_session_timeout = idle_in_transaction_session_timeout
99
100
  self.pre_ping = pre_ping
100
101
  self.pool_open_timeout = pool_open_timeout
102
+ self.single_row_tracking = single_row_tracking
101
103
 
102
104
  check = ConnectionPool.check_connection if pre_ping else None
103
105
  self.pool = ConnectionPool(
@@ -189,6 +191,12 @@ class PostgresDatastore:
189
191
  class PostgresRecorder:
190
192
  """Base class for recorders that use PostgreSQL."""
191
193
 
194
+ MAX_IDENTIFIER_LEN = 63
195
+ # From the PostgreSQL docs: "The system uses no more than NAMEDATALEN-1 bytes
196
+ # of an identifier; longer names can be written in commands, but they will be
197
+ # truncated. By default, NAMEDATALEN is 64 so the maximum identifier length is
198
+ # 63 bytes." https://www.postgresql.org/docs/current/sql-syntax-lexical.html
199
+
192
200
  def __init__(
193
201
  self,
194
202
  datastore: PostgresDatastore,
@@ -196,18 +204,22 @@ class PostgresRecorder:
196
204
  self.datastore = datastore
197
205
  self.create_table_statements = self.construct_create_table_statements()
198
206
 
199
- def construct_create_table_statements(self) -> list[Composed]:
200
- return []
201
-
202
- def check_table_name_length(self, table_name: str) -> None:
203
- if len(table_name) > 63:
207
+ @staticmethod
208
+ def check_table_name_length(table_name: str) -> None:
209
+ if len(table_name) > PostgresRecorder.MAX_IDENTIFIER_LEN:
204
210
  msg = f"Table name too long: {table_name}"
205
211
  raise ProgrammingError(msg)
206
212
 
213
+ def construct_create_table_statements(self) -> list[Composed]:
214
+ return []
215
+
207
216
  def create_table(self) -> None:
208
217
  with self.datastore.transaction(commit=True) as curs:
209
- for statement in self.create_table_statements:
210
- curs.execute(statement, prepare=False)
218
+ self._create_table(curs)
219
+
220
+ def _create_table(self, curs: Cursor[DictRow]) -> None:
221
+ for statement in self.create_table_statements:
222
+ curs.execute(statement, prepare=False)
211
223
 
212
224
 
213
225
  class PostgresAggregateRecorder(PostgresRecorder, AggregateRecorder):
@@ -610,25 +622,56 @@ class PostgresTrackingRecorder(PostgresRecorder, TrackingRecorder):
610
622
  super().__init__(datastore, **kwargs)
611
623
  self.check_table_name_length(tracking_table_name)
612
624
  self.tracking_table_name = tracking_table_name
613
- self.create_table_statements.append(
614
- SQL(
615
- "CREATE TABLE IF NOT EXISTS {0}.{1} ("
616
- "application_name text, "
617
- "notification_id bigint, "
618
- "PRIMARY KEY "
619
- "(application_name, notification_id))"
625
+ self.tracking_table_exists: bool = False
626
+ self.tracking_migration_previous: int | None = None
627
+ self.tracking_migration_current: int | None = None
628
+ self.table_migration_identifier = "__migration__"
629
+ self.has_checked_for_multi_row_tracking_table: bool = False
630
+ if self.datastore.single_row_tracking:
631
+ # For single-row tracking.
632
+ self.create_table_statements.append(
633
+ SQL(
634
+ "CREATE TABLE IF NOT EXISTS {0}.{1} ("
635
+ "application_name text, "
636
+ "notification_id bigint, "
637
+ "PRIMARY KEY "
638
+ "(application_name))"
639
+ ).format(
640
+ Identifier(self.datastore.schema),
641
+ Identifier(self.tracking_table_name),
642
+ )
643
+ )
644
+ self.insert_tracking_statement = SQL(
645
+ "INSERT INTO {0}.{1} "
646
+ "VALUES (%(application_name)s, %(notification_id)s) "
647
+ "ON CONFLICT (application_name) DO UPDATE "
648
+ "SET notification_id = %(notification_id)s "
649
+ "WHERE {0}.{1}.notification_id < %(notification_id)s "
650
+ "RETURNING notification_id"
651
+ ).format(
652
+ Identifier(self.datastore.schema),
653
+ Identifier(self.tracking_table_name),
654
+ )
655
+ else:
656
+ # For legacy multi-row tracking.
657
+ self.create_table_statements.append(
658
+ SQL(
659
+ "CREATE TABLE IF NOT EXISTS {0}.{1} ("
660
+ "application_name text, "
661
+ "notification_id bigint, "
662
+ "PRIMARY KEY "
663
+ "(application_name, notification_id))"
664
+ ).format(
665
+ Identifier(self.datastore.schema),
666
+ Identifier(self.tracking_table_name),
667
+ )
668
+ )
669
+ self.insert_tracking_statement = SQL(
670
+ "INSERT INTO {0}.{1} VALUES (%(application_name)s, %(notification_id)s)"
620
671
  ).format(
621
672
  Identifier(self.datastore.schema),
622
673
  Identifier(self.tracking_table_name),
623
674
  )
624
- )
625
-
626
- self.insert_tracking_statement = SQL(
627
- "INSERT INTO {0}.{1} VALUES (%s, %s)"
628
- ).format(
629
- Identifier(self.datastore.schema),
630
- Identifier(self.tracking_table_name),
631
- )
632
675
 
633
676
  self.max_tracking_id_statement = SQL(
634
677
  "SELECT MAX(notification_id) FROM {0}.{1} WHERE application_name=%s"
@@ -637,21 +680,85 @@ class PostgresTrackingRecorder(PostgresRecorder, TrackingRecorder):
637
680
  Identifier(self.tracking_table_name),
638
681
  )
639
682
 
640
- self.count_tracking_id_statement = SQL(
641
- "SELECT COUNT(*) FROM {0}.{1} "
642
- "WHERE application_name=%s AND notification_id=%s"
643
- ).format(
644
- Identifier(self.datastore.schema),
645
- Identifier(self.tracking_table_name),
646
- )
683
+ def create_table(self) -> None:
684
+ # Get the migration version.
685
+ try:
686
+ self.tracking_migration_current = self.tracking_migration_previous = (
687
+ self.max_tracking_id(self.table_migration_identifier)
688
+ )
689
+ except ProgrammingError:
690
+ pass
691
+ else:
692
+ self.tracking_table_exists = True
693
+ super().create_table()
694
+ if (
695
+ not self.datastore.single_row_tracking
696
+ and self.tracking_migration_current is not None
697
+ ):
698
+ msg = "Can't do multi-row tracking with single-row tracking table"
699
+ raise OperationalError(msg)
700
+
701
+ def _create_table(self, curs: Cursor[DictRow]) -> None:
702
+ max_tracking_ids: dict[str, int] = {}
703
+ if (
704
+ self.datastore.single_row_tracking
705
+ and self.tracking_table_exists
706
+ and not self.tracking_migration_previous
707
+ ):
708
+ # Migrate the table.
709
+ curs.execute(
710
+ SQL("SET LOCAL lock_timeout = '{0}s'").format(
711
+ self.datastore.lock_timeout
712
+ )
713
+ )
714
+ curs.execute(
715
+ SQL("LOCK TABLE {0}.{1} IN ACCESS EXCLUSIVE MODE").format(
716
+ Identifier(self.datastore.schema),
717
+ Identifier(self.tracking_table_name),
718
+ )
719
+ )
720
+
721
+ # Get all application names.
722
+ application_names: list[str] = [
723
+ select_row["application_name"]
724
+ for select_row in curs.execute(
725
+ SQL("SELECT DISTINCT application_name FROM {0}.{1}").format(
726
+ Identifier(self.datastore.schema),
727
+ Identifier(self.tracking_table_name),
728
+ )
729
+ )
730
+ ]
731
+
732
+ # Get max tracking ID for each application name.
733
+ for application_name in application_names:
734
+ curs.execute(self.max_tracking_id_statement, (application_name,))
735
+ max_tracking_id_row = curs.fetchone()
736
+ assert max_tracking_id_row is not None
737
+ max_tracking_ids[application_name] = max_tracking_id_row["max"]
738
+ # Rename the table.
739
+ rename = f"bkup1_{self.tracking_table_name}"[: self.MAX_IDENTIFIER_LEN]
740
+ drop_table_statement = SQL("ALTER TABLE {0}.{1} RENAME TO {2}").format(
741
+ Identifier(self.datastore.schema),
742
+ Identifier(self.tracking_table_name),
743
+ Identifier(rename),
744
+ )
745
+ curs.execute(drop_table_statement)
746
+ # Create the table.
747
+ super()._create_table(curs)
748
+ # Maybe insert migration tracking record and application tracking records.
749
+ if self.datastore.single_row_tracking and (
750
+ not self.tracking_table_exists
751
+ or (self.tracking_table_exists and not self.tracking_migration_previous)
752
+ ):
753
+ # Assume we just created a table for single-row tracking.
754
+ self._insert_tracking(curs, Tracking(self.table_migration_identifier, 1))
755
+ self.tracking_migration_current = 1
756
+ for application_name, max_tracking_id in max_tracking_ids.items():
757
+ self._insert_tracking(curs, Tracking(application_name, max_tracking_id))
647
758
 
648
759
  @retry((InterfaceError, OperationalError), max_attempts=10, wait=0.2)
649
760
  def insert_tracking(self, tracking: Tracking) -> None:
650
- with (
651
- self.datastore.get_connection() as conn,
652
- conn.transaction(),
653
- conn.cursor() as curs,
654
- ):
761
+ with self.datastore.transaction(commit=True) as curs:
655
762
  self._insert_tracking(curs, tracking)
656
763
 
657
764
  def _insert_tracking(
@@ -659,42 +766,57 @@ class PostgresTrackingRecorder(PostgresRecorder, TrackingRecorder):
659
766
  curs: Cursor[DictRow],
660
767
  tracking: Tracking,
661
768
  ) -> None:
769
+ self._check_has_multi_row_tracking_table(curs)
770
+
662
771
  curs.execute(
663
772
  query=self.insert_tracking_statement,
664
- params=(
665
- tracking.application_name,
666
- tracking.notification_id,
667
- ),
773
+ params={
774
+ "application_name": tracking.application_name,
775
+ "notification_id": tracking.notification_id,
776
+ },
668
777
  prepare=True,
669
778
  )
779
+ if self.datastore.single_row_tracking:
780
+ fetchone = curs.fetchone()
781
+ if fetchone is None:
782
+ msg = (
783
+ "Failed to record tracking for "
784
+ f"{tracking.application_name} {tracking.notification_id}"
785
+ )
786
+ raise IntegrityError(msg)
787
+
788
+ def _check_has_multi_row_tracking_table(self, c: Cursor[DictRow]) -> None:
789
+ if (
790
+ not self.datastore.single_row_tracking
791
+ and not self.has_checked_for_multi_row_tracking_table
792
+ and self._max_tracking_id(self.table_migration_identifier, c)
793
+ ):
794
+ msg = "Can't do multi-row tracking with single-row tracking table"
795
+ raise ProgrammingError(msg)
796
+ self.has_checked_for_multi_row_tracking_table = True
670
797
 
671
798
  @retry((InterfaceError, OperationalError), max_attempts=10, wait=0.2)
672
799
  def max_tracking_id(self, application_name: str) -> int | None:
673
800
  with self.datastore.get_connection() as conn, conn.cursor() as curs:
674
- curs.execute(
675
- query=self.max_tracking_id_statement,
676
- params=(application_name,),
677
- prepare=True,
678
- )
679
- fetchone = curs.fetchone()
680
- assert fetchone is not None
681
- return fetchone["max"]
801
+ return self._max_tracking_id(application_name, curs)
802
+
803
+ def _max_tracking_id(
804
+ self, application_name: str, curs: Cursor[DictRow]
805
+ ) -> int | None:
806
+ curs.execute(
807
+ query=self.max_tracking_id_statement,
808
+ params=(application_name,),
809
+ prepare=True,
810
+ )
811
+ fetchone = curs.fetchone()
812
+ assert fetchone is not None
813
+ return fetchone["max"]
682
814
 
683
815
  @retry((InterfaceError, OperationalError), max_attempts=10, wait=0.2)
684
816
  def has_tracking_id(
685
817
  self, application_name: str, notification_id: int | None
686
818
  ) -> bool:
687
- if notification_id is None:
688
- return True
689
- with self.datastore.get_connection() as conn, conn.cursor() as curs:
690
- curs.execute(
691
- query=self.count_tracking_id_statement,
692
- params=(application_name, notification_id),
693
- prepare=True,
694
- )
695
- fetchone = curs.fetchone()
696
- assert fetchone is not None
697
- return bool(fetchone["count"])
819
+ return super().has_tracking_id(application_name, notification_id)
698
820
 
699
821
 
700
822
  TPostgresTrackingRecorder = TypeVar(
@@ -750,6 +872,7 @@ class PostgresFactory(InfrastructureFactory[PostgresTrackingRecorder]):
750
872
  "POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT"
751
873
  )
752
874
  POSTGRES_SCHEMA = "POSTGRES_SCHEMA"
875
+ POSTGRES_SINGLE_ROW_TRACKING = "SINGLE_ROW_TRACKING"
753
876
  CREATE_TABLE = "CREATE_TABLE"
754
877
 
755
878
  aggregate_recorder_class = PostgresAggregateRecorder
@@ -911,13 +1034,16 @@ class PostgresFactory(InfrastructureFactory[PostgresTrackingRecorder]):
911
1034
 
912
1035
  schema = self.env.get(self.POSTGRES_SCHEMA) or ""
913
1036
 
1037
+ single_row_tracking = strtobool(
1038
+ self.env.get(self.POSTGRES_SINGLE_ROW_TRACKING, "t")
1039
+ )
1040
+
914
1041
  self.datastore = PostgresDatastore(
915
1042
  dbname=dbname,
916
1043
  host=host,
917
1044
  port=port,
918
1045
  user=user,
919
1046
  password=password,
920
- get_password_func=get_password_func,
921
1047
  connect_timeout=connect_timeout,
922
1048
  idle_in_transaction_session_timeout=idle_in_transaction_session_timeout,
923
1049
  pool_size=pool_size,
@@ -927,6 +1053,8 @@ class PostgresFactory(InfrastructureFactory[PostgresTrackingRecorder]):
927
1053
  pre_ping=pre_ping,
928
1054
  lock_timeout=lock_timeout,
929
1055
  schema=schema,
1056
+ get_password_func=get_password_func,
1057
+ single_row_tracking=single_row_tracking,
930
1058
  )
931
1059
 
932
1060
  def env_create_table(self) -> bool:
@@ -16,7 +16,6 @@ from eventsourcing.dispatch import singledispatchmethod
16
16
  from eventsourcing.domain import DomainEventProtocol
17
17
  from eventsourcing.persistence import (
18
18
  InfrastructureFactory,
19
- IntegrityError,
20
19
  ProcessRecorder,
21
20
  Tracking,
22
21
  TrackingRecorder,
@@ -144,40 +143,27 @@ class EventSourcedProjection(Application, ABC):
144
143
  def process_event(
145
144
  self, domain_event: DomainEventProtocol, tracking: Tracking
146
145
  ) -> None:
147
- """Calls :func:`~eventsourcing.system.Follower.policy` method with
148
- the given :class:`~eventsourcing.domain.AggregateEvent` and a
149
- new :class:`~eventsourcing.application.ProcessingEvent` created from
150
- the given :class:`~eventsourcing.persistence.Tracking` object.
146
+ """Calls :func:`~eventsourcing.system.Follower.policy` method with the given
147
+ domain event and a new :class:`~eventsourcing.application.ProcessingEvent`
148
+ constructed with the given tracking object.
151
149
 
152
- The policy will collect any new aggregate events on the process
150
+ The policy method should collect any new aggregate events on the process
153
151
  event object.
154
152
 
155
- After the policy method returns, the process event object will
156
- then be recorded by calling
157
- :func:`~eventsourcing.application.Application.record`, which
158
- will return new notifications.
153
+ After the policy method returns, the processing event object will be recorded
154
+ by calling :py:func:`~eventsourcing.application.Application._record`,
155
+ which then returns list of :py:class:`~eventsourcing.persistence.Recording`.
159
156
 
160
- After calling
161
- :func:`~eventsourcing.application.Application.take_snapshots`,
162
- the new notifications are passed to the
163
- :func:`~eventsourcing.application.Application.notify` method.
157
+ After calling :func:`~eventsourcing.application.Application._take_snapshots`,
158
+ the recordings are passed in a call to
159
+ :py:func:`~eventsourcing.application.Application._notify`.
164
160
  """
165
161
  processing_event = ProcessingEvent(tracking=tracking)
166
162
  self.policy(domain_event, processing_event)
167
- try:
168
- recordings = self._record(processing_event)
169
- except IntegrityError:
170
- if self.recorder.has_tracking_id(
171
- tracking.application_name,
172
- tracking.notification_id,
173
- ):
174
- pass
175
- else:
176
- raise
177
- else:
178
- self._take_snapshots(processing_event)
179
- self.notify(processing_event.events)
180
- self._notify(recordings)
163
+ recordings = self._record(processing_event)
164
+ self._take_snapshots(processing_event)
165
+ self.notify(processing_event.events)
166
+ self._notify(recordings)
181
167
 
182
168
  @singledispatchmethod
183
169
  def policy(
eventsourcing/sqlite.py CHANGED
@@ -212,6 +212,7 @@ class SQLiteDatastore:
212
212
  pool_timeout: float = 5.0,
213
213
  max_age: float | None = None,
214
214
  pre_ping: bool = False,
215
+ single_row_tracking: bool = True,
215
216
  ):
216
217
  self.pool = SQLiteConnectionPool(
217
218
  db_name=db_name,
@@ -222,6 +223,7 @@ class SQLiteDatastore:
222
223
  max_age=max_age,
223
224
  pre_ping=pre_ping,
224
225
  )
226
+ self.single_row_tracking = single_row_tracking
225
227
 
226
228
  @contextmanager
227
229
  def transaction(self, *, commit: bool) -> Iterator[SQLiteCursor]:
@@ -260,8 +262,11 @@ class SQLiteRecorder(Recorder):
260
262
 
261
263
  def create_table(self) -> None:
262
264
  with self.datastore.transaction(commit=True) as c:
263
- for statement in self.create_table_statements:
264
- c.execute(statement)
265
+ self._create_table(c)
266
+
267
+ def _create_table(self, c: SQLiteCursor) -> None:
268
+ for statement in self.create_table_statements:
269
+ c.execute(statement)
265
270
 
266
271
 
267
272
  class SQLiteAggregateRecorder(SQLiteRecorder, AggregateRecorder):
@@ -484,27 +489,104 @@ class SQLiteTrackingRecorder(SQLiteRecorder, TrackingRecorder):
484
489
  **kwargs: Any,
485
490
  ):
486
491
  super().__init__(datastore, **kwargs)
487
- self.insert_tracking_statement = "INSERT INTO tracking VALUES (?,?)"
492
+ self.tracking_table_exists: bool = False
493
+ self.tracking_migration_previous: int | None = None
494
+ self.tracking_migration_current: int | None = None
495
+ self.table_migration_identifier = "__migration__"
496
+ self.has_checked_for_multi_row_tracking_table: bool = False
497
+ if self.datastore.single_row_tracking:
498
+ self.insert_tracking_statement = (
499
+ "INSERT INTO tracking "
500
+ "VALUES (:application_name, :notification_id) "
501
+ "ON CONFLICT (application_name) DO UPDATE "
502
+ "SET notification_id = :notification_id "
503
+ "WHERE tracking.notification_id < :notification_id "
504
+ "RETURNING notification_id"
505
+ )
506
+ else:
507
+ self.insert_tracking_statement = (
508
+ "INSERT INTO tracking VALUES (:application_name, :notification_id)"
509
+ )
488
510
  self.select_max_tracking_id_statement = (
489
511
  "SELECT MAX(notification_id) FROM tracking WHERE application_name=?"
490
512
  )
491
- self.count_tracking_id_statement = (
492
- "SELECT COUNT(*) FROM tracking WHERE "
493
- "application_name=? AND notification_id=?"
494
- )
495
513
 
496
514
  def construct_create_table_statements(self) -> list[str]:
497
515
  statements = super().construct_create_table_statements()
498
- statements.append(
499
- "CREATE TABLE IF NOT EXISTS tracking ("
500
- "application_name TEXT, "
501
- "notification_id INTEGER, "
502
- "PRIMARY KEY "
503
- "(application_name, notification_id)) "
504
- "WITHOUT ROWID"
505
- )
516
+ if self.datastore.single_row_tracking:
517
+ statements.append(
518
+ "CREATE TABLE IF NOT EXISTS tracking ("
519
+ "application_name TEXT, "
520
+ "notification_id INTEGER, "
521
+ "PRIMARY KEY "
522
+ "(application_name)) "
523
+ "WITHOUT ROWID"
524
+ )
525
+ else:
526
+ statements.append(
527
+ "CREATE TABLE IF NOT EXISTS tracking ("
528
+ "application_name TEXT, "
529
+ "notification_id INTEGER, "
530
+ "PRIMARY KEY "
531
+ "(application_name, notification_id)) "
532
+ "WITHOUT ROWID"
533
+ )
506
534
  return statements
507
535
 
536
+ def create_table(self) -> None:
537
+ # Get the migration version.
538
+ try:
539
+ self.tracking_migration_current = self.tracking_migration_previous = (
540
+ self.max_tracking_id(self.table_migration_identifier)
541
+ )
542
+ except OperationalError:
543
+ pass
544
+ else:
545
+ self.tracking_table_exists = True
546
+ super().create_table()
547
+ if (
548
+ not self.datastore.single_row_tracking
549
+ and self.tracking_migration_current is not None
550
+ ):
551
+ msg = "Can't do multi-row tracking with single-row tracking table"
552
+ raise OperationalError(msg)
553
+
554
+ def _create_table(self, c: SQLiteCursor) -> None:
555
+ max_tracking_ids: dict[str, int] = {}
556
+ if (
557
+ self.datastore.single_row_tracking
558
+ and self.tracking_table_exists
559
+ and not self.tracking_migration_previous
560
+ ):
561
+ # Migrate tracking to use single-row per application name.
562
+ # - Get all application names.
563
+ c.execute("SELECT DISTINCT application_name FROM tracking")
564
+ application_names: list[str] = [
565
+ select_row["application_name"] for select_row in c.fetchall()
566
+ ]
567
+
568
+ # - Get max tracking ID for each application name.
569
+ for application_name in application_names:
570
+ c.execute(self.select_max_tracking_id_statement, (application_name,))
571
+ max_tracking_id_row = c.fetchone()
572
+ assert max_tracking_id_row is not None
573
+ max_tracking_ids[application_name] = max_tracking_id_row[0]
574
+ # - Rename the table.
575
+ drop_table_statement = "ALTER TABLE tracking RENAME TO old1_tracking"
576
+ c.execute(drop_table_statement)
577
+ # Create the table.
578
+ super()._create_table(c)
579
+ # - Maybe insert migration tracking record and application tracking records.
580
+ if self.datastore.single_row_tracking and (
581
+ not self.tracking_table_exists
582
+ or (self.tracking_table_exists and not self.tracking_migration_previous)
583
+ ):
584
+ # - Assume we just created a table for single-row tracking.
585
+ self._insert_tracking(c, Tracking(self.table_migration_identifier, 1))
586
+ self.tracking_migration_current = 1
587
+ for application_name, max_tracking_id in max_tracking_ids.items():
588
+ self._insert_tracking(c, Tracking(application_name, max_tracking_id))
589
+
508
590
  def insert_tracking(self, tracking: Tracking) -> None:
509
591
  with self.datastore.transaction(commit=True) as c:
510
592
  self._insert_tracking(c, tracking)
@@ -514,29 +596,42 @@ class SQLiteTrackingRecorder(SQLiteRecorder, TrackingRecorder):
514
596
  c: SQLiteCursor,
515
597
  tracking: Tracking,
516
598
  ) -> None:
599
+ self._check_has_multi_row_tracking_table(c)
600
+
517
601
  c.execute(
518
602
  self.insert_tracking_statement,
519
- (
520
- tracking.application_name,
521
- tracking.notification_id,
522
- ),
603
+ {
604
+ "application_name": tracking.application_name,
605
+ "notification_id": tracking.notification_id,
606
+ },
523
607
  )
608
+ if self.datastore.single_row_tracking:
609
+ fetchone = c.fetchone()
610
+ if fetchone is None:
611
+ msg = (
612
+ "Failed to record tracking for "
613
+ f"{tracking.application_name} {tracking.notification_id}"
614
+ )
615
+ raise IntegrityError(msg)
616
+
617
+ def _check_has_multi_row_tracking_table(self, c: SQLiteCursor) -> None:
618
+ if (
619
+ not self.datastore.single_row_tracking
620
+ and not self.has_checked_for_multi_row_tracking_table
621
+ and self._max_tracking_id(self.table_migration_identifier, c)
622
+ ):
623
+ msg = "Can't do multi-row tracking with single-row tracking table"
624
+ raise OperationalError(msg)
625
+ self.has_checked_for_multi_row_tracking_table = True
524
626
 
525
627
  def max_tracking_id(self, application_name: str) -> int | None:
526
- params = [application_name]
527
- with self.datastore.transaction(commit=False) as c:
528
- c.execute(self.select_max_tracking_id_statement, params)
529
- return c.fetchone()[0]
530
-
531
- def has_tracking_id(
532
- self, application_name: str, notification_id: int | None
533
- ) -> bool:
534
- if notification_id is None:
535
- return True
536
- params = [application_name, notification_id]
537
628
  with self.datastore.transaction(commit=False) as c:
538
- c.execute(self.count_tracking_id_statement, params)
539
- return bool(c.fetchone()[0])
629
+ return self._max_tracking_id(application_name, c)
630
+
631
+ def _max_tracking_id(self, application_name: str, c: SQLiteCursor) -> int | None:
632
+ params = [application_name]
633
+ c.execute(self.select_max_tracking_id_statement, params)
634
+ return c.fetchone()[0]
540
635
 
541
636
 
542
637
  class SQLiteProcessRecorder(
@@ -568,6 +663,7 @@ class SQLiteProcessRecorder(
568
663
  class SQLiteFactory(InfrastructureFactory[SQLiteTrackingRecorder]):
569
664
  SQLITE_DBNAME = "SQLITE_DBNAME"
570
665
  SQLITE_LOCK_TIMEOUT = "SQLITE_LOCK_TIMEOUT"
666
+ SQLITE_SINGLE_ROW_TRACKING = "SINGLE_ROW_TRACKING"
571
667
  CREATE_TABLE = "CREATE_TABLE"
572
668
 
573
669
  aggregate_recorder_class = SQLiteAggregateRecorder
@@ -603,7 +699,15 @@ class SQLiteFactory(InfrastructureFactory[SQLiteTrackingRecorder]):
603
699
  )
604
700
  raise OSError(msg) from None
605
701
 
606
- self.datastore = SQLiteDatastore(db_name=db_name, lock_timeout=lock_timeout)
702
+ single_row_tracking = strtobool(
703
+ self.env.get(self.SQLITE_SINGLE_ROW_TRACKING, "t")
704
+ )
705
+
706
+ self.datastore = SQLiteDatastore(
707
+ db_name=db_name,
708
+ lock_timeout=lock_timeout,
709
+ single_row_tracking=single_row_tracking,
710
+ )
607
711
 
608
712
  def aggregate_recorder(self, purpose: str = "events") -> AggregateRecorder:
609
713
  events_table_name = "stored_" + purpose
@@ -761,15 +761,17 @@ class TrackingRecorderTestCase(TestCase, ABC):
761
761
  tracking_recorder = self.create_recorder()
762
762
 
763
763
  # Construct tracking objects.
764
- tracking1 = Tracking(notification_id=21, application_name="upstream1")
765
- tracking2 = Tracking(notification_id=22, application_name="upstream1")
766
- tracking3 = Tracking(notification_id=21, application_name="upstream2")
764
+ tracking1 = Tracking("upstream1", 21)
765
+ tracking2 = Tracking("upstream1", 22)
766
+ tracking3 = Tracking("upstream2", 21)
767
767
 
768
768
  # Insert tracking objects.
769
769
  tracking_recorder.insert_tracking(tracking=tracking1)
770
770
  tracking_recorder.insert_tracking(tracking=tracking2)
771
771
  tracking_recorder.insert_tracking(tracking=tracking3)
772
772
 
773
+ # raise Exception(tracking_recorder.max_tracking_id(tracking1.application_name))
774
+
773
775
  # Fail to insert same tracking object twice.
774
776
  with self.assertRaises(IntegrityError):
775
777
  tracking_recorder.insert_tracking(tracking=tracking1)
@@ -778,16 +780,37 @@ class TrackingRecorderTestCase(TestCase, ABC):
778
780
  with self.assertRaises(IntegrityError):
779
781
  tracking_recorder.insert_tracking(tracking=tracking3)
780
782
 
781
- # Get latest tracked position.
783
+ # Get max tracking ID.
782
784
  self.assertEqual(tracking_recorder.max_tracking_id("upstream1"), 22)
783
785
  self.assertEqual(tracking_recorder.max_tracking_id("upstream2"), 21)
784
786
  self.assertIsNone(tracking_recorder.max_tracking_id("upstream3"))
785
787
 
786
788
  # Check if an event notification has been processed.
787
- assert tracking_recorder.has_tracking_id("upstream1", 21)
788
- assert tracking_recorder.has_tracking_id("upstream1", 22)
789
- assert tracking_recorder.has_tracking_id("upstream2", 21)
790
- assert not tracking_recorder.has_tracking_id("upstream2", 22)
789
+ self.assertTrue(tracking_recorder.has_tracking_id("upstream1", None))
790
+ self.assertTrue(tracking_recorder.has_tracking_id("upstream1", 20))
791
+ self.assertTrue(tracking_recorder.has_tracking_id("upstream1", 21))
792
+ self.assertTrue(tracking_recorder.has_tracking_id("upstream1", 22))
793
+ self.assertFalse(tracking_recorder.has_tracking_id("upstream1", 23))
794
+
795
+ self.assertTrue(tracking_recorder.has_tracking_id("upstream2", None))
796
+ self.assertTrue(tracking_recorder.has_tracking_id("upstream2", 20))
797
+ self.assertTrue(tracking_recorder.has_tracking_id("upstream2", 21))
798
+ self.assertFalse(tracking_recorder.has_tracking_id("upstream2", 22))
799
+
800
+ self.assertTrue(tracking_recorder.has_tracking_id("upstream2", None))
801
+ self.assertFalse(tracking_recorder.has_tracking_id("upstream2", 22))
802
+
803
+ # Construct more tracking objects.
804
+ tracking4 = Tracking("upstream1", 23)
805
+ tracking5 = Tracking("upstream1", 24)
806
+
807
+ tracking_recorder.insert_tracking(tracking5)
808
+
809
+ # Can't fill in the gap.
810
+ with self.assertRaises(IntegrityError):
811
+ tracking_recorder.insert_tracking(tracking4)
812
+
813
+ self.assertTrue(tracking_recorder.has_tracking_id("upstream1", 23))
791
814
 
792
815
  def test_wait(self) -> None:
793
816
  tracking_recorder = self.create_recorder()
@@ -918,14 +941,15 @@ class ProcessRecorderTestCase(TestCase, ABC):
918
941
  self.assertFalse(recorder.has_tracking_id("upstream_app", 1))
919
942
  self.assertFalse(recorder.has_tracking_id("upstream_app", 2))
920
943
  self.assertFalse(recorder.has_tracking_id("upstream_app", 3))
944
+ self.assertFalse(recorder.has_tracking_id("upstream_app", 4))
921
945
 
922
946
  tracking1 = Tracking(
923
947
  application_name="upstream_app",
924
948
  notification_id=1,
925
949
  )
926
- tracking2 = Tracking(
950
+ tracking3 = Tracking(
927
951
  application_name="upstream_app",
928
- notification_id=2,
952
+ notification_id=3,
929
953
  )
930
954
 
931
955
  recorder.insert_events(
@@ -936,15 +960,55 @@ class ProcessRecorderTestCase(TestCase, ABC):
936
960
  self.assertTrue(recorder.has_tracking_id("upstream_app", 1))
937
961
  self.assertFalse(recorder.has_tracking_id("upstream_app", 2))
938
962
  self.assertFalse(recorder.has_tracking_id("upstream_app", 3))
963
+ self.assertFalse(recorder.has_tracking_id("upstream_app", 4))
939
964
 
940
965
  recorder.insert_events(
941
966
  stored_events=[],
942
- tracking=tracking2,
967
+ tracking=tracking3,
943
968
  )
944
969
 
945
970
  self.assertTrue(recorder.has_tracking_id("upstream_app", 1))
946
971
  self.assertTrue(recorder.has_tracking_id("upstream_app", 2))
947
- self.assertFalse(recorder.has_tracking_id("upstream_app", 3))
972
+ self.assertTrue(recorder.has_tracking_id("upstream_app", 3))
973
+ self.assertFalse(recorder.has_tracking_id("upstream_app", 4))
974
+
975
+ def test_raises_when_lower_inserted_later(self) -> None:
976
+ # Construct the recorder.
977
+ recorder = self.create_recorder()
978
+
979
+ tracking1 = Tracking(
980
+ application_name="upstream_app",
981
+ notification_id=1,
982
+ )
983
+ tracking2 = Tracking(
984
+ application_name="upstream_app",
985
+ notification_id=2,
986
+ )
987
+
988
+ # Insert tracking info.
989
+ recorder.insert_events(
990
+ stored_events=[],
991
+ tracking=tracking2,
992
+ )
993
+
994
+ # Get current position.
995
+ self.assertEqual(
996
+ recorder.max_tracking_id("upstream_app"),
997
+ 2,
998
+ )
999
+
1000
+ # Insert tracking info.
1001
+ with self.assertRaises(IntegrityError):
1002
+ recorder.insert_events(
1003
+ stored_events=[],
1004
+ tracking=tracking1,
1005
+ )
1006
+
1007
+ # Get current position.
1008
+ self.assertEqual(
1009
+ recorder.max_tracking_id("upstream_app"),
1010
+ 2,
1011
+ )
948
1012
 
949
1013
  def test_performance(self) -> None:
950
1014
  # Construct the recorder.
@@ -1,7 +1,6 @@
1
1
  import psycopg
2
2
  from psycopg.sql import SQL, Identifier
3
3
 
4
- from eventsourcing.persistence import PersistenceError
5
4
  from eventsourcing.postgres import PostgresDatastore
6
5
 
7
6
 
@@ -44,12 +43,9 @@ def pg_close_all_connections(
44
43
 
45
44
 
46
45
  def drop_postgres_table(datastore: PostgresDatastore, table_name: str) -> None:
47
- statement = SQL("DROP TABLE {0}.{1}").format(
46
+ # print(f"Dropping table {datastore.schema}.{table_name}")
47
+ statement = SQL("DROP TABLE IF EXISTS {0}.{1}").format(
48
48
  Identifier(datastore.schema), Identifier(table_name)
49
49
  )
50
- # print(f"Dropping table {datastore.schema}.{table_name}")
51
- try:
52
- with datastore.transaction(commit=True) as curs:
53
- curs.execute(statement, prepare=False)
54
- except PersistenceError:
55
- pass
50
+ with datastore.transaction(commit=True) as curs:
51
+ curs.execute(statement, prepare=False)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: eventsourcing
3
- Version: 9.4.2
3
+ Version: 9.4.4
4
4
  Summary: Event sourcing in Python
5
5
  License: BSD-3-Clause
6
6
  Keywords: event sourcing,event store,domain driven design,domain-driven design,ddd,cqrs,cqs
@@ -24,10 +24,11 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
24
  Provides-Extra: crypto
25
25
  Provides-Extra: cryptography
26
26
  Provides-Extra: postgres
27
- Requires-Dist: cryptography (>=44.0,<45.0) ; extra == "cryptography"
28
- Requires-Dist: psycopg[pool] (>=3.2,<3.3) ; extra == "postgres"
29
- Requires-Dist: pycryptodome (>=3.22,<4.0) ; extra == "crypto"
27
+ Requires-Dist: cryptography (>=44.0) ; extra == "cryptography"
28
+ Requires-Dist: psycopg[pool] (>=3.2) ; extra == "postgres"
29
+ Requires-Dist: pycryptodome (>=3.22) ; extra == "crypto"
30
30
  Requires-Dist: typing_extensions
31
+ Project-URL: Documentation, https://eventsourcing.readthedocs.io/
31
32
  Project-URL: Homepage, https://github.com/pyeventsourcing/eventsourcing
32
33
  Project-URL: Repository, https://github.com/pyeventsourcing/eventsourcing
33
34
  Description-Content-Type: text/markdown
@@ -207,7 +208,7 @@ There are projects that adapt popular ORMs such as
207
208
  and [SQLAlchemy](https://github.com/pyeventsourcing/eventsourcing-sqlalchemy#readme).
208
209
  There are projects that adapt specialist event stores such as
209
210
  [Axon Server](https://github.com/pyeventsourcing/eventsourcing-axonserver#readme) and
210
- [EventStoreDB](https://github.com/pyeventsourcing/eventsourcing-eventstoredb#readme).
211
+ [KurrentDB](https://github.com/pyeventsourcing/eventsourcing-kurrentdb#readme).
211
212
  There are projects that support popular NoSQL databases such as
212
213
  [DynamoDB](https://github.com/pyeventsourcing/eventsourcing-dynamodb#readme).
213
214
  There are also projects that provide examples of using the
@@ -3,24 +3,24 @@ eventsourcing/application.py,sha256=K3M9_Rh2jaYzBDPMvmyemLXHQ4GsGfGsxmMfUeVSqXw,
3
3
  eventsourcing/cipher.py,sha256=ulTBtX5K9ejRAkdUaUbdIaj4H7anYwDOi7JxOolj2uo,3295
4
4
  eventsourcing/compressor.py,sha256=qEYWvsUXFLyhKgfuv-HGNJ6VF4sRw4z0IxbNW9ukOfc,385
5
5
  eventsourcing/cryptography.py,sha256=aFZLlJxxSb5seVbh94-T8FA_RIGOe-VFu5SJrbOnwUU,2969
6
- eventsourcing/dispatch.py,sha256=j03cIVPziq6LFEgJxvQMMIPlixuZ4bB8ynXXdd_Tj8Q,2740
6
+ eventsourcing/dispatch.py,sha256=-yI-0EpyXnpMBkciTHNPlxSHJebUe7Ko9rT-gdOjoIo,2797
7
7
  eventsourcing/domain.py,sha256=2c33FfhVIBcUzhJa6TMhGPDwOma-wGiPHUL8RC8ZokQ,62967
8
8
  eventsourcing/interface.py,sha256=-VLoqcd9a0PXpD_Bv0LjCiG21xLREG6tXK6phgtShOw,5035
9
- eventsourcing/persistence.py,sha256=y_1o3LNi9tkOTqkvjgsGF4un4XPXEgxzt0Iwhk7UzEI,46340
10
- eventsourcing/popo.py,sha256=xZD6mig7bVwAoHe-UdraXvuu2iL5a8b2b41cEcBHlBU,9642
11
- eventsourcing/postgres.py,sha256=LGEsfAN_cUOL6xX0NT4gxVcu2Btma95WLqtYcEzdPSA,37757
12
- eventsourcing/projection.py,sha256=2qkagy2o1sts22LqndYZzsDV7AJK28oGkPrFH1ECnYM,15069
9
+ eventsourcing/persistence.py,sha256=LrIjdEmMhM2Pz_ozGO_uOAQ099-yW92htaxO63vntwA,46406
10
+ eventsourcing/popo.py,sha256=8LvOmAdwVRArhvWVsmfeHSmZ4B1CVdlR2KggwVbmeWU,9131
11
+ eventsourcing/postgres.py,sha256=e1UGfc1qIX4RxLmUzneT3rjsV75Cres06Uckbrv4euo,43741
12
+ eventsourcing/projection.py,sha256=X73BHLq37bXNm9FpNYA3O1plqPunxQmi-vu0DuFiugw,14727
13
13
  eventsourcing/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- eventsourcing/sqlite.py,sha256=1DRQgDE1S7lz7Mnz9QH6WZ4luMjI9tof1YpH5qrK9u8,22076
14
+ eventsourcing/sqlite.py,sha256=8GJ6rKhX1z6CN0gXQIn6wh5pWmGvzOuMHtxcp0bO1SE,26696
15
15
  eventsourcing/system.py,sha256=WCfuSc45A9A1fFO7zpDum_ddh4pU7x-vEcpVZ_ycAyE,44358
16
16
  eventsourcing/tests/__init__.py,sha256=FtOyuj-L-oSisYeByTIrnUw-XzsctSbq76XmjPy5fMc,102
17
17
  eventsourcing/tests/application.py,sha256=pAn9Cugp_1rjOtj_nruGhh7PxdrQWibDlrnOAlOKXwo,20614
18
18
  eventsourcing/tests/domain.py,sha256=yN-F6gMRumeX6nIXIcZGxAR3RrUslzmEMM8JksnkI8Q,3227
19
- eventsourcing/tests/persistence.py,sha256=jy_aMQwRGKQKohw8Ji7oBf1aFIdcHLHiSrHxH6yHvzw,58654
20
- eventsourcing/tests/postgres_utils.py,sha256=0ywklGp6cXZ5PmV8ANVkwSHsZZCl5zTmOk7iG-RmrCE,1548
19
+ eventsourcing/tests/persistence.py,sha256=TMi4gnlBfcd7XqFlWPSsNxwnEKhaPpXu-dHHgqJUs4I,60816
20
+ eventsourcing/tests/postgres_utils.py,sha256=y-2ZrCZtHPqjfvBQqahtbTvvasFX2GGaMikG1BSK81A,1444
21
21
  eventsourcing/utils.py,sha256=pOnczXzaE5q7UbQbPmgcpWaP660fsmfiDJs6Gmo8QCM,8558
22
- eventsourcing-9.4.2.dist-info/AUTHORS,sha256=8aHOM4UbNZcKlD-cHpFRcM6RWyCqtwtxRev6DeUgVRs,137
23
- eventsourcing-9.4.2.dist-info/LICENSE,sha256=CQEQzcZO8AWXL5i3hIo4yVKrYjh2FBz6hCM7kpXWpw4,1512
24
- eventsourcing-9.4.2.dist-info/METADATA,sha256=agR-sOEFNlfqTmHsaQ4rIyxFoIROU_K6B4fh0Lo5ZL8,9959
25
- eventsourcing-9.4.2.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
26
- eventsourcing-9.4.2.dist-info/RECORD,,
22
+ eventsourcing-9.4.4.dist-info/AUTHORS,sha256=8aHOM4UbNZcKlD-cHpFRcM6RWyCqtwtxRev6DeUgVRs,137
23
+ eventsourcing-9.4.4.dist-info/LICENSE,sha256=CQEQzcZO8AWXL5i3hIo4yVKrYjh2FBz6hCM7kpXWpw4,1512
24
+ eventsourcing-9.4.4.dist-info/METADATA,sha256=C2sTYGk8meFDKMJn_w7GvoG37_9MHGMmJUgtWYhmvys,10003
25
+ eventsourcing-9.4.4.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
26
+ eventsourcing-9.4.4.dist-info/RECORD,,