1
1
import datetime
2
2
import itertools
3
3
import typing
4
+ import warnings
4
5
5
6
import sqlalchemy .event as event
6
7
import sqlalchemy .orm as orm
7
8
8
9
from temporal_sqlalchemy .bases import TemporalOption , Clocked
9
10
from temporal_sqlalchemy .metadata import (
10
- get_session_metadata ,
11
- set_session_metadata
11
+ STRICT_MODE_KEY ,
12
+ CHANGESET_STACK_KEY ,
13
+ IS_COMMITTING_KEY ,
14
+ IS_VCLOCK_UNCHANGED_KEY ,
12
15
)
13
16
14
17
15
- CHANGESET_KEY = 'changeset'
16
- IS_COMMITTING_KEY = 'is_committing'
17
- IS_VCLOCK_UNCHANGED = 'is_vclock_unchanged'
18
+ def get_current_changeset ( session ):
19
+ stack = session . info [ CHANGESET_STACK_KEY ]
20
+ assert len ( stack ) > 0
18
21
19
-
20
- def _reset_flags (metadata ):
21
- metadata [CHANGESET_KEY ] = {}
22
- metadata [IS_COMMITTING_KEY ] = False
23
- metadata [IS_VCLOCK_UNCHANGED ] = True
22
+ return stack [- 1 ]
24
23
25
24
26
25
def _temporal_models (session : orm .Session ) -> typing .Iterable [Clocked ]:
@@ -30,13 +29,17 @@ def _temporal_models(session: orm.Session) -> typing.Iterable[Clocked]:
30
29
31
30
32
31
def _build_history (session , correlate_timestamp ):
33
- metadata = get_session_metadata (session )
32
+ # this shouldn't happen, but it might happen, log a warning and investigate
33
+ if not session .info .get (CHANGESET_STACK_KEY ):
34
+ warnings .warn ('changeset_stack is missing but we are in _build_history()' )
35
+ return
34
36
35
- items = list (metadata [CHANGESET_KEY ].items ())
36
- metadata [CHANGESET_KEY ].clear ()
37
+ changeset = get_current_changeset (session )
38
+ items = list (changeset .items ())
39
+ changeset .clear ()
37
40
38
- is_strict_mode = metadata . get ( 'strict_mode' , False )
39
- is_vclock_unchanged = metadata . get ( IS_VCLOCK_UNCHANGED , False )
41
+ is_strict_mode = session . info [ STRICT_MODE_KEY ]
42
+ is_vclock_unchanged = session . info [ IS_VCLOCK_UNCHANGED_KEY ]
40
43
if items and is_strict_mode :
41
44
assert not is_vclock_unchanged , \
42
45
'commit() has triggered for a changed temporalized property without a clock tick'
@@ -49,45 +52,102 @@ def persist_history(session: orm.Session, flush_context, instances):
49
52
if any (_temporal_models (session .deleted )):
50
53
raise ValueError ("Cannot delete temporal objects." )
51
54
55
+ # its possible the temporal session was initialized after the transaction has started
56
+ _initialize_metadata (session )
57
+
52
58
correlate_timestamp = datetime .datetime .now (tz = datetime .timezone .utc )
53
59
changed_rows = _temporal_models (itertools .chain (session .dirty , session .new ))
54
60
55
- metadata = get_session_metadata (session )
61
+ changeset = get_current_changeset (session )
56
62
for obj in changed_rows :
57
63
if obj .temporal_options .allow_persist_on_commit :
58
64
new_changes , is_vclock_unchanged = obj .temporal_options .get_history (obj )
59
65
60
66
if new_changes :
61
- if obj not in metadata [ CHANGESET_KEY ] :
62
- metadata [ CHANGESET_KEY ] [obj ] = {}
67
+ if obj not in changeset :
68
+ changeset [obj ] = {}
63
69
64
- old_changes = metadata [ CHANGESET_KEY ] [obj ]
70
+ old_changes = changeset [obj ]
65
71
old_changes .update (new_changes )
66
72
67
- metadata [ IS_VCLOCK_UNCHANGED ] = metadata [ IS_VCLOCK_UNCHANGED ] and is_vclock_unchanged
73
+ session . info [ IS_VCLOCK_UNCHANGED_KEY ] = session . info [ IS_VCLOCK_UNCHANGED_KEY ] and is_vclock_unchanged
68
74
else :
69
75
obj .temporal_options .record_history (obj , session , correlate_timestamp )
70
76
71
77
# if this is the last flush, build all the history
72
- if metadata [IS_COMMITTING_KEY ]:
78
+ if session . info [IS_COMMITTING_KEY ]:
73
79
_build_history (session , correlate_timestamp )
74
80
75
- _reset_flags ( metadata )
81
+ session . info [ IS_COMMITTING_KEY ] = False
76
82
77
83
78
84
def enable_is_committing_flag (session ):
79
- metadata = get_session_metadata (session )
80
-
81
- metadata [IS_COMMITTING_KEY ] = True
85
+ """before_commit happens before before_flush, and we need to make sure the history gets built
86
+ during the final one of these two events, so we need to use the gross IS_COMMITTING_KEY flag to
87
+ control this behavior"""
88
+ session .info [IS_COMMITTING_KEY ] = True
82
89
90
+ # if the session is clean, a final flush won't happen, so try to build the history now
83
91
if session ._is_clean ():
84
92
correlate_timestamp = datetime .datetime .now (tz = datetime .timezone .utc )
85
93
_build_history (session , correlate_timestamp )
86
94
87
95
# building the history can cause the session to be dirtied, which will in turn call another
88
96
# flush(), so we want to check this before reseting
97
+ # if there are more changes, flush will build them itself
89
98
if session ._is_clean ():
90
- _reset_flags (metadata )
99
+ session .info [IS_COMMITTING_KEY ] = False
100
+
101
+
102
+ def _get_transaction_stack_depth (transaction ):
103
+ depth = 0
104
+
105
+ current = transaction
106
+ while current :
107
+ depth += 1
108
+ current = transaction .parent
109
+
110
+ return depth
111
+
112
+
113
+ def _initialize_metadata (session ):
114
+ if CHANGESET_STACK_KEY not in session .info :
115
+ session .info [CHANGESET_STACK_KEY ] = []
116
+
117
+ if IS_COMMITTING_KEY not in session .info :
118
+ session .info [IS_COMMITTING_KEY ] = False
119
+
120
+ if IS_VCLOCK_UNCHANGED_KEY not in session .info :
121
+ session .info [IS_VCLOCK_UNCHANGED_KEY ] = True
122
+
123
+ # sometimes temporalize a session after a transaction has already been open, so we need to
124
+ # backfill any missing stack entries
125
+ if len (session .info [CHANGESET_STACK_KEY ]) == 0 :
126
+ depth = _get_transaction_stack_depth (session .transaction )
127
+ for _ in range (depth ):
128
+ session .info [CHANGESET_STACK_KEY ].append ({})
129
+
130
+
131
+ def start_transaction (session , transaction ):
132
+ _initialize_metadata (session )
133
+
134
+ session .info [CHANGESET_STACK_KEY ].append ({})
135
+
136
+
137
+ def end_transaction (session , transaction ):
138
+ # there are some edge cases where no temporal changes actually happen, so don't bother
139
+ if not session .info .get (CHANGESET_STACK_KEY ):
140
+ return
141
+
142
+ session .info [CHANGESET_STACK_KEY ].pop ()
143
+
144
+ # reset bookkeeping fields if we're ending a top most transaction
145
+ if transaction .parent is None :
146
+ session .info [IS_VCLOCK_UNCHANGED_KEY ] = True
147
+ session .info [IS_COMMITTING_KEY ] = False
148
+
149
+ # there should be no more changeset stacks at this point, otherwise there is a mismatch
150
+ assert len (session .info [CHANGESET_STACK_KEY ]) == 0
91
151
92
152
93
153
def temporal_session (session : typing .Union [orm .Session , orm .sessionmaker ], strict_mode = False ) -> orm .Session :
@@ -98,23 +158,26 @@ def temporal_session(session: typing.Union[orm.Session, orm.sessionmaker], stric
98
158
:param strict_mode: if True, will raise exceptions when improper flush() calls are made (default is False)
99
159
:return: temporalized SQLALchemy ORM session
100
160
"""
101
- temporal_metadata = {
102
- 'strict_mode' : strict_mode ,
103
- }
104
- _reset_flags (temporal_metadata )
105
-
106
161
# defer listening to the flush hook until after we update the metadata
107
162
install_flush_hook = not is_temporal_session (session )
108
163
109
- # update to the latest metadata
110
- set_session_metadata (session , temporal_metadata )
164
+ if isinstance (session , orm .Session ):
165
+ session .info [STRICT_MODE_KEY ] = strict_mode
166
+ elif isinstance (session , orm .sessionmaker ):
167
+ session .configure (info = {STRICT_MODE_KEY : strict_mode })
168
+ else :
169
+ raise ValueError ('Invalid session' )
111
170
112
171
if install_flush_hook :
113
172
event .listen (session , 'before_flush' , persist_history )
114
173
event .listen (session , 'before_commit' , enable_is_committing_flag )
115
174
175
+ # nested transaction handling
176
+ event .listen (session , 'after_transaction_create' , start_transaction )
177
+ event .listen (session , 'after_transaction_end' , end_transaction )
178
+
116
179
return session
117
180
118
181
119
182
def is_temporal_session (session : orm .Session ) -> bool :
120
- return isinstance (session , orm .Session ) and get_session_metadata ( session ) is not None
183
+ return isinstance (session , orm .Session ) and session . info . get ( STRICT_MODE_KEY ) is not None
0 commit comments