Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
590c66a
Add regression test for update_fields issue
bjester May 1, 2026
00ff230
Implement fix for update_fields issue
bjester May 1, 2026
8106e8d
Bump version and update changelog
bjester May 1, 2026
a571afb
Merge pull request #326 from bjester/pls-save-fields
bjester May 5, 2026
83cd4b4
Remove multiprocessing fallback for key generation. Stay in process.
rtibbles Jun 4, 2026
ccc64ee
Merge pull request #331 from rtibbles/no_zombies_please_were_british
rtibbles Jun 4, 2026
a3fb088
Bump version and update changelog
rtibbles Jun 12, 2026
03bbfc2
Merge pull request #335 from rtibbles/bump_0.8.13
rtibbles Jun 12, 2026
a16ebcd
Add deferrable FK utility, test case, and migration
bjester Jun 26, 2026
4de5f43
Bump version and update changelog
bjester Jun 26, 2026
51e70a1
Merge pull request #341 from bjester/make-deferrable-pls
bjester Jun 30, 2026
73bca99
Post 2.17 requests declares urllib3 a normal dep
bjester Jun 29, 2026
d3a64a1
Allow repeat push/pull of buffers
bjester Jun 30, 2026
0fd9fa3
Ignore 404s on close/destroy requests due to possible retry
bjester Jun 30, 2026
ea045b9
Add retry behavior for low level connection issues not captured by ur…
bjester Jun 30, 2026
ddb551f
Update changelog
bjester Jun 30, 2026
3ab3267
Merge pull request #342 from bjester/retry-defense
bjester Jul 6, 2026
a390e2b
Improve buffer serialization performance by performing bulk counter l…
bjester Jul 7, 2026
3548306
Bump version and update changelog
bjester Jul 7, 2026
41636c4
Merge pull request #345 from bjester/buffer-serialization-perf
bjester Jul 7, 2026
d5f3459
Be more restrictive on what fields are saved during the sync stages
bjester Jul 8, 2026
e873adb
Add regression test for handling of is_push
bjester Jul 8, 2026
a7d4d66
TransferSession.push is non-nullable boolean, and a False value was i…
bjester Jul 8, 2026
f2f513c
Update changelog
bjester Jul 8, 2026
635f8d9
Merge pull request #346 from bjester/tidy-the-strawberry-fields
rtibbles Jul 9, 2026
8f12d91
Resolve merge conflicts
bjester Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

List of the most important changes for each release.

## 0.8.15
- Improves buffer serialization performance by performing bulk counter lookup
- Corrects an initialization issue with `SessionContext` that leads to an incorrect value for `is_push`
- Save calls to Morango models during a sync are now scoped to only the changed fields

## 0.8.14
- Adds utility for addressing immediate FK constraints caused by Django upgrade, automatically performed for morango models in a Django migration.
- Adds retry behavior for low-level connection issues not handled by `urllib3` retries
- Allows repeat pushes and pulls of buffers during transfer
- Ignores HTTP 404 errors during sync or transfer session closure, which may occur if they're already closed

## 0.8.13
- Removes multiprocessing fallback for RSA key generation to avoid leaving zombie processes; key generation now stays in-process

## 0.8.12
- Fixes issue where dirty-bit isn't updated when calling save on a syncable model with `update_fields`

## 0.8.11
- Adds additional `deserialization_exception` field to `Store` model to track the fully qualified exception path

Expand Down
31 changes: 31 additions & 0 deletions morango/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from collections import defaultdict

from rest_framework import exceptions
from rest_framework import serializers
from rest_framework.fields import ReadOnlyField
Expand Down Expand Up @@ -157,11 +159,40 @@ class Meta:
read_only_fields = fields


class BufferListSerializer(serializers.ListSerializer):
def to_representation(self, data):
buffers = list(data)
if buffers:
rmcb_map = defaultdict(list)
transfer_session_ids = {b.transfer_session_id for b in buffers}
# Morango implementation will only ever call this for one transfer session ID at a time,
Comment thread
bjester marked this conversation as resolved.
# but a loop makes the code straightforward regardless and limits the quantity of
# SQLite variables in use
for transfer_session_id in transfer_session_ids:
# bulk-fetch all RMCB records needed for this batch of buffers in a
# single query, then cache the relevant subset on each buffer, instead
# of letting each buffer's nested rmcb_list serializer issue its own query
rmcb_queryset = RecordMaxCounterBuffer.objects.filter(
transfer_session_id=transfer_session_id,
model_uuid__in={
b.model_uuid
for b in buffers
if b.transfer_session_id == transfer_session_id
},
)
for rmcb in rmcb_queryset:
rmcb_map[(transfer_session_id, rmcb.model_uuid)].append(rmcb)
for buffer in buffers:
buffer._rmcb_list = rmcb_map[(buffer.transfer_session_id, buffer.model_uuid)]
return super(BufferListSerializer, self).to_representation(buffers)


