plexus-python-pulse-common 1.1.85__py3-none-any.whl → 1.1.87__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.
@@ -948,6 +948,14 @@ def make_snapshot_model_trigger[SnapshotModelT: SnapshotModelMixin](engine: sa.E
948
948
  NEW."expired_at" := NULL;
949
949
  NEW."record_sqn" := nextval('{record_sqn_seq_name}');
950
950
  ELSE
951
+ -- Serialize transitions even when there is no active version.
952
+ PERFORM "sqn" FROM "{table_name}"
953
+ WHERE "record_sqn" = NEW."record_sqn"
954
+ ORDER BY "sqn" LIMIT 1 FOR UPDATE;
955
+ IF NOT FOUND THEN
956
+ RAISE EXCEPTION 'record_sqn % does not exist', NEW."record_sqn";
957
+ END IF;
958
+
951
959
  IF NEW."created_at" IS NULL THEN
952
960
  NEW."created_at" := CURRENT_TIMESTAMP;
953
961
  END IF;
@@ -1019,6 +1027,14 @@ def make_revision_model_trigger[RevisionModelT: RevisionModelMixin](engine: sa.E
1019
1027
  NEW."record_sqn" := nextval('{record_sqn_seq_name}');
1020
1028
  NEW."revision" := 1;
1021
1029
  ELSE
1030
+ -- Serialize transitions even when there is no active version.
1031
+ PERFORM "sqn" FROM "{table_name}"
1032
+ WHERE "record_sqn" = NEW."record_sqn"
1033
+ ORDER BY "sqn" LIMIT 1 FOR UPDATE;
1034
+ IF NOT FOUND THEN
1035
+ RAISE EXCEPTION 'record_sqn % does not exist', NEW."record_sqn";
1036
+ END IF;
1037
+
1022
1038
  SELECT MAX("created_at") INTO NEW."created_at"
1023
1039
  FROM "{table_name}"
1024
1040
  WHERE "record_sqn" = NEW."record_sqn";
@@ -1070,6 +1086,7 @@ def clone_sequence_model_instance[SequenceModelT: SequenceModelMixin](
1070
1086
  return instance
1071
1087
  if clear_meta_fields:
1072
1088
  result.sqn = None
1089
+ result.model_fields_set.discard("sqn")
1073
1090
  return result
1074
1091
 
1075
1092
 
@@ -1087,6 +1104,9 @@ def clone_changing_model_instance[ChangingModelT: ChangingModelMixin](
1087
1104
  result.sqn = None
1088
1105
  result.created_at = None
1089
1106
  result.updated_at = None
1107
+ result.model_fields_set.discard("sqn")
1108
+ result.model_fields_set.discard("created_at")
1109
+ result.model_fields_set.discard("updated_at")
1090
1110
  return result
1091
1111
 
1092
1112
 
@@ -1105,6 +1125,10 @@ def clone_snapshot_model_instance[SnapshotModelT: SnapshotModelMixin](
1105
1125
  result.created_at = None
1106
1126
  result.expired_at = None
1107
1127
  result.record_sqn = None
1128
+ result.model_fields_set.discard("sqn")
1129
+ result.model_fields_set.discard("created_at")
1130
+ result.model_fields_set.discard("expired_at")
1131
+ result.model_fields_set.discard("record_sqn")
1108
1132
  return result
1109
1133
 
1110
1134
 
@@ -1125,6 +1149,12 @@ def clone_revision_model_instance[RevisionModelT: RevisionModelMixin](
1125
1149
  result.expired_at = None
1126
1150
  result.record_sqn = None
1127
1151
  result.revision = None
1152
+ result.model_fields_set.discard("sqn")
1153
+ result.model_fields_set.discard("created_at")
1154
+ result.model_fields_set.discard("updated_at")
1155
+ result.model_fields_set.discard("expired_at")
1156
+ result.model_fields_set.discard("record_sqn")
1157
+ result.model_fields_set.discard("revision")
1128
1158
  return result
1129
1159
 
1130
1160
 
@@ -1144,6 +1174,41 @@ def db_make_order_by_clause[SequenceModelT: SequenceModelMixin](
1144
1174
  return order_criteria
1145
1175
 
1146
1176
 
1177
+ def db_lock_model_record[ModelT: RevisionModelMixin | SnapshotModelMixin](
1178
+ db: sa_orm.Session,
1179
+ model: type[ModelT],
1180
+ record_sqn: int,
1181
+ ) -> None:
1182
+ """
1183
+ Lock the first history row, including when the record is inactive.
1184
+
1185
+ Select by ``record_sqn`` rather than assuming it equals the first ``sqn``: trigger-managed
1186
+ records use a separate sequence. Every lifecycle writer must acquire this same lock before
1187
+ reading current state.
1188
+ Locks last until caller commit or rollback. Do not delete the first history row.
1189
+ """
1190
+ db_anchor = (
1191
+ db
1192
+ .query(model)
1193
+ .where(model.record_sqn == record_sqn)
1194
+ .order_by(model.sqn)
1195
+ .limit(1)
1196
+ .with_for_update()
1197
+ .populate_existing()
1198
+ .one_or_none()
1199
+ )
1200
+ if db_anchor is None:
1201
+ raise sa_exc.NoResultFound(f"'{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1202
+
1203
+
1204
+ # Mutating helpers flush but never begin, commit, or roll back transactions. Callers must roll
1205
+ # back on failure. Row locks serialize cooperating writers on databases supporting SELECT FOR UPDATE;
1206
+ # SQLite callers must serialize writes themselves (e.g. begin an IMMEDIATE transaction before
1207
+ # reading).
1208
+ # At stronger isolation levels, callers must also handle serialization failures by retrying
1209
+ # transactions. History rows used as lock anchors must not be deleted while a record exists.
1210
+
1211
+
1147
1212
  def db_create_sequence_model[SequenceModelT: SequenceModelMixin](
1148
1213
  db: sa_orm.Session,
1149
1214
  model: type[SequenceModelT],
@@ -1201,11 +1266,11 @@ def db_update_sequence_model[SequenceModelT: SequenceModelMixin](
1201
1266
  instance: SequenceModelMixin,
1202
1267
  sqn: int,
1203
1268
  ) -> SequenceModelT:
1204
- db_instance = db.query(model).where(model.sqn == sqn).one_or_none()
1269
+ db_instance = db.query(model).where(model.sqn == sqn).with_for_update().populate_existing().one_or_none()
1205
1270
  if db_instance is None:
1206
1271
  raise sa_exc.NoResultFound(f"'{model_name_of(model)}' of specified sqn '{sqn}' not found")
1207
1272
 
1208
- db_instance = model_copy_from(db_instance, clone_sequence_model_instance(model, instance), exclude_none=True)
1273
+ db_instance = model_copy_from(db_instance, clone_sequence_model_instance(model, instance), exclude_unset=True)
1209
1274
  clone_sequence_model_instance(model, db_instance, validate_only=True)
1210
1275
  db.flush()
1211
1276
 
@@ -1217,7 +1282,7 @@ def db_delete_sequence_model[SequenceModelT: SequenceModelMixin](
1217
1282
  model: type[SequenceModelT],
1218
1283
  sqn: int,
1219
1284
  ) -> SequenceModelT:
1220
- db_instance = db.query(model).where(model.sqn == sqn).one_or_none()
1285
+ db_instance = db.query(model).where(model.sqn == sqn).with_for_update().populate_existing().one_or_none()
1221
1286
  if db_instance is None:
1222
1287
  raise sa_exc.NoResultFound(f"'{model_name_of(model)}' of specified sqn '{sqn}' not found")
1223
1288
 
@@ -1265,11 +1330,11 @@ def db_update_changing_model[ChangingModelT: ChangingModelMixin](
1265
1330
  sqn: int,
1266
1331
  updated_at: datetime.datetime,
1267
1332
  ) -> ChangingModelT:
1268
- db_instance = db.query(model).where(model.sqn == sqn).one_or_none()
1333
+ db_instance = db.query(model).where(model.sqn == sqn).with_for_update().populate_existing().one_or_none()
1269
1334
  if db_instance is None:
1270
1335
  raise sa_exc.NoResultFound(f"'{model_name_of(model)}' of specified sqn '{sqn}' not found")
1271
1336
 
1272
- db_instance = model_copy_from(db_instance, clone_changing_model_instance(model, instance), exclude_none=True)
1337
+ db_instance = model_copy_from(db_instance, clone_changing_model_instance(model, instance), exclude_unset=True)
1273
1338
  db_instance.updated_at = updated_at
1274
1339
  clone_changing_model_instance(model, db_instance, validate_only=True)
1275
1340
  db.flush()
@@ -1425,7 +1490,14 @@ def db_update_snapshot_model[SnapshotModelT: SnapshotModelMixin](
1425
1490
  record_sqn: int,
1426
1491
  updated_at: datetime.datetime,
1427
1492
  ) -> SnapshotModelT:
1428
- db_instance = db.query(model).where(model.record_sqn == record_sqn, model.expired_at.is_(None)).one_or_none()
1493
+ db_lock_model_record(db, model, record_sqn)
1494
+ db_instance = (
1495
+ db
1496
+ .query(model)
1497
+ .where(model.record_sqn == record_sqn, model.expired_at.is_(None))
1498
+ .populate_existing()
1499
+ .one_or_none()
1500
+ )
1429
1501
  if db_instance is None:
1430
1502
  raise sa_exc.NoResultFound(f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1431
1503
 
@@ -1450,10 +1522,12 @@ def db_expire_snapshot_model[SnapshotModelT: SnapshotModelMixin](
1450
1522
  record_sqn: int,
1451
1523
  updated_at: datetime.datetime,
1452
1524
  ) -> SnapshotModelT:
1525
+ db_lock_model_record(db, model, record_sqn)
1453
1526
  db_instance = (
1454
1527
  db
1455
1528
  .query(model)
1456
1529
  .where(model.record_sqn == record_sqn, model.expired_at.is_(None))
1530
+ .populate_existing()
1457
1531
  .one_or_none()
1458
1532
  )
1459
1533
  if db_instance is None:
@@ -1472,16 +1546,24 @@ def db_activate_snapshot_model[SnapshotModelT: SnapshotModelMixin](
1472
1546
  record_sqn: int,
1473
1547
  updated_at: datetime.datetime,
1474
1548
  ) -> SnapshotModelT:
1475
- db_instance = db.query(model).where(model.record_sqn == record_sqn, model.expired_at.is_(None)).one_or_none()
1549
+ db_lock_model_record(db, model, record_sqn)
1550
+ db_instance = (
1551
+ db
1552
+ .query(model)
1553
+ .where(model.record_sqn == record_sqn, model.expired_at.is_(None))
1554
+ .populate_existing()
1555
+ .one_or_none()
1556
+ )
1476
1557
  if db_instance is not None:
1477
1558
  raise sa_exc.MultipleResultsFound(
1478
- f"Active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' already exists")
1559
+ f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' already exists")
1479
1560
 
1480
1561
  db_instance = (
1481
1562
  db
1482
1563
  .query(model)
1483
1564
  .where(model.record_sqn == record_sqn, model.expired_at.is_not(None))
1484
- .order_by(model.created_at.desc())
1565
+ .order_by(model.created_at.desc(), model.sqn.desc())
1566
+ .populate_existing()
1485
1567
  .first()
1486
1568
  )
1487
1569
  if db_instance is None:
@@ -1653,7 +1735,14 @@ def db_update_revision_model[RevisionModelT: RevisionModelMixin](
1653
1735
  record_sqn: int,
1654
1736
  updated_at: datetime.datetime,
1655
1737
  ) -> RevisionModelT:
1656
- db_instance = db.query(model).where(model.record_sqn == record_sqn, model.expired_at.is_(None)).one_or_none()
1738
+ db_lock_model_record(db, model, record_sqn)
1739
+ db_instance = (
1740
+ db
1741
+ .query(model)
1742
+ .where(model.record_sqn == record_sqn, model.expired_at.is_(None))
1743
+ .populate_existing()
1744
+ .one_or_none()
1745
+ )
1657
1746
  if db_instance is None:
1658
1747
  raise sa_exc.NoResultFound(f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' not found")
1659
1748
 
@@ -1680,10 +1769,12 @@ def db_expire_revision_model[RevisionModelT: RevisionModelMixin](
1680
1769
  record_sqn: int,
1681
1770
  updated_at: datetime.datetime,
1682
1771
  ) -> RevisionModelT:
1772
+ db_lock_model_record(db, model, record_sqn)
1683
1773
  db_instance = (
1684
1774
  db
1685
1775
  .query(model)
1686
1776
  .where(model.record_sqn == record_sqn, model.expired_at.is_(None))
1777
+ .populate_existing()
1687
1778
  .one_or_none()
1688
1779
  )
1689
1780
  if db_instance is None:
@@ -1702,7 +1793,14 @@ def db_activate_revision_model[RevisionModelT: RevisionModelMixin](
1702
1793
  record_sqn: int,
1703
1794
  updated_at: datetime.datetime,
1704
1795
  ) -> RevisionModelT:
1705
- db_instance = db.query(model).where(model.record_sqn == record_sqn, model.expired_at.is_(None)).one_or_none()
1796
+ db_lock_model_record(db, model, record_sqn)
1797
+ db_instance = (
1798
+ db
1799
+ .query(model)
1800
+ .where(model.record_sqn == record_sqn, model.expired_at.is_(None))
1801
+ .populate_existing()
1802
+ .one_or_none()
1803
+ )
1706
1804
  if db_instance is not None:
1707
1805
  raise sa_exc.MultipleResultsFound(
1708
1806
  f"active '{model_name_of(model)}' of specified record_sqn '{record_sqn}' already exists")
@@ -1712,6 +1810,7 @@ def db_activate_revision_model[RevisionModelT: RevisionModelMixin](
1712
1810
  .query(model)
1713
1811
  .where(model.record_sqn == record_sqn, model.expired_at.is_not(None))
1714
1812
  .order_by(model.revision.desc())
1813
+ .populate_existing()
1715
1814
  .first()
1716
1815
  )
1717
1816
  if db_instance is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plexus-python-pulse-common
3
- Version: 1.1.85
3
+ Version: 1.1.87
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: 3.12
6
6
  Classifier: Programming Language :: Python :: 3.13
@@ -17,14 +17,14 @@ plexus/pulse/common/utils/dockerutils.py,sha256=7DF9yFGz8uHwpYk115xBWqOjs5etz0UE
17
17
  plexus/pulse/common/utils/flowutils.py,sha256=MGu15KL9mw-LyGGLceVmY-c2FGer5PURnMZWFSJN--I,10182
18
18
  plexus/pulse/common/utils/gisutils.py,sha256=Cx0ZB9-NA_7VuzbuzRArTJ5UfltG-KmvVC8YxPIgPE4,11600
19
19
  plexus/pulse/common/utils/jsonutils.py,sha256=zSbW94klN2jrkMdBECu4RNJX6zzNaSxauQ3FuihuB6E,3342
20
- plexus/pulse/common/utils/ormutils.py,sha256=FPkkdmiTZ1M0ohLTQakA8shO1MD_2mhM6NolpnD4OWo,64472
20
+ plexus/pulse/common/utils/ormutils.py,sha256=ycs8MF5EDfxx8TP4gDrD7zIunxB0N3hSvGcB4adopkA,68187
21
21
  plexus/pulse/common/utils/pathutils.py,sha256=eYfNK48wf1xZGK2IbpKxYOn4skZhWWA8OTyUwDXGMIc,9222
22
22
  plexus/pulse/common/utils/s3utils.py,sha256=5q5wSOavt-n5EdrflQX7WPGgDeWQkGO9lG5Nq3QXjCk,35904
23
23
  plexus/pulse/common/utils/sqlutils.py,sha256=D6kTBjhO5YlNRt3uFlPt6z3uH61m9ajEzPYmsI6NoFc,231
24
24
  plexus/pulse/common/utils/strutils.py,sha256=PkmMHCcHN9vALCy_8fJTf00Dg7I2N4QTCXdBmB3t7rk,16440
25
25
  plexus/pulse/common/utils/tagutils.py,sha256=V8eUxtWSijX6nHSCuRX2dTJz3why3TCOzPVK5YXt28s,66944
26
26
  plexus/pulse/common/utils/testutils.py,sha256=hKPRvoN4mg8QGQJH121l59a0uo8jN5rntGQAVYplGBw,6166
27
- plexus_python_pulse_common-1.1.85.dist-info/METADATA,sha256=khRqgCk5BE_g8XkuhmSDVTfVKQhEd_hTr6u9CrRb2-Y,1555
28
- plexus_python_pulse_common-1.1.85.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
29
- plexus_python_pulse_common-1.1.85.dist-info/top_level.txt,sha256=ug_g7CVwaMQuas5UzAXbHUrQvKGCn8ezc6ZNvvRlJOE,7
30
- plexus_python_pulse_common-1.1.85.dist-info/RECORD,,
27
+ plexus_python_pulse_common-1.1.87.dist-info/METADATA,sha256=SnylMrcOPgHpUmVJgX4jAdGVuiBl8KBOsSUlmX2U2xs,1555
28
+ plexus_python_pulse_common-1.1.87.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
29
+ plexus_python_pulse_common-1.1.87.dist-info/top_level.txt,sha256=ug_g7CVwaMQuas5UzAXbHUrQvKGCn8ezc6ZNvvRlJOE,7
30
+ plexus_python_pulse_common-1.1.87.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (84.2.0)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5