plexus-python-pulse-common 1.1.85__tar.gz → 1.1.87__tar.gz

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.
Files changed (95) hide show
  1. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/PKG-INFO +1 -1
  2. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/ormutils.py +110 -11
  3. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus_python_pulse_common.egg-info/PKG-INFO +1 -1
  4. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/ormutils_test.py +197 -3
  5. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/s3utils_test.py +47 -29
  6. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/.editorconfig +0 -0
  7. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/.github/workflows/pr.yml +0 -0
  8. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/.github/workflows/push.yml +0 -0
  9. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/.gitignore +0 -0
  10. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/MANIFEST.in +0 -0
  11. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/README.md +0 -0
  12. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/VERSION +0 -0
  13. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/pyproject.toml +0 -0
  14. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/jsonutils/dummy.0.jsonl +0 -0
  15. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/jsonutils/dummy.1.jsonl +0 -0
  16. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/jsonutils/dummy.2.jsonl +0 -0
  17. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/0-dummy +0 -0
  18. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/1-dummy +0 -0
  19. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/2-dummy +0 -0
  20. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.0.0.jsonl +0 -0
  21. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.0.0.vol-0.jsonl +0 -0
  22. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.0.jsonl +0 -0
  23. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.1.1.jsonl +0 -0
  24. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.1.1.vol-1.jsonl +0 -0
  25. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.1.jsonl +0 -0
  26. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.2.2.jsonl +0 -0
  27. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.2.2.vol-2.jsonl +0 -0
  28. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.2.jsonl +0 -0
  29. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.csv.part0 +0 -0
  30. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.csv.part1 +0 -0
  31. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.csv.part2 +0 -0
  32. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/pathutils/dummy.txt +0 -0
  33. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/s3utils/dir.baz/file.bar.baz +0 -0
  34. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/s3utils/dir.baz/file.foo.bar +0 -0
  35. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/s3utils/dir.baz/file.foo.baz +0 -0
  36. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/s3utils/dir.foo/dir.foo.bar/dir.foo.bar.baz/file.foo.bar.baz +0 -0
  37. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/s3utils/dir.foo/dir.foo.bar/file.bar.baz +0 -0
  38. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/s3utils/dir.foo/dir.foo.bar/file.foo.bar +0 -0
  39. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/s3utils/dir.foo/dir.foo.bar/file.foo.baz +0 -0
  40. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/s3utils/dir.foo/file.bar +0 -0
  41. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/s3utils/dir.foo/file.baz +0 -0
  42. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/s3utils/dir.foo/file.foo +0 -0
  43. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/s3utils_archive/archive.compressed.zip +0 -0
  44. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/resources/unittest/s3utils_archive/archive.uncompressed.zip +0 -0
  45. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/setup.cfg +0 -0
  46. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/setup.py +0 -0
  47. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/__init__.py +0 -0
  48. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/carto/OSMFile.py +0 -0
  49. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/carto/OSMNode.py +0 -0
  50. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/carto/OSMTags.py +0 -0
  51. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/carto/OSMWay.py +0 -0
  52. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/carto/__init__.py +0 -0
  53. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/resources/__init__.py +0 -0
  54. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/resources/tags/__init__.py +0 -0
  55. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/resources/tags/unittest-1.0.0.tagset.yaml +0 -0
  56. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/resources/tags/universal-1.0.0.tagset.yaml +0 -0
  57. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/__init__.py +0 -0
  58. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/apiutils.py +0 -0
  59. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/bagutils.py +0 -0
  60. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/config.py +0 -0
  61. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/datautils.py +0 -0
  62. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/dockerutils.py +0 -0
  63. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/flowutils.py +0 -0
  64. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/gisutils.py +0 -0
  65. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/jsonutils.py +0 -0
  66. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/pathutils.py +0 -0
  67. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/s3utils.py +0 -0
  68. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/sqlutils.py +0 -0
  69. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/strutils.py +0 -0
  70. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/tagutils.py +0 -0
  71. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus/pulse/common/utils/testutils.py +0 -0
  72. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus_python_pulse_common.egg-info/SOURCES.txt +0 -0
  73. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus_python_pulse_common.egg-info/dependency_links.txt +0 -0
  74. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus_python_pulse_common.egg-info/not-zip-safe +0 -0
  75. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus_python_pulse_common.egg-info/requires.txt +0 -0
  76. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/src/plexus_python_pulse_common.egg-info/top_level.txt +0 -0
  77. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/__init__.py +0 -0
  78. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/__init__.py +0 -0
  79. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/__init__.py +0 -0
  80. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/carto/__init__.py +0 -0
  81. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/carto/osm_file_test.py +0 -0
  82. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/carto/osm_tags_test.py +0 -0
  83. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/__init__.py +0 -0
  84. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/apiutils_test.py +0 -0
  85. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/bagutils_test.py +0 -0
  86. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/datautils_test.py +0 -0
  87. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/dockerutils_test.py +0 -0
  88. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/flowutils_test.py +0 -0
  89. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/gisutils_test.py +0 -0
  90. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/jsonutils_test.py +0 -0
  91. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/pathutils_test.py +0 -0
  92. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/strutils_test.py +0 -0
  93. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/tagutils_test.py +0 -0
  94. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/plexus_tests/pulse/common/utils/testutils_test.py +0 -0
  95. {plexus_python_pulse_common-1.1.85 → plexus_python_pulse_common-1.1.87}/test/testenv.py +0 -0