class BufferSerializer(serializers.ModelSerializer):
rmcb_list = RecordMaxCounterBufferSerializer(many=True)

class Meta:
model = Buffer
list_serializer_class = BufferListSerializer
fields = (
"serialized",
"deleted",
Expand Down
137 changes: 137 additions & 0 deletions morango/deferrable_foreign_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""
Helpers for ensuring that existing SQLite foreign key constraints are
``DEFERRABLE INITIALLY DEFERRED``.

Prior to Django 3.1, SQLite tables were created with immediate foreign key constraints, e.g.::

"store_model_id" char(32) NOT NULL REFERENCES "morango_store" ("id")

Since Django 3.1 the same column is created as::

"store_model_id" char(32) NOT NULL REFERENCES "morango_store" ("id") DEFERRABLE INITIALLY DEFERRED

Django relies on deferred constraint checking for correct cascade-deletion ordering. Databases
created before Morango 0.8 (with Django 1.11) therefore retain immediate constraints, which can
raise ``IntegrityError: FOREIGN KEY constraint failed`` during operations such as sync
deserialization once foreign key enforcement is enabled at the database level (the default since
Django 3.2).

The migration framework does not regenerate these constraints on its own because the deferrable
clause is not part of the field definition that migrations track. This module rebuilds the affected
tables so that a migrated database ends up with the same schema as a freshly created one.
"""

import logging
from typing import List
from typing import Optional

from django.apps import apps as global_apps
from django.apps.registry import Apps
from django.db import connection
from django.db import DatabaseError
from django.db import router
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.backends.sqlite3.schema import DatabaseSchemaEditor

logger = logging.getLogger(__name__)


def _get_table_sql(conn, table_name: str) -> Optional[str]:
"""
Return the ``CREATE TABLE`` statement stored by SQLite for ``table_name``, or ``None`` if the
table does not exist.
"""
with conn.cursor() as cursor:
cursor.execute(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = %s",
[table_name],
)
row = cursor.fetchone()
return row[0] if row else None


def _table_has_immediate_foreign_key(conn, table_name: str) -> bool:
"""
Determine whether ``table_name`` has at least one foreign key constraint that is not deferrable.
Tables are created with all constraints in the same style, so the presence of a ``REFERENCES``
clause without ``DEFERRABLE`` indicates an immediate constraint that needs to be rebuilt.
"""
sql = _get_table_sql(conn, table_name)
if not sql:
return False
references_count = sql.count("REFERENCES")
if references_count == 0:
return False
# make sure that all FK columns (identified by references) have a deferrable constraint
return references_count != sql.count("DEFERRABLE")
Comment thread
bjester marked this conversation as resolved.


class MakeForeignKeysDeferrable:
"""
Invokable utility class that rebuilds of any table belonging to one of ``*app_labels`` whose
foreign key constraints are still immediate, so that they become
``DEFERRABLE INITIALLY DEFERRED``.

This can be utilized by passing this class to ``RunPython`` in a Django migration, or by
manually invoking its ``run`` method.

This is a no-op on non-SQLite backends (PostgreSQL already creates deferrable
constraints) and on tables that already use deferrable constraints, so it is
safe to apply to any database.
"""

def __init__(
self,
include_app_labels: Optional[List[str]] = None,
exclude_app_labels: Optional[List[str]] = None,
):
self.include_app_labels = include_app_labels
self.exclude_app_labels = exclude_app_labels

def _iter_apps(self, apps: Apps):
for app_config in apps.get_app_configs():
if self.exclude_app_labels is not None and app_config.label in self.exclude_app_labels:
continue
if self.include_app_labels is None or app_config.label in self.include_app_labels:
yield app_config

def __call__(self, apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
"""
Loops through all relevant django apps and their models, checking if the model's table has
FKs, and if so, runs the schema editor's utility that synchronizes the schema to the model,
which will make FKs deferrable
"""
conn = schema_editor.connection
# these should be in sync, but since we call `_remake_table`, specific to this schema editor
# class, we ensure we have the correct instance to begin with
if conn.vendor != "sqlite" or not isinstance(schema_editor, DatabaseSchemaEditor):
return

for app_config in self._iter_apps(apps):
for model in app_config.get_models(include_auto_created=True):
if not router.allow_migrate_model(conn.alias, model):
continue
table_name = model._meta.db_table
if not _table_has_immediate_foreign_key(conn, table_name):
continue
logger.info(
"Rebuilding table %s to make foreign key constraints deferrable",
table_name,
)
try:
# Rebuilding the table from the current model state regenerates the column
# definitions, which SQLite always emits with the deferrable clause (see
# DatabaseSchemaEditor.sql_create_inline_fk). Only tested with Django 3.2
schema_editor._remake_table(model)
except DatabaseError as e:
Comment thread
bjester marked this conversation as resolved.
logger.error("Failed to rebuild table %s", table_name)
logger.exception(e)

def run(self):
"""
Invoke this utility outside of Django migrations, manually passing in Django's app registry
and schema editor.
"""
# since this method is meant for manual invocation, we let the editor create a transaction
with connection.schema_editor(atomic=True) as schema_editor:
self.__call__(global_apps, schema_editor)
24 changes: 24 additions & 0 deletions morango/migrations/0004_deferrable_foreign_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""
Make existing foreign key constraints ``DEFERRABLE INITIALLY DEFERRED``.

Databases created before Morango 0.8 (with Django 1.11) have immediate SQLite foreign key
constraints, which can break cascade deletions (e.g. during sync) now that foreign keys are enforced
at the database level. See https://github.com/learningequality/kolibri/issues/14884.
"""

from django.db import migrations

from morango.deferrable_foreign_keys import MakeForeignKeysDeferrable


class Migration(migrations.Migration):
dependencies = [
("morango", "0003_store_deserialization_errors"),
]

operations = [
migrations.RunPython(
MakeForeignKeysDeferrable(include_app_labels=["morango"]),
migrations.RunPython.noop,
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

class Migration(migrations.Migration):
dependencies = [
("morango", "0003_store_deserialization_errors"),
("morango", "0004_deferrable_foreign_keys"),
Comment thread
bjester marked this conversation as resolved.
]

operations = [
Expand Down
22 changes: 19 additions & 3 deletions morango/models/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ def update_state(self, stage=None, stage_status=None):
:type stage: morango.constants.transfer_stages.*|None
:type stage_status: morango.constants.transfer_statuses.*|None
"""
update_fields = []
if stage is not None:
if self.transfer_stage and transfer_stages.stage(
self.transfer_stage
Expand All @@ -332,13 +333,18 @@ def update_state(self, stage=None, stage_status=None):
)
)
self.transfer_stage = stage
update_fields.append("transfer_stage")

if stage_status is not None:
self.transfer_stage_status = stage_status
if stage is not None or stage_status is not None:
update_fields.append("transfer_stage_status")

if update_fields:
self.last_activity_timestamp = timezone.now()
self.save()
update_fields.append("last_activity_timestamp")
self.save(update_fields=update_fields)
self.sync_session.last_activity_timestamp = timezone.now()
self.sync_session.save()
self.sync_session.save(update_fields=["last_activity_timestamp"])

def delete_buffers(self):
"""
Expand Down Expand Up @@ -565,6 +571,11 @@ class Meta:
unique_together = ("transfer_session", "model_uuid")

def rmcb_list(self):
# allow callers (e.g. BufferListSerializer) to batch-fetch RMCB records
# for many buffers at once and cache them here, to avoid an N+1 query
# pattern when serializing a large number of buffers
if hasattr(self, "_rmcb_list"):
return self._rmcb_list
return RecordMaxCounterBuffer.objects.filter(
model_uuid=self.model_uuid, transfer_session_id=self.transfer_session_id
)
Expand Down Expand Up @@ -844,6 +855,11 @@ def save(self, update_dirty_bit_to=True, *args, **kwargs):
self._morango_dirty_bit = True
elif not update_dirty_bit_to:
self._morango_dirty_bit = False

# ensure the dirty bit field is in the fields to update if present, to keep it in sync
if update_dirty_bit_to is not None and kwargs.get("update_fields") is not None:
Comment thread
bjester marked this conversation as resolved.
kwargs["update_fields"] = set(kwargs["update_fields"]) | {"_morango_dirty_bit"}

super(SyncableModel, self).save(*args, **kwargs)

def delete(self, using=None, keep_parents=False, hard_delete=False, *args, **kwargs):
Expand Down
5 changes: 1 addition & 4 deletions morango/models/fields/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,7 @@ class PythonRSAKey(BaseKey):
_private_key = None

def generate_new_key(self, keysize=2048):
try:
self._public_key, self._private_key = PYRSA.newkeys(keysize, poolsize=4)
except: # noqa: E722
self._public_key, self._private_key = PYRSA.newkeys(keysize)
self._public_key, self._private_key = PYRSA.newkeys(keysize)

def _sign(self, message):

Expand Down
2 changes: 1 addition & 1 deletion morango/sync/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def __init__(

if self.transfer_session:
self.sync_session = transfer_session.sync_session or self.sync_session
self.is_push = transfer_session.push or self.is_push
self.is_push = transfer_session.push
if transfer_session.filter:
self.filter = transfer_session.get_filter()

Expand Down
2 changes: 1 addition & 1 deletion morango/sync/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ def _invoke_middleware(self, context, middleware):
return context.stage_status
except Exception as e:
# always log the error itself
logger.error(e)
logger.exception(e)
context.update(stage_status=transfer_statuses.ERRORED, error=e)
# fire completed signal, after context update. handlers can use context to detect error
signal.completed.fire(context=prepared_context or context)
Expand Down
Loading
Loading