Skip to content

Commit 6f81f79

Browse files
authored
Merge pull request #582 from MerginMaps/fix-rank-merges-on-delta-vibe
Fix rank merges on delta vibe
2 parents ecb34c5 + 994c771 commit 6f81f79

2 files changed

Lines changed: 148 additions & 29 deletions

File tree

server/mergin/sync/models.py

Lines changed: 45 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,12 @@ class ChangeComparisonAction(Enum):
6969
"""Actions to take when comparing two changes"""
7070

7171
REPLACE = "replace"
72-
DELETE = "delete"
73-
UPDATE = "update"
74-
UPDATE_DIFF = "update_diff"
72+
UPDATE_METADATA = "update_metadata" # Update metadata and keep diffs (used for update + update sequence)
73+
REPLACE_DIFFS = "replace_diffs" # Replace diffs but keep metadata (used for update + update sequence when only diffs are changed)
7574
EXCLUDE = "exclude" # Return None to exclude the file
75+
FORCE_UPDATE = (
76+
"force_update" # Force update even if it looks like a delete + create sequence
77+
)
7678

7779

7880
class Project(db.Model):
@@ -1089,9 +1091,11 @@ def merge_changes(
10891091
continue
10901092

10911093
# Compare and merge with previous change for this file
1092-
can_delete = path in updating_files
1094+
# If file did not exist before this range (first change was CREATE),
1095+
# a CREATE+DELETE sequence is transparent — exclude the pair.
1096+
exclude_delete = path not in updating_files
10931097
new_change = ProjectVersionDelta._compare_changes(
1094-
result[path], current, can_delete
1098+
result[path], current, exclude_delete
10951099
)
10961100

10971101
# Update result (or remove if no change is detected)
@@ -1106,45 +1110,54 @@ def merge_changes(
11061110
def _compare_changes(
11071111
previous: DeltaChangeMerged,
11081112
new: DeltaChangeMerged,
1109-
prevent_delete_change: bool,
1113+
exclude_delete: bool,
11101114
) -> Optional[DeltaChangeMerged]:
11111115
"""
11121116
Compare and merge two changes for the same file.
11131117
11141118
Args:
11151119
previous: Previously accumulated change
11161120
new: New change to compare
1117-
prevent_delete_change: Whether the change can be deleted when resolving create+delete sequences
1121+
exclude_delete: If True, a CREATE+DELETE pair is excluded (file was
1122+
created and deleted within the same range — no net effect).
1123+
If False, the DELETE is kept (file existed before this range
1124+
and the client needs to remove it).
11181125
11191126
Returns:
11201127
Merged change or None if file should be excluded
11211128
"""
11221129

11231130
# Map change type pairs to actions
11241131
action_map = {
1125-
# create + delete = file is transparent for current changes -> delete it
1132+
# CREATE + DELETE: if file didn't exist before (exclude_delete=True),
1133+
# the pair cancels out (EXCLUDE). If it did exist, keep the DELETE
1134+
# so the client removes its local copy.
11261135
(
11271136
PushChangeType.CREATE,
11281137
PushChangeType.DELETE,
1129-
): ChangeComparisonAction.DELETE,
1138+
): (
1139+
ChangeComparisonAction.EXCLUDE
1140+
if exclude_delete
1141+
else ChangeComparisonAction.REPLACE
1142+
),
11301143
# create + update = create with updated info
11311144
(
11321145
PushChangeType.CREATE,
11331146
PushChangeType.UPDATE,
1134-
): ChangeComparisonAction.UPDATE,
1147+
): ChangeComparisonAction.UPDATE_METADATA,
11351148
(
11361149
PushChangeType.CREATE,
11371150
PushChangeType.UPDATE_DIFF,
1138-
): ChangeComparisonAction.UPDATE,
1151+
): ChangeComparisonAction.UPDATE_METADATA,
11391152
(
11401153
PushChangeType.CREATE,
11411154
PushChangeType.CREATE,
1142-
): ChangeComparisonAction.EXCLUDE,
1155+
): ChangeComparisonAction.REPLACE,
11431156
# update + update_diff = update with latest info
11441157
(
11451158
PushChangeType.UPDATE,
11461159
PushChangeType.UPDATE_DIFF,
1147-
): ChangeComparisonAction.UPDATE,
1160+
): ChangeComparisonAction.UPDATE_METADATA,
11481161
(
11491162
PushChangeType.UPDATE,
11501163
PushChangeType.UPDATE,
@@ -1161,7 +1174,7 @@ def _compare_changes(
11611174
(
11621175
PushChangeType.UPDATE_DIFF,
11631176
PushChangeType.UPDATE_DIFF,
1164-
): ChangeComparisonAction.UPDATE_DIFF,
1177+
): ChangeComparisonAction.REPLACE_DIFFS,
11651178
(
11661179
PushChangeType.UPDATE_DIFF,
11671180
PushChangeType.UPDATE,
@@ -1173,44 +1186,45 @@ def _compare_changes(
11731186
(
11741187
PushChangeType.UPDATE_DIFF,
11751188
PushChangeType.CREATE,
1176-
): ChangeComparisonAction.EXCLUDE,
1189+
): ChangeComparisonAction.REPLACE,
11771190
(
11781191
PushChangeType.DELETE,
11791192
PushChangeType.CREATE,
1180-
): ChangeComparisonAction.REPLACE,
1181-
# delete + update = invalid sequence
1193+
): ChangeComparisonAction.FORCE_UPDATE,
1194+
# delete + update = replace it (used for multicheckpoint ranges when we want to keep file in history even if it was deleted in the middle, so we keep delete but update metadata and diffs)
11821195
(
11831196
PushChangeType.DELETE,
11841197
PushChangeType.UPDATE,
1185-
): ChangeComparisonAction.EXCLUDE,
1198+
): ChangeComparisonAction.REPLACE,
11861199
(
11871200
PushChangeType.DELETE,
11881201
PushChangeType.UPDATE_DIFF,
11891202
): ChangeComparisonAction.EXCLUDE,
11901203
(
11911204
PushChangeType.DELETE,
11921205
PushChangeType.DELETE,
1193-
): ChangeComparisonAction.EXCLUDE,
1206+
): ChangeComparisonAction.REPLACE,
11941207
}
11951208