@@ -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
@@ -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,9 +17,17 @@ from sqlmodel import Field, SQLModel
17
17
 
18
18
  from plexus.pulse.common.utils.apiutils import managed_db_session
19
19
  from plexus.pulse.common.utils.ormutils import (
20
- changing_model_mixin, make_base_model, make_changing_model_mixin,
21
- make_revision_model_mixin, make_revision_model_trigger, make_sequence_model_mixin, make_snapshot_model_mixin,
22
- make_snapshot_model_trigger, model_copy_from, revision_model_mixin, snapshot_model_mixin,
20
+ changing_model_mixin,
21
+ make_base_model,
22
+ make_changing_model_mixin,
23
+ make_revision_model_mixin,
24
+ make_revision_model_trigger,
25
+ make_sequence_model_mixin,
26
+ make_snapshot_model_mixin,
27
+ make_snapshot_model_trigger,
28
+ model_copy_from,
29
+ revision_model_mixin,
30
+ snapshot_model_mixin,
23
31
  )
24
32
  from plexus.pulse.common.utils.ormutils import (
25
33
  db_activate_revision_model,
@@ -1266,3 +1274,189 @@ class OrmUtilsTest(unittest.TestCase):
1266
1274
  for options in ({"include": {"name": True}}, {"by_alias": True}):
1267
1275
  with self.subTest(options=options), self.assertRaises(TypeError):
1268
1276
  model_copy_from(CopyRecord(), CopyRecord(), **options)
1277
+
1278
+
1279
+ @pytest.mark.parametrize(
1280
+ "functions",
1281
+ [
1282
+ (DummySnapshotModel,
1283
+ db_create_snapshot_model,
1284
+ db_update_snapshot_model,
1285
+ db_expire_snapshot_model,
1286
+ db_activate_snapshot_model,
1287
+ ),
1288
+ (DummyRevisionModel,
1289
+ db_create_revision_model,
1290
+ db_update_revision_model,
1291
+ db_expire_revision_model,
1292
+ db_activate_revision_model,
1293
+ ),
1294
+ ],
1295
+ )
1296
+ @pytest.mark.parametrize("operation", ["update", "expire", "activate"])
1297
+ def test_lifecycle_serializes_on_history_row(fixture_postgresql_test, functions, operation):
1298
+ """An uncommitted transition blocks every other transition, even activation."""
1299
+ info = fixture_postgresql_test.info
1300
+ maker = ConnectionMaker.create(
1301
+ make_scheme(Dialects.postgresql, Drivers.psycopg),
1302
+ host=info.host,
1303
+ port=info.port,
1304
+ username=info.user,
1305
+ database=info.dbname,
1306
+ engine_opts={"connect_args": {"options": "-c timezone=UTC"}},
1307
+ )
1308
+ engine = maker.engine
1309
+ model, create, update, expire, activate = functions
1310
+ now = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
1311
+ payload = DummyModel(dummy_array=[], dummy_json={})
1312
+ try:
1313
+ DummyBaseModel.metadata.create_all(engine)
1314
+ with maker.make_session() as session, session.begin():
1315
+ record_sqn = create(session, model, payload, now).record_sqn
1316
+ # The first history row is now inactive, but remains the lock anchor.
1317
+ update(session, model, payload, record_sqn, now + datetime.timedelta(hours=1))
1318
+
1319
+ with maker.make_session() as first_session, maker.make_session() as second_session:
1320
+ # Expiration locks the anchor until the caller finishes its transaction.
1321
+ expire(first_session, model, record_sqn, now + datetime.timedelta(days=1))
1322
+ second_session.execute(sa.text("SET LOCAL lock_timeout = '200ms'"))
1323
+ with pytest.raises(sa_exc.OperationalError) as error:
1324
+ if operation == "update":
1325
+ update(second_session, model, payload, record_sqn, now + datetime.timedelta(days=2))
1326
+ elif operation == "expire":
1327
+ expire(second_session, model, record_sqn, now + datetime.timedelta(days=2))
1328
+ else:
1329
+ activate(second_session, model, record_sqn, now + datetime.timedelta(days=2))
1330
+ assert error.value.orig.sqlstate == "55P03" # lock_not_available
1331
+ second_session.rollback()
1332
+ first_session.commit()
1333
+
1334
+ # The anchor still exists with no active version; activation can proceed.
1335
+ active = activate(second_session, model, record_sqn, now + datetime.timedelta(days=2))
1336
+ assert active.expired_at is None
1337
+ second_session.commit()
1338
+ finally:
1339
+ maker.reset_engine()
1340
+
1341
+
1342
+ def test_revision_update_refreshes_cached_version(fixture_postgresql_test):
1343
+ """A long-lived session must not overwrite a concurrently expired version."""
1344
+ info = fixture_postgresql_test.info
1345
+ maker = ConnectionMaker.create(
1346
+ make_scheme(Dialects.postgresql, Drivers.psycopg),
1347
+ host=info.host,
1348
+ port=info.port,
1349
+ username=info.user,
1350
+ database=info.dbname,
1351
+ engine_opts={"connect_args": {"options": "-c timezone=UTC"}},
1352
+ )
1353
+ engine = maker.engine
1354
+ now = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
1355
+ payload = DummyModel(dummy_array=[], dummy_json={})
1356
+ try:
1357
+ DummyBaseModel.metadata.create_all(engine)
1358
+ with maker.make_session() as session, session.begin():
1359
+ record_sqn = db_create_revision_model(session, DummyRevisionModel, payload, now).record_sqn
1360
+ with maker.make_session() as stale_session, maker.make_session() as writer_session:
1361
+ cached = db_read_active_revision_model_of_record(stale_session, DummyRevisionModel, record_sqn)
1362
+ db_update_revision_model(writer_session,
1363
+ DummyRevisionModel,
1364
+ payload,
1365
+ record_sqn,
1366
+ now + datetime.timedelta(days=1))
1367
+ writer_session.commit()
1368
+ result = db_update_revision_model(stale_session,
1369
+ DummyRevisionModel,
1370
+ payload,
1371
+ record_sqn,
1372
+ now + datetime.timedelta(days=2))
1373
+ assert result.revision == 3
1374
+ assert cached.expired_at == now + datetime.timedelta(days=1)
1375
+ stale_session.commit()
1376
+ finally:
1377
+ maker.reset_engine()
1378
+
1379
+
1380
+ NullableUpdateBase = make_base_model()
1381
+
1382
+
1383
+ class NullableSequenceUpdateModel(NullableUpdateBase, make_sequence_model_mixin("sqlite"), table=True):
1384
+ name: str | None = None
1385
+ count: int = 0
1386
+
1387
+
1388
+ class NullableChangingUpdateModel(NullableUpdateBase, make_changing_model_mixin("sqlite"), table=True):
1389
+ name: str | None = None
1390
+ count: int = 0
1391
+
1392
+
1393
+ class NullableUpdateInput(pdt.BaseModel):
1394
+ """Non-table input, including metadata that the helpers must ignore."""
1395
+ sqn: int | None = None
1396
+ name: str | None = None
1397
+ count: int | None = 0
1398
+ created_at: datetime.datetime | None = None
1399
+ updated_at: datetime.datetime | None = None
1400
+
1401
+
1402
+ @pytest.fixture(params=[NullableSequenceUpdateModel, NullableChangingUpdateModel])
1403
+ def nullable_update_session(request):
1404
+ model = request.param
1405
+ engine = sa.create_engine("sqlite://")
1406
+ NullableUpdateBase.metadata.create_all(engine)
1407
+ now = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
1408
+ try:
1409
+ with sa.orm.Session(engine) as session:
1410
+ row = model(name="keep", count=7)
1411
+ if model is NullableChangingUpdateModel:
1412
+ row.created_at = now
1413
+ row.updated_at = now
1414
+ session.add(row)
1415
+ session.flush()
1416
+ sqn = row.sqn
1417
+
1418
+ def update(payload):
1419
+ if model is NullableChangingUpdateModel:
1420
+ return db_update_changing_model(session, model, payload, sqn, now + datetime.timedelta(days=1))
1421
+ return db_update_sequence_model(session, model, payload, sqn)
1422
+
1423
+ yield session, model, sqn, now, update
1424
+ finally:
1425
+ engine.dispose()
1426
+
1427
+
1428
+ def test_update_explicit_none_clears_nullable_field(nullable_update_session):
1429
+ session, model, sqn, now, update = nullable_update_session
1430
+ supplied_time = now - datetime.timedelta(days=1)
1431
+ result = update(NullableUpdateInput(
1432
+ name=None, count=7, sqn=sqn + 100,
1433
+ created_at=supplied_time, updated_at=supplied_time,
1434
+ ))
1435
+ # Reload to verify persistence, not just the Python object's value.
1436
+ session.expire_all()
1437
+ stored = session.get(model, sqn)
1438
+ assert stored is result
1439
+ assert stored.name is None
1440
+ assert stored.count == 7
1441
+ assert stored.sqn == sqn
1442
+ if model is NullableChangingUpdateModel:
1443
+ assert stored.created_at == now
1444
+ assert stored.updated_at == now + datetime.timedelta(days=1)
1445
+
1446
+
1447
+ def test_update_explicit_default_is_copied(nullable_update_session):
1448
+ session, model, sqn, _, update = nullable_update_session
1449
+ update(NullableUpdateInput(name="changed", count=0))
1450
+ session.expire_all()
1451
+ stored = session.get(model, sqn)
1452
+ assert stored.name == "changed"
1453
+ assert stored.count == 0
1454
+
1455
+
1456
+ def test_update_none_rejected_for_non_nullable_field(nullable_update_session):
1457
+ session, model, sqn, _, update = nullable_update_session
1458
+ with pytest.raises(pdt.ValidationError):
1459
+ update(NullableUpdateInput(name="changed", count=None))
1460
+ stored = session.get(model, sqn)
1461
+ assert stored.name == "keep"
1462
+ assert stored.count == 7
@@ -43,8 +43,9 @@ class S3UtilsTest(unittest.TestCase):
43
43
  result = list(s3_list_objects(client, "dummy-bucket", "dummy_prefix/"))
44
44
 
45
45
  for key, text in data:
46
- self.assertTrue(any(o.key == key for o in result))
47
- self.assertEqual(s3_pull_text(client, "dummy-bucket", key), text)
46
+ with self.subTest(progressed_client=False, key=key):
47
+ self.assertTrue(any(o.key == key for o in result))
48
+ self.assertEqual(s3_pull_text(client, "dummy-bucket", key), text)
48
49
 
49
50
  with moto.mock_aws(), s3_make_progressed_client() as client:
50
51
  client.create_bucket(Bucket="dummy-bucket")
@@ -61,8 +62,9 @@ class S3UtilsTest(unittest.TestCase):
61
62
  result = list(s3_list_objects(client, "dummy-bucket", "dummy_prefix/"))
62
63
 
63
64
  for key, text in data:
64
- self.assertTrue(any(o.key == key for o in result))
65
- self.assertEqual(s3_pull_text(client, "dummy-bucket", key), text)
65
+ with self.subTest(progressed_client=True, key=key):
66
+ self.assertTrue(any(o.key == key for o in result))
67
+ self.assertEqual(s3_pull_text(client, "dummy-bucket", key), text)
66
68
 
67
69
  def test_s3_list_files__random_text_files(self):
68
70
  with moto.mock_aws(), s3_make_client() as client:
@@ -80,8 +82,9 @@ class S3UtilsTest(unittest.TestCase):
80
82
  result = list(s3_list_files(client, "dummy-bucket", "dummy_prefix/"))
81
83
 
82
84
  for key, text in data:
83
- self.assertTrue(any(o.key == key for o in result))
84
- self.assertEqual(s3_pull_text(client, "dummy-bucket", key), text)
85
+ with self.subTest(progressed_client=False, key=key):
86
+ self.assertTrue(any(o.key == key for o in result))
87
+ self.assertEqual(s3_pull_text(client, "dummy-bucket", key), text)
85
88
 
86
89
  with moto.mock_aws(), s3_make_progressed_client() as client:
87
90
  client.create_bucket(Bucket="dummy-bucket")
@@ -98,8 +101,9 @@ class S3UtilsTest(unittest.TestCase):
98
101
  result = list(s3_list_files(client, "dummy-bucket", "dummy_prefix/"))
99
102
 
100
103
  for key, text in data:
101
- self.assertTrue(any(o.key == key for o in result))
102
- self.assertEqual(s3_pull_text(client, "dummy-bucket", key), text)
104
+ with self.subTest(progressed_client=True, key=key):
105
+ self.assertTrue(any(o.key == key for o in result))
106
+ self.assertEqual(s3_pull_text(client, "dummy-bucket", key), text)
103
107
 
104
108
  data_s3_list_files = [
105
109
  (
@@ -498,14 +502,15 @@ class S3UtilsTest(unittest.TestCase):
498
502
  ["s3utils_archive/archive.uncompressed.zip", "s3utils_archive/archive.compressed.zip"],
499
503
  [local_members, None],
500
504
  ):
501
- archive_size, member_zip_infos, missed_members = s3_archive_list_files(client,
502
- "dummy-bucket",
503
- archive_key,
504
- members)
505
+ with self.subTest(archive_key=archive_key, members=members):
506
+ archive_size, member_zip_infos, missed_members = s3_archive_list_files(client,
507
+ "dummy-bucket",
508
+ archive_key,
509
+ members)
505
510
 
506
- self.assertEqual(archive_size - sum(info.compress_size for info in member_zip_infos), 2470)
507
- self.assertEqual(set(info.filename for info in member_zip_infos), set(local_members))
508
- self.assertEqual(len(missed_members), 0)
511
+ self.assertEqual(archive_size - sum(info.compress_size for info in member_zip_infos), 2470)
512
+ self.assertEqual(set(info.filename for info in member_zip_infos), set(local_members))
513
+ self.assertEqual(len(missed_members), 0)
509
514
 
510
515
  for archive_key, (members, members_expect) in itertools.product(
511
516
  ["s3utils_archive/archive.uncompressed.zip", "s3utils_archive/archive.compressed.zip"],
@@ -533,13 +538,14 @@ class S3UtilsTest(unittest.TestCase):
533
538
  ),
534
539
  ],
535
540
  ):
