Skip to content

Commit d7bbfc0

Browse files
committed
Introduce file diff table
Expand diff json column into separate table
1 parent 9a9ab4e commit d7bbfc0

8 files changed

Lines changed: 174 additions & 47 deletions

File tree

server/mergin/sync/files.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ def create_obj(self, data, **kwargs):
116116

117117
class ProjectFileSchema(FileSchema):
118118
mtime = DateTimeWithZ()
119-
diff = fields.Nested(FileSchema())
119+
diff = fields.Nested(
120+
FileSchema(),
121+
)
120122

121123
@post_dump
122124
def patch_field(self, data, **kwargs):

server/mergin/sync/models.py

Lines changed: 149 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -164,14 +164,18 @@ def files(self) -> List[ProjectFile]:
164164
SELECT
165165
fp.path,
166166
fh.size,
167-
fh.diff,
168167
fh.location,
169168
fh.checksum,
170-
pv.created AS mtime
169+
pv.created AS mtime,
170+
fd.path as diff_path,
171+
fd.size as diff_size,
172+
fd.checksum as diff_checksum,
173+
fd.location as diff_location
171174
FROM files_ids
172175
LEFT OUTER JOIN file_history fh ON fh.id = files_ids.fh_id
173176
LEFT OUTER JOIN project_file_path fp ON fp.id = fh.file_path_id
174-
LEFT OUTER JOIN project_version pv ON pv.id = fh.version_id;
177+
LEFT OUTER JOIN project_version pv ON pv.id = fh.version_id
178+
LEFT OUTER JOIN file_diff fd ON fd.file_path_id = fh.file_path_id AND fd.version = fh.project_version_name and fd.rank = 0;
175179
"""
176180
params = {"project_id": self.id}
177181
files = [
@@ -181,7 +185,16 @@ def files(self) -> List[ProjectFile]:
181185
checksum=row.checksum,
182186
location=row.location,
183187
mtime=row.mtime,
184-
diff=File(**row.diff) if row.diff else None,
188+
diff=(
189+
File(
190+
path=row.diff_path,
191+
size=row.diff_size,
192+
checksum=row.diff_checksum,
193+
location=row.diff_location,
194+
)
195+
if row.diff_path
196+
else None
197+
),
185198
)
186199
for row in db.session.execute(query, params).fetchall()
187200
]
@@ -436,7 +449,6 @@ class FileHistory(db.Model):
436449
location = db.Column(db.String)
437450
size = db.Column(db.BigInteger, nullable=False)
438451
checksum = db.Column(db.String, nullable=False)
439-
diff = db.Column(JSONB)
440452
change = db.Column(
441453
ENUM(
442454
*PushChangeType.values(),
@@ -470,17 +482,6 @@ class FileHistory(db.Model):
470482
file_path_id,
471483
project_version_name.desc(),
472484
),
473-
db.CheckConstraint(
474-
text(
475-
"""
476-
CASE
477-
WHEN (change = 'update_diff') THEN diff IS NOT NULL
478-
ELSE diff IS NULL
479-
END
480-
"""
481-
),
482-
name="changes_with_diff",
483-
),
484485
)
485486

486487
def __init__(
@@ -491,22 +492,55 @@ def __init__(
491492
location: str,
492493
change: PushChangeType,
493494
diff: dict = None,
495+
version_name: int = None,
494496
):
495497
self.file = file
496498
self.size = size
497499
self.checksum = checksum
498500
self.location = location
499-
self.diff = diff if diff is not None else null()
500501
self.change = change.value
502+
self.project_version_name = version_name
503+
504+
if diff is not None:
505+
basefile = FileHistory.get_basefile(file.id, version_name)
506+
diff_file = FileDiff(
507+
basefile,
508+
diff.get("path"),
509+
diff.get("size"),
510+
diff.get("checksum"),
511+
rank=0,
512+
version=version_name,
513+
)
514+
db.session.add(diff_file)
501515

502516
@property
503517
def path(self) -> str:
504518
return self.file.path
505519

520+
@property
521+
def diff(self) -> Optional[FileDiff]:
522+
"""Diff file pushed with UPDATE_DIFF change type.
523+
524+
In FileDiff table it is defined as diff related to file, saved for the same project version with rank 0 (elementar diff)
525+
"""
526+
if self.change != PushChangeType.UPDATE_DIFF.value:
527+
return
528+
529+
return FileDiff.query.filter_by(
530+
file_path_id=self.file_path_id, version=self.project_version_name, rank=0
531+
).first()
532+
506533
@property
507534
def diff_file(self) -> Optional[File]:
508-
if self.diff:
509-
return File(**self.diff)
535+
if not self.diff:
536+
return
537+
538+
return File(
539+
path=self.diff.path,
540+
size=self.diff.size,
541+
checksum=self.diff.checksum,
542+
location=self.diff.location,
543+
)
510544

511545
@property
512546
def mtime(self) -> datetime:
@@ -518,7 +552,7 @@ def abs_path(self) -> str:
518552

519553
@property
520554
def expiration(self) -> Optional[datetime]:
521-
if not self.diff:
555+
if not self.diff_file:
522556
return
523557

524558
if os.path.exists(self.abs_path):
@@ -564,11 +598,7 @@ def changes(
564598
break
565599

566600
# if we are interested only in 'diffable' history (not broken with forced update)
567-
if (
568-
diffable
569-
and item.change == PushChangeType.UPDATE.value
570-
and not item.diff
571-
):
601+
if diffable and item.change == PushChangeType.UPDATE.value:
572602
break
573603

574604
return history
@@ -615,7 +645,7 @@ def diffs_chain(
615645
if history:
616646
first_change = history[-1]
617647
# we have either full history of changes or v_x = v_x+n => no basefile in way, it is 'diffable' from the end
618-
if first_change.diff:
648+
if first_change.change == PushChangeType.UPDATE_DIFF.value:
619649
# omit diff for target version as it would lead to previous version if reconstructed backward
620650
diffs = [
621651
value.diff_file
@@ -665,6 +695,75 @@ def diffs_chain(
665695

666696
return basefile, diffs
667697

698+
@classmethod
699+
def get_basefile(cls, file_path_id: int, version: int) -> Optional[FileHistory]:
700+
"""Get basefile (start of file diffable history) for diff file change at some version"""
701+
return (
702+
FileHistory.query.filter_by(file_path_id=file_path_id)
703+
.filter(
704+
FileHistory.project_version_name < version,
705+
FileHistory.change.in_(
706+
[PushChangeType.CREATE.value, PushChangeType.UPDATE.value]
707+
),
708+
)
709+
.order_by(desc(FileHistory.project_version_name))
710+
.first()
711+
)
712+
713+
714+
class FileDiff(db.Model):
715+
"""File diffs related to versioned files, also contain higher order (rank) merged diffs"""
716+
717+
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True)
718+
file_path_id = db.Column(
719+
db.BigInteger,
720+
db.ForeignKey("project_file_path.id", ondelete="CASCADE"),
721+
nullable=False,
722+
)
723+
# reference to actual full gpkg file
724+
basefile_id = db.Column(
725+
db.BigInteger,
726+
db.ForeignKey("file_history.id", ondelete="CASCADE"),
727+
index=True,
728+
nullable=False,
729+
)
730+
path = db.Column(db.String, nullable=False, index=True)
731+
# exponential order of merged diff, 0 is a source diff file uploaded by user, > 0 is merged diff
732+
rank = db.Column(db.Integer, nullable=False, index=True)
733+
# to which project version is this linked
734+
version = db.Column(db.Integer, nullable=False, index=True)
735+
# path on FS relative to project directory
736+
location = db.Column(db.String)
737+
size = db.Column(db.BigInteger, nullable=False)
738+
checksum = db.Column(db.String, nullable=False)
739+
740+
__table_args__ = (
741+
db.UniqueConstraint("file_path_id", "rank", "version", name="unique_diff"),
742+
db.Index("ix_file_diff_file_path_id_version_rank", file_path_id, version, rank),
743+
)
744+
745+
def __init__(
746+
self,
747+
basefile: FileHistory,
748+
path: str,
749+
size: int,
750+
checksum: str,
751+
rank: int,
752+
version: int,
753+
):
754+
self.basefile_id = basefile.id
755+
self.file_path_id = basefile.file_path_id
756+
self.path = path
757+
self.size = size
758+
self.checksum = checksum
759+
self.rank = rank
760+
self.version = version
761+
762+
if rank > 0:
763+
self.location = f"diffs/{path}"
764+
else:
765+
self.location = f"v{version}/{path}"
766+
668767

669768
class ProjectVersion(db.Model):
670769
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
@@ -760,11 +859,12 @@ def __init__(
760859
diff=(
761860
asdict(upload_file.diff)
762861
if (is_diff_change and upload_file.diff)
763-
else null()
862+
else None
764863
),
765864
change=(
766865
PushChangeType.UPDATE_DIFF if is_diff_change else change_type
767866
),
867+
version_name=self.name,
768868
)
769869
fh.version = self
770870
fh.project_version_name = self.name
@@ -822,14 +922,18 @@ def _files_from_start(self):
822922
SELECT
823923
fp.path,
824924
fh.size,
825-
fh.diff,
826925
fh.location,
827926
fh.checksum,
828-
pv.created AS mtime
927+
pv.created AS mtime,
928+
fd.path as diff_path,
929+
fd.size as diff_size,
930+
fd.checksum as diff_checksum,
931+
fd.location as diff_location
829932
FROM latest_changes ch
830933
LEFT OUTER JOIN file_history fh ON (fh.file_path_id = ch.id AND fh.project_version_name = ch.version)
831934
LEFT OUTER JOIN project_file_path fp ON fp.id = fh.file_path_id
832935
LEFT OUTER JOIN project_version pv ON pv.id = fh.version_id
936+
LEFT OUTER JOIN file_diff fd ON fd.file_path_id = fh.file_path_id AND fd.version = fh.project_version_name and fd.rank = 0
833937
WHERE fh.change != 'delete';
834938
"""
835939
params = {"project_id": self.project_id, "version": self.name}
@@ -878,14 +982,18 @@ def _files_from_end(self):
878982
SELECT
879983
fp.path,
880984
fh.size,
881-
fh.diff,
882985
fh.location,
883986
fh.checksum,
884-
pv.created AS mtime
987+
pv.created AS mtime,
988+
fd.path as diff_path,
989+
fd.size as diff_size,
990+
fd.checksum as diff_checksum,
991+
fd.location as diff_location
885992
FROM files_changes_before_version ch
886993
INNER JOIN file_history fh ON (fh.file_path_id = ch.file_id AND fh.project_version_name = ch.version)
887994
INNER JOIN project_file_path fp ON fp.id = fh.file_path_id
888995
INNER JOIN project_version pv ON pv.id = fh.version_id
996+
LEFT OUTER JOIN file_diff fd ON fd.file_path_id = fh.file_path_id AND fd.version = fh.project_version_name and fd.rank = 0
889997
WHERE fh.change != 'delete'
890998
ORDER BY fp.path;
891999
"""
@@ -909,7 +1017,16 @@ def files(self) -> List[ProjectFile]:
9091017
checksum=row.checksum,
9101018
location=row.location,
9111019
mtime=row.mtime,
912-
diff=File(**row.diff) if row.diff else None,
1020+
diff=(
1021+
File(
1022+
path=row.diff_path,
1023+
checksum=row.diff_checksum,
1024+
size=row.diff_size,
1025+
location=row.diff_location,
1026+
)
1027+
if row.diff_path
1028+
else None
1029+
),
9131030
)
9141031
for row in result
9151032
]

server/mergin/sync/public_api_controller.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from ..auth import auth_required
4141
from ..auth.models import User
4242
from .models import (
43+
FileDiff,
4344
Project,
4445
ProjectVersion,
4546
Upload,

server/mergin/sync/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def optimize_storage(project_id):
8989

9090
for item in f_history:
9191
# no diffs, it is a basefile for geodiff
92-
if not item.diff:
92+
if not item.diff_file:
9393
continue
9494

9595
# skip the latest file version (high chance of being used)

server/mergin/sync/utils.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,9 +345,8 @@ def files_size():
345345
WHERE change = 'create'::push_change_type OR change = 'update'::push_change_type
346346
UNION
347347
SELECT
348-
SUM(COALESCE((diff ->> 'size')::bigint, 0))
349-
FROM file_history
350-
WHERE change = 'update_diff'::push_change_type
348+
SUM(size)
349+
FROM file_diff
351350
UNION
352351
SELECT
353352
SUM(size)

server/mergin/tests/test_project_controller.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from sqlalchemy import desc
2626
from ..app import db
2727
from ..sync.models import (
28+
FileDiff,
2829
Project,
2930
Upload,
3031
ProjectVersion,
@@ -443,7 +444,7 @@ def test_add_project(client, app, data, expected):
443444
assert not any(file.diff for file in proj_files)
444445
assert not any(file.diff for file in pv.files)
445446
assert all(
446-
item.change == PushChangeType.CREATE.value and not item.diff
447+
item.change == PushChangeType.CREATE.value and not item.diff_file
447448
for item in pv.changes.all()
448449
)
449450
# cleanup
@@ -1597,7 +1598,7 @@ def test_push_no_diff_finish(client):
15971598
file_meta = latest_version.changes.filter(
15981599
FileHistory.change == PushChangeType.UPDATE_DIFF.value
15991600
).first()
1600-
assert file_meta.diff is not None
1601+
assert file_meta.diff_file is not None
16011602
assert os.path.exists(
16021603
os.path.join(upload.project.storage.project_dir, file_meta.diff_file.location)
16031604
)
@@ -2370,7 +2371,8 @@ def test_version_files(client, diff_project):
23702371
x.checksum == y.checksum
23712372
and x.path == y.path
23722373
and x.location == y.location
2373-
and x.diff == y.diff
2374+
and x.diff_path == y.diff_path
2375+
and x.diff_checksum == y.diff_checksum
23742376
for x, y in zip(
23752377
sorted(forward_search, key=lambda f: f.path),
23762378
sorted(backward_search, key=lambda f: f.path),
@@ -2397,7 +2399,7 @@ def test_delete_diff_file(client):
23972399
project_version_name=upload.project.latest_version,
23982400
change=PushChangeType.UPDATE_DIFF.value,
23992401
).first()
2400-
assert fh.diff is not None
2402+
assert fh.diff_file is not None
24012403

24022404
# delete file
24032405
diff_change = next(
@@ -2424,7 +2426,7 @@ def test_delete_diff_file(client):
24242426
project_version_name=upload.project.latest_version,
24252427
change=PushChangeType.DELETE.value,
24262428
).first()
2427-
assert fh.path == "base.gpkg" and fh.diff is None
2429+
assert fh.path == "base.gpkg" and fh.diff_file is None
24282430

24292431

24302432
def test_cache_files_ids(client):

0 commit comments

Comments
 (0)