11961209
action = action_map.get((previous.change, new.change))
1210+
11971211
result = None
11981212
if action == ChangeComparisonAction.REPLACE:
11991213
result = new
12001214

1201-
elif action == ChangeComparisonAction.DELETE:
1202-
# if change is create + delete, we can just remove the change from accumulated changes
1203-
# only if this action is allowed (file existed before)
1204-
if prevent_delete_change:
1205-
result = new
1215+
elif action == ChangeComparisonAction.FORCE_UPDATE:
1216+
# handle force update case, when previous change was delete and new change is create - just revert to update with new metadata
1217+
new.change = PushChangeType.UPDATE
1218+
new.diffs = []
1219+
result = new
12061220

1207-
elif action == ChangeComparisonAction.UPDATE:
1221+
elif action == ChangeComparisonAction.UPDATE_METADATA:
12081222
# handle update case, when previous change was create - just revert to create with new metadata
12091223
new.change = previous.change
12101224
new.diffs = []
12111225
result = new
12121226

1213-
elif action == ChangeComparisonAction.UPDATE_DIFF:
1227+
elif action == ChangeComparisonAction.REPLACE_DIFFS:
12141228
new.diffs = (previous.diffs or []) + (new.diffs or [])
12151229
result = new
12161230

@@ -1269,8 +1283,12 @@ def create_checkpoint(
12691283
changes = []
12701284
for delta in delta_range:
12711285
changes.extend(DeltaChangeSchema(many=True).load(delta.changes))
1286+
1287+
# Merge changes for compact storage and FileDiff checkpoint decisions.
1288+
merged_changes = cls.merge_changes(changes)
1289+
12721290
merged_delta_items: List[DeltaChange] = [
1273-
d.to_data_delta() for d in cls.merge_changes(changes)
1291+
d.to_data_delta() for d in merged_changes
12741292
]
12751293

12761294
# Pre-fetch data for all versioned files to create FileDiff checkpoints where it makes sense

server/mergin/tests/test_public_api_v2.py

Lines changed: 103 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
from .utils import (
2727
add_user,
28+
create_blank_version,
2829
create_project,
2930
create_workspace,
3031
diffs_are_equal,
@@ -493,6 +494,106 @@ def test_delta_merge_changes():
493494
assert merged[0].checksum == delete8.checksum
494495

495496

497+
def test_delta_cross_checkpoint_create_delete_recreate(client):
498+
"""
499+
Setup (rank-1 chunks cover 4 versions each, 4^1=4):
500+
v1–v4: tracked.gpkg does NOT exist (client at v4 has never seen it)
501+
v5–v8: tracked.gpkg is CREATED at v5 → rank-1 stores [CREATE(v5)]
502+
v9–v12: tracked.gpkg is DELETED at v9, RE-CREATED at v10
503+
→ rank-1 stores [CREATE(v10)] after pre-merging DELETE+CREATE
504+
505+
get_delta_changes(4, 12) then loads [CREATE(v5), CREATE(v10)] and the outer
506+
merge_changes hits CREATE+CREATE → EXCLUDE → returns [].
507+
508+
Expected: [CREATE(v10)] so the client downloads the re-created file.
509+
"""
510+
project = Project.query.filter_by(
511+
workspace_id=test_workspace_id, name=test_project
512+
).first()
513+
514+
tracked_gpkg = os.path.join(TMP_DIR, "tracked.gpkg")
515+
shutil.copy(os.path.join(test_project_dir, "base.gpkg"), tracked_gpkg)
516+
517+
# advance to v4 with blank versions (project starts at v1)
518+
create_blank_version(project) # v2
519+
create_blank_version(project) # v3
520+
create_blank_version(project) # v4
521+
assert project.latest_version == 4
522+
523+
# rank-1 chunk [v5–v8]: file is born at v5
524+
push_change(project, "added", "tracked.gpkg", TMP_DIR) # v5
525+
create_blank_version(project) # v6
526+
create_blank_version(project) # v7
527+
create_blank_version(project) # v8
528+
assert project.latest_version == 8
529+
530+
# rank-1 chunk [v9–v12]: file is deleted then re-created in the same chunk
531+
push_change(project, "removed", "tracked.gpkg", TMP_DIR) # v9
532+
push_change(project, "added", "tracked.gpkg", TMP_DIR) # v10
533+
create_blank_version(project) # v11
534+
create_blank_version(project) # v12
535+
assert project.latest_version == 12
536+
537+
# client at v4 never had tracked.gpkg; it was created at v5, deleted at v9, re-created at v10
538+
delta = project.get_delta_changes(4, 12)
539+
540+
assert delta is not None
541+
tracked = next((d for d in delta if d.path == "tracked.gpkg"), None)
542+
# DELETE(v9)+CREATE(v10) was collapsed to CREATE inside checkpoint(v9-v12)
543+
# then CREATE(v5)+CREATE(v10) hit CREATE+CREATE→EXCLUDE in the outer merge
544+
assert tracked is not None
545+
assert tracked.change == PushChangeType.CREATE
546+
assert tracked.version == 10
547+
548+
549+
def test_delta_cross_checkpoint_recreate_then_delete(client):
550+
"""
551+
Setup:
552+
v1–v4: base.gpkg exists (client at v4 has the file, exclude_delete should be False)
553+
v5–v8: base.gpkg DELETED at v5, RE-CREATED at v6
554+
→ rank-1 stores [CREATE(v6)] after pre-merging DELETE+CREATE
555+
v9–v12: base.gpkg DELETED at v9 (permanently gone)
556+
→ rank-1 stores [DELETE(v9)]
557+
558+
get_delta_changes(4, 12) loads [CREATE(v6), DELETE(v9)]. The stored CREATE
559+
from chunk A did not set updating_files, so exclude_delete=True and
560+
CREATE+DELETE → EXCLUDE → returns [].
561+
562+
Expected: [DELETE] so the client removes the stale local copy of base.gpkg.
563+
"""
564+
project = Project.query.filter_by(
565+
workspace_id=test_workspace_id, name=test_project
566+
).first()
567+
568+
# advance to v4; base.gpkg is present throughout (client at v4 has it)
569+
create_blank_version(project) # v2
570+
create_blank_version(project) # v3
571+
create_blank_version(project) # v4
572+
assert project.latest_version == 4
573+
574+
# rank-1 chunk [v5–v8]: delete then immediately re-create in the same chunk
575+
push_change(project, "removed", "base.gpkg", TMP_DIR) # v5
576+
push_change(project, "added", "base.gpkg", test_project_dir) # v6
577+
create_blank_version(project) # v7
578+
create_blank_version(project) # v8
579+
assert project.latest_version == 8
580+
581+
# rank-1 chunk [v9–v12]: file is permanently deleted
582+
push_change(project, "removed", "base.gpkg", TMP_DIR) # v9
583+
create_blank_version(project) # v10
584+
create_blank_version(project) # v11
585+
create_blank_version(project) # v12
586+
assert project.latest_version == 12
587+
588+
# client at v4 has base.gpkg; server deleted it at v9
589+
delta = project.get_delta_changes(4, 12)
590+
591+
assert delta is not None
592+
base_gpkg = next((d for d in delta if d.path == "base.gpkg"), None)
593+
assert base_gpkg is not None
594+
assert base_gpkg.change == PushChangeType.DELETE
595+
596+
496597
def test_project_version_delta_changes(client, diff_project: Project):
497598
"""Test that get_delta_changes and its schema work as expected"""
498599
latest_version = diff_project.get_latest_version()
@@ -524,15 +625,15 @@ def test_project_version_delta_changes(client, diff_project: Project):
524625
# delete + create version
525626
delta = diff_project.get_delta_changes(1, 3)
526627
assert len(delta) == 1
527-
assert delta[0].change == PushChangeType.CREATE
628+
assert delta[0].change == PushChangeType.UPDATE
528629
# file was created in v3
529630
assert delta[0].version == 3
530631
assert delta[0].checksum == deltas[3].changes[0]["checksum"]
531632

532633
# get_delta with update diff
533634
delta = diff_project.get_delta_changes(1, 4)
534635
assert len(delta) == 1
535-
assert delta[0].change == PushChangeType.CREATE
636+
assert delta[0].change == PushChangeType.UPDATE
536637
assert ProjectVersionDelta.query.filter_by(rank=1).count() == 0
537638

538639
# create rank 1 checkpoint for v4

0 commit comments

Comments
 (0)