536
- archive_size, member_zip_infos, missed_members = s3_archive_list_files(client,
537
- "dummy-bucket",
538
- archive_key,
539
- members)
541
+ with self.subTest(archive_key=archive_key, members=members):
542
+ archive_size, member_zip_infos, missed_members = s3_archive_list_files(client,
543
+ "dummy-bucket",
544
+ archive_key,
545
+ members)
540
546
 
541
- self.assertEqual(sorted(list(info.filename for info in member_zip_infos)), sorted(members_expect))
542
- self.assertEqual(len(missed_members), 0)
547
+ self.assertEqual(sorted(list(info.filename for info in member_zip_infos)), sorted(members_expect))
548
+ self.assertEqual(len(missed_members), 0)
543
549
 
544
550
  def test_s3_archive_open_member(self):
545
551
  with (
@@ -562,6 +568,7 @@ class S3UtilsTest(unittest.TestCase):
562
568
  ):
563
569
  for local_member in local_members:
564
570
  with (
571
+ self.subTest(archive_key=archive_key, mode=mode, member=local_member),
565
572
  open(local_root / local_member, mode) as local_fh,
566
573
  s3_archive_open_member(client, "dummy-bucket", archive_key, local_member, mode) as s3_fh,
567
574
  ):
@@ -589,14 +596,25 @@ class S3UtilsTest(unittest.TestCase):
589
596
  [False, True, s3_archive_use_ranged_requests()],
590
597
  [False, True, s3_archive_use_chunked_reads()],
591
598
  ):
592
- for member, opener in s3_archive_open_members(
593
- client,
594
- "dummy-bucket",
595
- archive_key,
596
- members,
597
- mode,
599
+ with self.subTest(
600
+ archive_key=archive_key,
601
+ members=members,
602
+ mode=mode,
598
603
  use_ranged_requests=use_ranged_requests,
599
604
  use_chunked_reads=use_chunked_reads,
600
605
  ):
601
- with open(local_root / member, mode) as local_fh, opener() as s3_fh:
602
- self.assertEqual(local_fh.read(), s3_fh.read())
606
+ for member, opener in s3_archive_open_members(
607
+ client,
608
+ "dummy-bucket",
609
+ archive_key,
610
+ members,
611
+ mode,
612
+ use_ranged_requests=use_ranged_requests,
613
+ use_chunked_reads=use_chunked_reads,
614
+ ):
615
+ with (
616
+ self.subTest(member=member),
617
+ open(local_root / member, mode) as local_fh,
618
+ opener() as s3_fh,
619
+ ):
620
+ self.assertEqual(local_fh.read(), s3_fh.read())