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.
Files changed (64) hide show
  1. morango/__init__.py +1 -6
  2. morango/api/serializers.py +3 -0
  3. morango/api/viewsets.py +38 -23
  4. morango/apps.py +1 -2
  5. morango/constants/settings.py +3 -0
  6. morango/constants/transfer_stages.py +1 -1
  7. morango/constants/transfer_statuses.py +1 -1
  8. morango/errors.py +4 -0
  9. morango/management/commands/cleanupsyncs.py +64 -14
  10. morango/migrations/0001_initial.py +0 -2
  11. morango/migrations/0001_squashed_0024_auto_20240129_1757.py +583 -0
  12. morango/migrations/0002_auto_20170511_0400.py +0 -2
  13. morango/migrations/0002_store_idx_morango_deserialize.py +21 -0
  14. morango/migrations/0003_auto_20170519_0543.py +0 -2
  15. morango/migrations/0004_auto_20170520_2112.py +0 -2
  16. morango/migrations/0005_auto_20170629_2139.py +0 -2
  17. morango/migrations/0006_instanceidmodel_system_id.py +0 -2
  18. morango/migrations/0007_auto_20171018_1615.py +0 -2
  19. morango/migrations/0008_auto_20171114_2217.py +0 -2
  20. morango/migrations/0009_auto_20171205_0252.py +0 -2
  21. morango/migrations/0010_auto_20171206_1615.py +0 -2
  22. morango/migrations/0011_sharedkey.py +0 -2
  23. morango/migrations/0012_auto_20180927_1658.py +0 -2
  24. morango/migrations/0013_auto_20190627_1513.py +0 -2
  25. morango/migrations/0014_syncsession_extra_fields.py +0 -2
  26. morango/migrations/0015_auto_20200508_2104.py +2 -3
  27. morango/migrations/0016_store_deserialization_error.py +2 -3
  28. morango/migrations/0017_store_last_transfer_session_id.py +1 -2
  29. morango/migrations/0018_auto_20210714_2216.py +2 -3
  30. morango/migrations/0019_auto_20220113_1807.py +2 -3
  31. morango/migrations/0020_postgres_fix_nullable.py +0 -2
  32. morango/migrations/0021_store_partition_index_create.py +0 -2
  33. morango/migrations/0022_rename_instance_fields.py +23 -0
  34. morango/migrations/0023_add_instance_id_fields.py +24 -0
  35. morango/migrations/0024_auto_20240129_1757.py +28 -0
  36. morango/models/__init__.py +0 -6
  37. morango/models/certificates.py +137 -28
  38. morango/models/core.py +48 -46
  39. morango/models/fields/crypto.py +20 -6
  40. morango/models/fields/uuids.py +2 -1
  41. morango/models/utils.py +5 -6
  42. morango/proquint.py +2 -3
  43. morango/registry.py +28 -49
  44. morango/sync/backends/base.py +34 -0
  45. morango/sync/backends/postgres.py +129 -0
  46. morango/sync/backends/utils.py +10 -11
  47. morango/sync/context.py +198 -13
  48. morango/sync/controller.py +33 -11
  49. morango/sync/operations.py +324 -251
  50. morango/sync/session.py +11 -0
  51. morango/sync/syncsession.py +78 -85
  52. morango/sync/utils.py +18 -0
  53. morango/urls.py +3 -3
  54. morango/utils.py +2 -3
  55. {morango-0.6.11.dist-info → morango-0.8.6.dist-info}/METADATA +29 -14
  56. morango-0.8.6.dist-info/RECORD +79 -0
  57. {morango-0.6.11.dist-info → morango-0.8.6.dist-info}/WHEEL +1 -1
  58. morango/models/morango_mptt.py +0 -33
  59. morango/settings.py +0 -115
  60. morango/wsgi.py +0 -33
  61. morango-0.6.11.dist-info/RECORD +0 -77
  62. {morango-0.6.11.dist-info → morango-0.8.6.dist-info/licenses}/AUTHORS.md +0 -0
  63. {morango-0.6.11.dist-info → morango-0.8.6.dist-info/licenses}/LICENSE +0 -0
  64. {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)
@@ -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
- with self.connection.schema_editor() as schema_editor:
114
- for field in self.fields:
115
- # generates the SQL expression for the table column
116
- field_sql, field_params = schema_editor.column_sql(
117
- self, field, include_default=True
118
- )
119
- field_sql_name = self.connection.ops.quote_name(field.column)
120
- fields.append("{name} {sql}".format(name=field_sql_name, sql=field_sql))
121
- params.extend(field_params)
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
- raise MorangoContextUpdateError("Filter already exists")
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
- self.filter = sync_filter or self.filter
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
- capabilities = kwargs.pop("capabilities", [])
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)
@@ -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 the status changes,
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 the status changes,
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(self, target_stage, context=None, max_interval=5):
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
- sleep(min(0.3 * (2 ** tries - 1), max_interval))
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=context)
251
+ signal.started.fire(context=prepared_context)
235
252
 
236
- result = middleware(context)
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=context)
265
+ signal.completed.fire(context=prepared_context)
245
266
  else:
246
- signal.in_progress.fire(context=context)
267
+ signal.in_progress.fire(context=prepared_context)
247
268
 
248
- return result
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