morango 0.6.11__py2.py3-none-any.whl → 0.8.6__py2.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.
- morango/__init__.py +1 -6
- morango/api/serializers.py +3 -0
- morango/api/viewsets.py +38 -23
- morango/apps.py +1 -2
- morango/constants/settings.py +3 -0
- morango/constants/transfer_stages.py +1 -1
- morango/constants/transfer_statuses.py +1 -1
- morango/errors.py +4 -0
- morango/management/commands/cleanupsyncs.py +64 -14
- morango/migrations/0001_initial.py +0 -2
- morango/migrations/0001_squashed_0024_auto_20240129_1757.py +583 -0
- morango/migrations/0002_auto_20170511_0400.py +0 -2
- morango/migrations/0002_store_idx_morango_deserialize.py +21 -0
- morango/migrations/0003_auto_20170519_0543.py +0 -2
- morango/migrations/0004_auto_20170520_2112.py +0 -2
- morango/migrations/0005_auto_20170629_2139.py +0 -2
- morango/migrations/0006_instanceidmodel_system_id.py +0 -2
- morango/migrations/0007_auto_20171018_1615.py +0 -2
- morango/migrations/0008_auto_20171114_2217.py +0 -2
- morango/migrations/0009_auto_20171205_0252.py +0 -2
- morango/migrations/0010_auto_20171206_1615.py +0 -2
- morango/migrations/0011_sharedkey.py +0 -2
- morango/migrations/0012_auto_20180927_1658.py +0 -2
- morango/migrations/0013_auto_20190627_1513.py +0 -2
- morango/migrations/0014_syncsession_extra_fields.py +0 -2
- morango/migrations/0015_auto_20200508_2104.py +2 -3
- morango/migrations/0016_store_deserialization_error.py +2 -3
- morango/migrations/0017_store_last_transfer_session_id.py +1 -2
- morango/migrations/0018_auto_20210714_2216.py +2 -3
- morango/migrations/0019_auto_20220113_1807.py +2 -3
- morango/migrations/0020_postgres_fix_nullable.py +0 -2
- morango/migrations/0021_store_partition_index_create.py +0 -2
- morango/migrations/0022_rename_instance_fields.py +23 -0
- morango/migrations/0023_add_instance_id_fields.py +24 -0
- morango/migrations/0024_auto_20240129_1757.py +28 -0
- morango/models/__init__.py +0 -6
- morango/models/certificates.py +137 -28
- morango/models/core.py +48 -46
- morango/models/fields/crypto.py +20 -6
- morango/models/fields/uuids.py +2 -1
- morango/models/utils.py +5 -6
- morango/proquint.py +2 -3
- morango/registry.py +28 -49
- morango/sync/backends/base.py +34 -0
- morango/sync/backends/postgres.py +129 -0
- morango/sync/backends/utils.py +10 -11
- morango/sync/context.py +198 -13
- morango/sync/controller.py +33 -11
- morango/sync/operations.py +324 -251
- morango/sync/session.py +11 -0
- morango/sync/syncsession.py +78 -85
- morango/sync/utils.py +18 -0
- morango/urls.py +3 -3
- morango/utils.py +2 -3
- {morango-0.6.11.dist-info → morango-0.8.6.dist-info}/METADATA +29 -14
- morango-0.8.6.dist-info/RECORD +79 -0
- {morango-0.6.11.dist-info → morango-0.8.6.dist-info}/WHEEL +1 -1
- morango/models/morango_mptt.py +0 -33
- morango/settings.py +0 -115
- morango/wsgi.py +0 -33
- morango-0.6.11.dist-info/RECORD +0 -77
- {morango-0.6.11.dist-info → morango-0.8.6.dist-info/licenses}/AUTHORS.md +0 -0
- {morango-0.6.11.dist-info → morango-0.8.6.dist-info/licenses}/LICENSE +0 -0
- {morango-0.6.11.dist-info → morango-0.8.6.dist-info}/top_level.txt +0 -0
|
@@ -1,9 +1,24 @@
|
|
|
1
|
+
import binascii
|
|
2
|
+
import logging
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
|
|
1
5
|
from .base import BaseSQLWrapper
|
|
2
6
|
from .utils import get_pk_field
|
|
7
|
+
from morango.errors import MorangoDatabaseError
|
|
3
8
|
from morango.models.core import Buffer
|
|
4
9
|
from morango.models.core import RecordMaxCounter
|
|
5
10
|
from morango.models.core import RecordMaxCounterBuffer
|
|
6
11
|
from morango.models.core import Store
|
|
12
|
+
from morango.utils import SETTINGS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# advisory lock integers for locking partitions
|
|
16
|
+
LOCK_ALL_PARTITIONS = 1
|
|
17
|
+
LOCK_PARTITION = 2
|
|
18
|
+
|
|
19
|
+
SIGNED_MAX_INTEGER = 2147483647
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
7
22
|
|
|
8
23
|
|
|
9
24
|
class SQLWrapper(BaseSQLWrapper):
|
|
@@ -12,6 +27,60 @@ class SQLWrapper(BaseSQLWrapper):
|
|
|
12
27
|
"CREATE TEMP TABLE {name} ({fields}) ON COMMIT DROP"
|
|
13
28
|
)
|
|
14
29
|
|
|
30
|
+
def _is_transaction_isolation_error(self, error):
|
|
31
|
+
"""
|
|
32
|
+
Determine if an error is related to transaction isolation
|
|
33
|
+
:param error: An exception
|
|
34
|
+
:return: A bool whether the error is a transaction isolation error
|
|
35
|
+
"""
|
|
36
|
+
from psycopg2.extensions import TransactionRollbackError
|
|
37
|
+
|
|
38
|
+
# Django can wrap errors, adding it to the `__cause__` attribute
|
|
39
|
+
for e in (error, getattr(error, '__cause__', None)):
|
|
40
|
+
if isinstance(e, TransactionRollbackError):
|
|
41
|
+
return True
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
@contextmanager
|
|
45
|
+
def _set_transaction_repeatable_read(self):
|
|
46
|
+
"""Set the current transaction isolation level"""
|
|
47
|
+
from psycopg2.extensions import ISOLATION_LEVEL_REPEATABLE_READ
|
|
48
|
+
|
|
49
|
+
# if we're running tests, we should skip modifying the isolation since the test suites
|
|
50
|
+
# manage their own connections
|
|
51
|
+
if SETTINGS.MORANGO_TEST_POSTGRESQL:
|
|
52
|
+
yield
|
|
53
|
+
else:
|
|
54
|
+
# setting the transaction isolation must be either done at the BEGIN statement, or
|
|
55
|
+
# before any reading/writing operations have taken place, which includes creating
|
|
56
|
+
# savepoints
|
|
57
|
+
if self.connection.in_atomic_block:
|
|
58
|
+
raise MorangoDatabaseError("Unable to set transaction isolation during a transaction")
|
|
59
|
+
|
|
60
|
+
for savepoint_id in self.connection.savepoint_ids:
|
|
61
|
+
if savepoint_id is not None:
|
|
62
|
+
raise MorangoDatabaseError("Unable to set transaction isolation when savepoints have been created")
|
|
63
|
+
|
|
64
|
+
# ensure the postgres database wrapper loads the isolation level, which requires it to
|
|
65
|
+
# connection to the database.
|
|
66
|
+
# @see django.db.backends.postgresql.base.DatabaseWrapper.get_new_connection
|
|
67
|
+
self.connection.ensure_connection()
|
|
68
|
+
existing_autocommit = self.connection.autocommit
|
|
69
|
+
existing_isolation_level = self.connection.isolation_level
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
self.connection.isolation_level = ISOLATION_LEVEL_REPEATABLE_READ
|
|
73
|
+
self.connection.connection.set_session(isolation_level=ISOLATION_LEVEL_REPEATABLE_READ, autocommit=False)
|
|
74
|
+
yield
|
|
75
|
+
finally:
|
|
76
|
+
self.connection.isolation_level = existing_isolation_level
|
|
77
|
+
if existing_isolation_level is None:
|
|
78
|
+
self.connection.connection.set_isolation_level(existing_isolation_level)
|
|
79
|
+
else:
|
|
80
|
+
# this method cannot accept None values, as they will be ignored
|
|
81
|
+
self.connection.connection.set_session(isolation_level=existing_isolation_level)
|
|
82
|
+
self.connection.connection.set_session(autocommit=existing_autocommit)
|
|
83
|
+
|
|
15
84
|
def _prepare_with_values(self, name, fields, db_values):
|
|
16
85
|
placeholder_list = self._create_placeholder_list(fields, db_values)
|
|
17
86
|
# convert this list to a string to be passed into raw sql query
|
|
@@ -268,3 +337,63 @@ class SQLWrapper(BaseSQLWrapper):
|
|
|
268
337
|
)
|
|
269
338
|
|
|
270
339
|
cursor.execute(insert_remaining_rmcb)
|
|
340
|
+
|
|
341
|
+
def _execute_lock(self, key1, key2=None, unlock=False, session=False, shared=False, wait=True):
|
|
342
|
+
"""
|
|
343
|
+
Creates or destroys an advisory lock within postgres
|
|
344
|
+
:param key1: An int sent to the PG lock function
|
|
345
|
+
:param key2: A 2nd int sent to the PG lock function
|
|
346
|
+
:param unlock: A bool representing whether query should use `unlock`
|
|
347
|
+
:param session: A bool indicating if this should persist outside of transaction
|
|
348
|
+
:param shared: A bool indicating if this should be shared, otherwise exclusive
|
|
349
|
+
:param wait: A bool indicating if it should use a `try` PG function
|
|
350
|
+
"""
|
|
351
|
+
if not session:
|
|
352
|
+
if not self.connection.in_atomic_block:
|
|
353
|
+
raise NotImplementedError("Advisory lock requires transaction")
|
|
354
|
+
if unlock:
|
|
355
|
+
raise NotImplementedError("Transaction level locks unlock automatically")
|
|
356
|
+
|
|
357
|
+
keys = [key1]
|
|
358
|
+
if key2 is not None:
|
|
359
|
+
keys.append(key2)
|
|
360
|
+
|
|
361
|
+
query = "SELECT pg{_try}_advisory_{xact_}{lock}{_shared}({keys}) AS lock;".format(
|
|
362
|
+
_try="" if wait else "_try",
|
|
363
|
+
xact_="" if session else "xact_",
|
|
364
|
+
lock="unlock" if unlock else "lock",
|
|
365
|
+
_shared="_shared" if shared else "",
|
|
366
|
+
keys=", ".join(["%s"] * len(keys))
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
with self.connection.cursor() as c:
|
|
370
|
+
c.execute(query, keys)
|
|
371
|
+
|
|
372
|
+
def _lock_all_partitions(self, shared=False):
|
|
373
|
+
"""
|
|
374
|
+
Execute a lock within the database for all partitions, if the database supports it.
|
|
375
|
+
|
|
376
|
+
:param shared: Whether the lock is exclusive or shared
|
|
377
|
+
"""
|
|
378
|
+
self._execute_lock(LOCK_ALL_PARTITIONS, shared=shared)
|
|
379
|
+
|
|
380
|
+
def _lock_partition(self, partition, shared=False):
|
|
381
|
+
"""
|
|
382
|
+
Execute a lock within the database for a specific partition, if the database supports it.
|
|
383
|
+
|
|
384
|
+
:param partition: The partition prefix string to lock
|
|
385
|
+
:param shared: Whether the lock is exclusive or shared
|
|
386
|
+
"""
|
|
387
|
+
# first we open a shared lock on all partitions, so that we don't interfere with concurrent
|
|
388
|
+
# locks on all partitions or operations that could attempt to open a lock on all partitions
|
|
389
|
+
# while we've locked only some partitions
|
|
390
|
+
self._lock_all_partitions(shared=True)
|
|
391
|
+
|
|
392
|
+
# Postgres advisory locks use integers, so we have to convert the partition string into
|
|
393
|
+
# an integer. To do this we use crc32, which returns an unsigned integer. When using two
|
|
394
|
+
# keys for advisory locks, the two keys are signed integers, so we have to adjust the crc32
|
|
395
|
+
# value so that it doesn't exceed the maximum signed integer. Turning the partition str into
|
|
396
|
+
# a crc32 value could produce the same integer for different partitions, but for the
|
|
397
|
+
# purposes of locking to manage concurrency, this shouldn't be an issue.
|
|
398
|
+
partition_int = binascii.crc32(partition.encode("utf-8")) - SIGNED_MAX_INTEGER
|
|
399
|
+
self._execute_lock(LOCK_PARTITION, key2=partition_int, shared=shared)
|
morango/sync/backends/utils.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import sqlite3
|
|
2
|
+
from functools import lru_cache
|
|
2
3
|
from importlib import import_module
|
|
3
4
|
|
|
4
|
-
from django.utils.lru_cache import lru_cache
|
|
5
|
-
|
|
6
5
|
from morango.errors import MorangoError
|
|
7
6
|
|
|
8
7
|
|
|
@@ -110,15 +109,15 @@ class TemporaryTable(object):
|
|
|
110
109
|
"""
|
|
111
110
|
fields = []
|
|
112
111
|
params = []
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
112
|
+
schema_editor = self.connection.schema_editor()
|
|
113
|
+
for field in self.fields:
|
|
114
|
+
# generates the SQL expression for the table column
|
|
115
|
+
field_sql, field_params = schema_editor.column_sql(
|
|
116
|
+
self, field, include_default=True
|
|
117
|
+
)
|
|
118
|
+
field_sql_name = self.connection.ops.quote_name(field.column)
|
|
119
|
+
fields.append("{name} {sql}".format(name=field_sql_name, sql=field_sql))
|
|
120
|
+
params.extend(field_params)
|
|
122
121
|
with self.connection.cursor() as c:
|
|
123
122
|
self.backend._create_temporary_table(c, self.sql_name, fields, params)
|
|
124
123
|
|
morango/sync/context.py
CHANGED
|
@@ -4,8 +4,8 @@ from morango.errors import MorangoContextUpdateError
|
|
|
4
4
|
from morango.models.certificates import Filter
|
|
5
5
|
from morango.models.core import SyncSession
|
|
6
6
|
from morango.models.core import TransferSession
|
|
7
|
-
from morango.utils import parse_capabilities_from_server_request
|
|
8
7
|
from morango.utils import CAPABILITIES
|
|
8
|
+
from morango.utils import parse_capabilities_from_server_request
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class SessionContext(object):
|
|
@@ -21,6 +21,7 @@ class SessionContext(object):
|
|
|
21
21
|
"capabilities",
|
|
22
22
|
"error",
|
|
23
23
|
)
|
|
24
|
+
max_backoff_interval = 5
|
|
24
25
|
|
|
25
26
|
def __init__(
|
|
26
27
|
self,
|
|
@@ -55,6 +56,20 @@ class SessionContext(object):
|
|
|
55
56
|
if transfer_session.filter:
|
|
56
57
|
self.filter = transfer_session.get_filter()
|
|
57
58
|
|
|
59
|
+
def prepare(self):
|
|
60
|
+
"""
|
|
61
|
+
Perform any processing of the session context prior to passing it to the middleware,
|
|
62
|
+
and return the context as it should be passed to the middleware
|
|
63
|
+
"""
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def join(self, context):
|
|
67
|
+
"""
|
|
68
|
+
Perform any processing of session context after passing it to the middleware, which will
|
|
69
|
+
receive the result returned by `prepare` after that has happened
|
|
70
|
+
"""
|
|
71
|
+
pass
|
|
72
|
+
|
|
58
73
|
def update(
|
|
59
74
|
self,
|
|
60
75
|
transfer_session=None,
|
|
@@ -75,7 +90,7 @@ class SessionContext(object):
|
|
|
75
90
|
:type capabilities: str[]|None
|
|
76
91
|
:type error: BaseException|None
|
|
77
92
|
"""
|
|
78
|
-
if transfer_session and self.transfer_session:
|
|
93
|
+
if transfer_session and self.transfer_session and transfer_session.id != self.transfer_session.id:
|
|
79
94
|
raise MorangoContextUpdateError("Transfer session already exists")
|
|
80
95
|
elif (
|
|
81
96
|
transfer_session
|
|
@@ -84,14 +99,18 @@ class SessionContext(object):
|
|
|
84
99
|
):
|
|
85
100
|
raise MorangoContextUpdateError("Sync session mismatch")
|
|
86
101
|
|
|
87
|
-
if sync_filter and self.filter:
|
|
88
|
-
|
|
102
|
+
if sync_filter and self.filter and sync_filter != self.filter:
|
|
103
|
+
if not self.filter.is_subset_of(sync_filter):
|
|
104
|
+
raise MorangoContextUpdateError("The existing filter must be a subset of the new filter")
|
|
105
|
+
if transfer_stages.stage(self.stage) > transfer_stages.stage(transfer_stages.INITIALIZING):
|
|
106
|
+
raise MorangoContextUpdateError("Cannot update filter after initializing stage")
|
|
89
107
|
|
|
90
108
|
if is_push is not None and self.is_push is not None:
|
|
91
109
|
raise MorangoContextUpdateError("Push/pull method already exists")
|
|
92
110
|
|
|
93
111
|
self.transfer_session = transfer_session or self.transfer_session
|
|
94
|
-
|
|
112
|
+
if sync_filter or self.filter:
|
|
113
|
+
self.filter = Filter.add(self.filter, sync_filter)
|
|
95
114
|
self.is_push = is_push if is_push is not None else self.is_push
|
|
96
115
|
self.capabilities = set(capabilities or self.capabilities) & CAPABILITIES
|
|
97
116
|
self.update_state(stage=stage, stage_status=stage_status)
|
|
@@ -186,6 +205,7 @@ class LocalSessionContext(SessionContext):
|
|
|
186
205
|
"request",
|
|
187
206
|
"is_server",
|
|
188
207
|
)
|
|
208
|
+
max_backoff_interval = 1
|
|
189
209
|
|
|
190
210
|
def __init__(self, request=None, **kwargs):
|
|
191
211
|
"""
|
|
@@ -194,14 +214,22 @@ class LocalSessionContext(SessionContext):
|
|
|
194
214
|
passed in.
|
|
195
215
|
:type request: django.http.request.HttpRequest
|
|
196
216
|
"""
|
|
197
|
-
|
|
198
|
-
if request is not None:
|
|
199
|
-
capabilities = parse_capabilities_from_server_request(request)
|
|
200
|
-
|
|
201
|
-
super(LocalSessionContext, self).__init__(capabilities=capabilities, **kwargs)
|
|
217
|
+
super(LocalSessionContext, self).__init__(**kwargs)
|
|
202
218
|
self.request = request
|
|
203
219
|
self.is_server = request is not None
|
|
204
220
|
|
|
221
|
+
@classmethod
|
|
222
|
+
def from_request(cls, request, **kwargs):
|
|
223
|
+
"""
|
|
224
|
+
Parse capabilities from request and instantiate the LocalSessionContext
|
|
225
|
+
:param request: The request object
|
|
226
|
+
:type request: django.http.request.HttpRequest
|
|
227
|
+
:param kwargs: Any other keyword args for the constructor
|
|
228
|
+
:rtype: LocalSessionContext
|
|
229
|
+
"""
|
|
230
|
+
kwargs.update(capabilities=parse_capabilities_from_server_request(request))
|
|
231
|
+
return LocalSessionContext(request=request, **kwargs)
|
|
232
|
+
|
|
205
233
|
@property
|
|
206
234
|
def _has_transfer_session(self):
|
|
207
235
|
"""
|
|
@@ -288,9 +316,7 @@ class NetworkSessionContext(SessionContext):
|
|
|
288
316
|
:type connection: NetworkSyncConnection
|
|
289
317
|
"""
|
|
290
318
|
self.connection = connection
|
|
291
|
-
super(NetworkSessionContext, self).__init__(
|
|
292
|
-
capabilities=self.connection.server_info.get("capabilities", []), **kwargs
|
|
293
|
-
)
|
|
319
|
+
super(NetworkSessionContext, self).__init__(**kwargs)
|
|
294
320
|
|
|
295
321
|
# since this is network context, keep local reference to state vars
|
|
296
322
|
self._stage = transfer_stages.INITIALIZING
|
|
@@ -317,3 +343,162 @@ class NetworkSessionContext(SessionContext):
|
|
|
317
343
|
"""
|
|
318
344
|
self._stage = stage or self._stage
|
|
319
345
|
self._stage_status = stage_status or self._stage_status
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class CompositeSessionContext(SessionContext):
|
|
349
|
+
"""
|
|
350
|
+
A composite context class that acts as a facade for more than one context, to facilitate
|
|
351
|
+
"simpler" operation on local and remote contexts simultaneously
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
__slots__ = (
|
|
355
|
+
"children",
|
|
356
|
+
"_counter",
|
|
357
|
+
"_stage",
|
|
358
|
+
"_stage_status",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def __init__(self, contexts, *args, **kwargs):
|
|
362
|
+
"""
|
|
363
|
+
:param contexts: A list of context objects
|
|
364
|
+
:param args: Args to pass to the parent constructor
|
|
365
|
+
:param kwargs: Keyword args to pass to the parent constructor
|
|
366
|
+
"""
|
|
367
|
+
self.children = contexts
|
|
368
|
+
self._counter = 0
|
|
369
|
+
self._stage = transfer_stages.INITIALIZING
|
|
370
|
+
self._stage_status = transfer_statuses.PENDING
|
|
371
|
+
super(CompositeSessionContext, self).__init__(*args, **kwargs)
|
|
372
|
+
self._update_attrs(**kwargs)
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def max_backoff_interval(self):
|
|
376
|
+
"""
|
|
377
|
+
The maximum amount of time to wait between retries
|
|
378
|
+
:return: A number of seconds
|
|
379
|
+
"""
|
|
380
|
+
return self.prepare().max_backoff_interval
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def stage(self):
|
|
384
|
+
"""
|
|
385
|
+
The stage of the transfer context
|
|
386
|
+
:return: A transfer_stages.* constant
|
|
387
|
+
:rtype: str
|
|
388
|
+
"""
|
|
389
|
+
return self._stage
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def stage_status(self):
|
|
393
|
+
"""
|
|
394
|
+
The status of the transfer context's stage
|
|
395
|
+
:return: A transfer_statuses.* constant
|
|
396
|
+
:rtype: str
|
|
397
|
+
"""
|
|
398
|
+
return self._stage_status
|
|
399
|
+
|
|
400
|
+
def _update_attrs(self, **kwargs):
|
|
401
|
+
"""
|
|
402
|
+
Updates all contexts by applying key/value arguments as attributes. This avoids using the
|
|
403
|
+
contexts' update methods because some validation is already handled in this class.
|
|
404
|
+
"""
|
|
405
|
+
for context in self.children:
|
|
406
|
+
for attr, value in kwargs.items():
|
|
407
|
+
set_attr = "filter" if attr == "sync_filter" else attr
|
|
408
|
+
setattr(context, set_attr, value)
|
|
409
|
+
|
|
410
|
+
def prepare(self):
|
|
411
|
+
"""
|
|
412
|
+
Preparing this context will return the current sub context that needs completion
|
|
413
|
+
"""
|
|
414
|
+
return self.children[self._counter % len(self.children)]
|
|
415
|
+
|
|
416
|
+
def join(self, context):
|
|
417
|
+
"""
|
|
418
|
+
This updates some context attributes that really only make sense logically for what an
|
|
419
|
+
operation might change during a sync
|
|
420
|
+
|
|
421
|
+
:param context: The context that was returned previously from `prepare`
|
|
422
|
+
:type context: SessionContext
|
|
423
|
+
"""
|
|
424
|
+
updates = {}
|
|
425
|
+
if not self.transfer_session and context.transfer_session:
|
|
426
|
+
# if the transfer session is being resumed, we'd detect a different stage here,
|
|
427
|
+
# and thus we reset the counter, so we can be sure to start fresh at that stage
|
|
428
|
+
# on the next invocation of the middleware
|
|
429
|
+
if (
|
|
430
|
+
context.transfer_session.transfer_stage
|
|
431
|
+
and self._stage
|
|
432
|
+
and context.transfer_session.transfer_stage != self._stage
|
|
433
|
+
):
|
|
434
|
+
self._counter = 0
|
|
435
|
+
updates.update(
|
|
436
|
+
stage=context.transfer_session.transfer_stage,
|
|
437
|
+
stage_status=transfer_statuses.PENDING,
|
|
438
|
+
)
|
|
439
|
+
updates.update(transfer_session=context.transfer_session)
|
|
440
|
+
if self.filter != context.filter:
|
|
441
|
+
updates.update(sync_filter=context.filter)
|
|
442
|
+
if self.capabilities != context.capabilities:
|
|
443
|
+
updates.update(capabilities=context.capabilities)
|
|
444
|
+
if self.error != context.error:
|
|
445
|
+
updates.update(error=context.error)
|
|
446
|
+
if updates:
|
|
447
|
+
self.update(**updates)
|
|
448
|
+
|
|
449
|
+
def update(self, stage=None, stage_status=None, **kwargs):
|
|
450
|
+
# update ourselves, but exclude stage and stage_status
|
|
451
|
+
super(CompositeSessionContext, self).update(**kwargs)
|
|
452
|
+
# update children contexts directly, but exclude stage and stage_status
|
|
453
|
+
self._update_attrs(**kwargs)
|
|
454
|
+
# handle state changes after updating children
|
|
455
|
+
self.update_state(stage=stage, stage_status=stage_status)
|
|
456
|
+
|
|
457
|
+
def update_state(self, stage=None, stage_status=None):
|
|
458
|
+
"""
|
|
459
|
+
Updates the state of the transfer
|
|
460
|
+
:type stage: transfer_stages.*|None
|
|
461
|
+
:type stage_status: transfer_statuses.*|None
|
|
462
|
+
"""
|
|
463
|
+
# parent's update method can pass through None values
|
|
464
|
+
if stage is None and stage_status is None:
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
# advance the composite's stage when we move forward only
|
|
468
|
+
if stage is not None and transfer_stages.stage(stage) > transfer_stages.stage(
|
|
469
|
+
self._stage
|
|
470
|
+
):
|
|
471
|
+
self._stage = stage
|
|
472
|
+
|
|
473
|
+
# when finishing a stage without an error, we'll increment the counter by one such that
|
|
474
|
+
# `prepare` returns the next context to process
|
|
475
|
+
if stage_status == transfer_statuses.COMPLETED:
|
|
476
|
+
self._counter += 1
|
|
477
|
+
|
|
478
|
+
# when we've completed a loop through all contexts (modulus is zero), we want to bring
|
|
479
|
+
# all the contexts' states up to date
|
|
480
|
+
if (
|
|
481
|
+
self._counter % len(self.children) == 0
|
|
482
|
+
or stage_status == transfer_statuses.ERRORED
|
|
483
|
+
):
|
|
484
|
+
for context in self.children:
|
|
485
|
+
context.update_state(stage=stage, stage_status=stage_status)
|
|
486
|
+
if stage_status is not None:
|
|
487
|
+
self._stage_status = stage_status
|
|
488
|
+
|
|
489
|
+
def __getstate__(self):
|
|
490
|
+
"""Return dict of simplified data for serialization"""
|
|
491
|
+
return dict(
|
|
492
|
+
children=self.children,
|
|
493
|
+
counter=self._counter,
|
|
494
|
+
stage=self._stage,
|
|
495
|
+
stage_status=self._stage_status,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
def __setstate__(self, state):
|
|
499
|
+
"""Re-apply dict state after serialization"""
|
|
500
|
+
self.children = state.get("children", [])
|
|
501
|
+
self._counter = state.get("counter", 0)
|
|
502
|
+
self._stage = state.get("stage", None)
|
|
503
|
+
self._stage_status = state.get("stage_status", None)
|
|
504
|
+
self.error = state.get("error", None)
|
morango/sync/controller.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
import math
|
|
2
3
|
from time import sleep
|
|
3
4
|
|
|
4
5
|
from morango.constants import transfer_stages
|
|
5
6
|
from morango.constants import transfer_statuses
|
|
6
7
|
from morango.registry import session_middleware
|
|
7
|
-
|
|
8
8
|
from morango.sync.operations import _deserialize_from_store
|
|
9
9
|
from morango.sync.operations import _serialize_into_store
|
|
10
10
|
from morango.sync.operations import OperationLogger
|
|
@@ -130,10 +130,10 @@ class SessionController(object):
|
|
|
130
130
|
When invoking the middleware, if the status result is:
|
|
131
131
|
PENDING: The controller will continue to invoke the middleware again when this method
|
|
132
132
|
is called repeatedly, until the status result changes
|
|
133
|
-
STARTED: The controller will not invoke any middleware until the
|
|
133
|
+
STARTED: The controller will not invoke any middleware until the status changes,
|
|
134
134
|
and assumes the "started" operation will update the state itself
|
|
135
135
|
COMPLETED: The controller will proceed to invoke the middleware for the next stage
|
|
136
|
-
ERRORED: The controller will not invoke any middleware until the
|
|
136
|
+
ERRORED: The controller will not invoke any middleware until the status changes,
|
|
137
137
|
which would require codified resolution of the error outside of the controller
|
|
138
138
|
|
|
139
139
|
:param target_stage: transfer_stage.* - The transfer stage to proceed to
|
|
@@ -187,7 +187,9 @@ class SessionController(object):
|
|
|
187
187
|
# should always be a non-False status
|
|
188
188
|
return result
|
|
189
189
|
|
|
190
|
-
def proceed_to_and_wait_for(
|
|
190
|
+
def proceed_to_and_wait_for(
|
|
191
|
+
self, target_stage, context=None, max_interval=None, callback=None
|
|
192
|
+
):
|
|
191
193
|
"""
|
|
192
194
|
Same as `proceed_to` but waits for a finished status to be returned by sleeping between
|
|
193
195
|
calls to `proceed_to` if status is not complete
|
|
@@ -197,18 +199,29 @@ class SessionController(object):
|
|
|
197
199
|
:param context: Override controller context, or provide it if missing
|
|
198
200
|
:type context: morango.sync.context.SessionContext|None
|
|
199
201
|
:param max_interval: The max time, in seconds, between repeat calls to `.proceed_to`
|
|
202
|
+
:param callback: A callable to invoke after every attempt
|
|
200
203
|
:return: transfer_status.* - The status of proceeding to that stage,
|
|
201
204
|
which should be `ERRORED` or `COMPLETE`
|
|
202
205
|
:rtype: str
|
|
203
206
|
"""
|
|
204
207
|
result = transfer_statuses.PENDING
|
|
205
208
|
tries = 0
|
|
209
|
+
context = context or self.context
|
|
210
|
+
max_interval = max_interval or context.max_backoff_interval
|
|
211
|
+
# solve for the number of tries at which our sleep time will always be max_interval
|
|
212
|
+
max_interval_tries = math.ceil(math.log(max_interval / 0.3 + 1) / math.log(2))
|
|
213
|
+
|
|
206
214
|
while result not in transfer_statuses.FINISHED_STATES:
|
|
207
215
|
if tries > 0:
|
|
208
216
|
# exponential backoff up to max_interval
|
|
209
|
-
|
|
217
|
+
if tries >= max_interval_tries:
|
|
218
|
+
sleep(max_interval)
|
|
219
|
+
else:
|
|
220
|
+
sleep(0.3 * (2 ** tries - 1))
|
|
210
221
|
result = self.proceed_to(target_stage, context=context)
|
|
211
222
|
tries += 1
|
|
223
|
+
if callable(callback):
|
|
224
|
+
callback()
|
|
212
225
|
return result
|
|
213
226
|
|
|
214
227
|
def _invoke_middleware(self, context, middleware):
|
|
@@ -224,16 +237,24 @@ class SessionController(object):
|
|
|
224
237
|
stage = middleware.related_stage
|
|
225
238
|
signal = getattr(self.signals, stage)
|
|
226
239
|
at_stage = context.stage == stage
|
|
240
|
+
prepared_context = None
|
|
227
241
|
|
|
228
242
|
try:
|
|
229
243
|
context.update(stage=stage, stage_status=transfer_statuses.PENDING)
|
|
230
244
|
|
|
245
|
+
# we'll use the prepared context for passing to the middleware and any signal handlers
|
|
246
|
+
prepared_context = context.prepare()
|
|
247
|
+
|
|
231
248
|
# only fire "started" when we first try to invoke the stage
|
|
232
249
|
# NOTE: this means that signals.started is not equivalent to transfer_stage.STARTED
|
|
233
250
|
if not at_stage:
|
|
234
|
-
signal.started.fire(context=
|
|
251
|
+
signal.started.fire(context=prepared_context)
|
|
235
252
|
|
|
236
|
-
|
|
253
|
+
# invoke the middleware with the prepared context
|
|
254
|
+
result = middleware(prepared_context)
|
|
255
|
+
|
|
256
|
+
# return the prepared context back
|
|
257
|
+
context.join(prepared_context)
|
|
237
258
|
|
|
238
259
|
# don't update stage result if context's stage was updated during operation
|
|
239
260
|
if context.stage == stage:
|
|
@@ -241,15 +262,16 @@ class SessionController(object):
|
|
|
241
262
|
|
|
242
263
|
# fire signals based off middleware invocation result; the progress signal if incomplete
|
|
243
264
|
if result == transfer_statuses.COMPLETED:
|
|
244
|
-
signal.completed.fire(context=
|
|
265
|
+
signal.completed.fire(context=prepared_context)
|
|
245
266
|
else:
|
|
246
|
-
signal.in_progress.fire(context=
|
|
267
|
+
signal.in_progress.fire(context=prepared_context)
|
|
247
268
|
|
|
248
|
-
|
|
269
|
+
# context should take precedence over result, and was likely updated
|
|
270
|
+
return context.stage_status
|
|
249
271
|
except Exception as e:
|
|
250
272
|
# always log the error itself
|
|
251
273
|
logger.error(e)
|
|
252
274
|
context.update(stage_status=transfer_statuses.ERRORED, error=e)
|
|
253
275
|
# fire completed signal, after context update. handlers can use context to detect error
|
|
254
|
-
signal.completed.fire(context=context)
|
|
276
|
+
signal.completed.fire(context=prepared_context or context)
|
|
255
277
|
return transfer_statuses.ERRORED
|