Skip to content

Commit f4cb025

Browse files
authored
feat(snapshot): add safe merge import plans (#42)
1 parent d9bb157 commit f4cb025

4 files changed

Lines changed: 208 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Add explicit monotonic snapshot merge planning and import-impact reporting so cache consumers can apply changed shards without silently replacing local rows, while exact mirrors retain replacement semantics.
6+
57
## v0.13.1 - 2026-06-23
68

79
- Harden crawlkit scheduler, output, release-check, vector ranking, and CI

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ See `docs/boundary.md` for the crawlkit-versus-app ownership boundary and
2727
- `config`: standard TOML config paths, opt-in platform-native runtime dirs,
2828
migration-safe legacy path fallback, and token diagnostics.
2929
- `store`: SQLite open/read-only/transaction/query helpers plus safe FTS5 term and optimization helpers.
30-
- `snapshot`: `manifest.json` plus JSONL/Gzip table snapshot export, file fingerprints, incremental import, and managed sidecar trees.
30+
- `snapshot`: `manifest.json` plus JSONL/Gzip table snapshot export, file fingerprints, exact or monotonic-merge import planning, impact classification, and managed sidecar trees.
3131
- `backup`: age-encrypted JSONL/Gzip shards, backup manifests, recipient/identity helpers, history listing, and historical-ref restore verification.
3232
- `mirror`: clone/init/pull/commit/push helpers plus non-mutating fetch, immutable tags, Git-object reads, and history inspection for private snapshot repos.
3333
- `state`: generic crawler cursor and freshness records, including mapped adapters for existing app table layouts.

snapshot/snapshot.go

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ type ImportPlan struct {
111111
Tables []TableImportPlan
112112
}
113113

114+
type ImportImpact string
115+
116+
const (
117+
ImportImpactNone ImportImpact = "none"
118+
ImportImpactMerge ImportImpact = "merge"
119+
ImportImpactReplace ImportImpact = "replace"
120+
)
121+
114122
type TableImportPlan struct {
115123
Table TableManifest
116124
Mode TableImportMode
@@ -240,6 +248,17 @@ func Import(ctx context.Context, opts ImportOptions) (Manifest, error) {
240248
}
241249

242250
func PlanIncrementalImport(previous, current Manifest) ImportPlan {
251+
return planIncrementalImport(previous, current, false)
252+
}
253+
254+
// PlanMergeImport prepares a monotonic cache refresh. Changed snapshot files
255+
// are upserted without deleting rows that disappeared from the snapshot. Use
256+
// PlanIncrementalImport when the destination must exactly mirror the source.
257+
func PlanMergeImport(previous, current Manifest) ImportPlan {
258+
return planIncrementalImport(previous, current, true)
259+
}
260+
261+
func planIncrementalImport(previous, current Manifest, merge bool) ImportPlan {
243262
if current.Version != previous.Version {
244263
return ImportPlan{Full: true, Reason: "manifest version changed"}
245264
}
@@ -260,15 +279,21 @@ func PlanIncrementalImport(previous, current Manifest) ImportPlan {
260279
for _, table := range current.Tables {
261280
previousTable, ok := previousTables[table.Name]
262281
if !ok {
282+
mode := TableImportReplace
283+
reason := "new table"
284+
if merge {
285+
mode = TableImportFiles
286+
reason = "merge new table"
287+
}
263288
plan.Tables = append(plan.Tables, TableImportPlan{
264289
Table: table,
265-
Mode: TableImportReplace,
290+
Mode: mode,
266291
Files: tableFileManifests(table),
267-
Reason: "new table",
292+
Reason: reason,
268293
})
269294
continue
270295
}
271-
tablePlan := planTableIncrement(previousTable, table)
296+
tablePlan := planTableIncrement(previousTable, table, merge)
272297
plan.Tables = append(plan.Tables, tablePlan)
273298
}
274299
return plan
@@ -286,6 +311,22 @@ func (p ImportPlan) Changed() bool {
286311
return false
287312
}
288313

314+
func (p ImportPlan) Impact() ImportImpact {
315+
if p.Full {
316+
return ImportImpactReplace
317+
}
318+
impact := ImportImpactNone
319+
for _, table := range p.Tables {
320+
switch table.Mode {
321+
case TableImportReplace:
322+
return ImportImpactReplace
323+
case TableImportFiles:
324+
impact = ImportImpactMerge
325+
}
326+
}
327+
return impact
328+
}
329+
289330
func ImportIncremental(ctx context.Context, opts IncrementalImportOptions) (Manifest, ImportPlan, error) {
290331
if opts.DB == nil {
291332
return Manifest{}, ImportPlan{}, errors.New("db is required")
@@ -691,7 +732,7 @@ func exportValue(value any) any {
691732
}
692733
}
693734

694-
func planTableIncrement(previous, current TableManifest) TableImportPlan {
735+
func planTableIncrement(previous, current TableManifest, merge bool) TableImportPlan {
695736
if !sameStrings(previous.Columns, current.Columns) {
696737
return TableImportPlan{Table: current, Mode: TableImportReplace, Files: tableFileManifests(current), Reason: "columns changed"}
697738
}
@@ -706,6 +747,9 @@ func planTableIncrement(previous, current TableManifest) TableImportPlan {
706747
if sameFileManifests(previousFiles, currentFiles) {
707748
return TableImportPlan{Table: current, Mode: TableImportSkip, Reason: "unchanged"}
708749
}
750+
if merge {
751+
return planTableMerge(previousFiles, currentFiles, current)
752+
}
709753
if len(currentFiles) < len(previousFiles) {
710754
return TableImportPlan{Table: current, Mode: TableImportReplace, Files: currentFiles, Reason: "files removed"}
711755
}
@@ -734,6 +778,31 @@ func planTableIncrement(previous, current TableManifest) TableImportPlan {
734778
return TableImportPlan{Table: current, Mode: TableImportFiles, Files: changed, Reason: "tail files changed"}
735779
}
736780

781+
func planTableMerge(previousFiles, currentFiles []FileManifest, current TableManifest) TableImportPlan {
782+
previousByPath := make(map[string]FileManifest, len(previousFiles))
783+
for _, file := range previousFiles {
784+
previousByPath[file.Path] = file
785+
}
786+
currentPaths := make(map[string]struct{}, len(currentFiles))
787+
changed := make([]FileManifest, 0, len(currentFiles))
788+
for _, file := range currentFiles {
789+
currentPaths[file.Path] = struct{}{}
790+
previous, ok := previousByPath[file.Path]
791+
if !ok || !sameFileManifest(previous, file) {
792+
changed = append(changed, file)
793+
}
794+
}
795+
for _, file := range previousFiles {
796+
if _, ok := currentPaths[file.Path]; !ok {
797+
return TableImportPlan{Table: current, Mode: TableImportReplace, Files: currentFiles, Reason: "files removed"}
798+
}
799+
}
800+
if len(changed) == 0 {
801+
return TableImportPlan{Table: current, Mode: TableImportSkip, Reason: "unchanged"}
802+
}
803+
return TableImportPlan{Table: current, Mode: TableImportFiles, Files: changed, Reason: "merge changed files"}
804+
}
805+
737806
func tableShardDir(table string) (string, error) {
738807
table = strings.TrimSpace(table)
739808
if table == "" {

snapshot/snapshot_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,82 @@ func TestPlanIncrementalImportReplacesUnsafeTailChanges(t *testing.T) {
268268
}
269269
}
270270

271+
func TestPlanMergeImportUsesChangedFilesWithoutReplacement(t *testing.T) {
272+
previous := Manifest{
273+
Version: 1,
274+
Tables: []TableManifest{{
275+
Name: "things",
276+
Columns: []string{"id", "body"},
277+
Files: []string{"tables/things/000000.jsonl.gz", "tables/things/000001.jsonl.gz"},
278+
FileManifests: []FileManifest{
279+
{Path: "tables/things/000000.jsonl.gz", Rows: 2, Size: 100, SHA256: "same"},
280+
{Path: "tables/things/000001.jsonl.gz", Rows: 1, Size: 50, SHA256: "old"},
281+
},
282+
}},
283+
}
284+
current := previous
285+
current.Tables = append([]TableManifest(nil), previous.Tables...)
286+
current.Tables[0].Files = append([]string(nil), previous.Tables[0].Files...)
287+
current.Tables[0].FileManifests = []FileManifest{
288+
{Path: "tables/things/000000.jsonl.gz", Rows: 2, Size: 100, SHA256: "same"},
289+
{Path: "tables/things/000001.jsonl.gz", Rows: 2, Size: 75, SHA256: "new"},
290+
{Path: "tables/things/000002.jsonl.gz", Rows: 1, Size: 25, SHA256: "added"},
291+
}
292+
current.Tables[0].Files = append(current.Tables[0].Files, "tables/things/000002.jsonl.gz")
293+
294+
plan := PlanMergeImport(previous, current)
295+
if plan.Impact() != ImportImpactMerge {
296+
t.Fatalf("impact = %q, plan = %+v", plan.Impact(), plan)
297+
}
298+
if len(plan.Tables) != 1 || plan.Tables[0].Mode != TableImportFiles {
299+
t.Fatalf("plan = %+v", plan)
300+
}
301+
files := plan.Tables[0].Files
302+
if len(files) != 2 || files[0].SHA256 != "new" || files[1].SHA256 != "added" {
303+
t.Fatalf("files = %+v", files)
304+
}
305+
}
306+
307+
func TestPlanMergeImportStillRequiresReplacementForRemovedFiles(t *testing.T) {
308+
previous := Manifest{Version: 1, Tables: []TableManifest{{
309+
Name: "things", Columns: []string{"id"}, Files: []string{"one", "two"},
310+
FileManifests: []FileManifest{{Path: "one", SHA256: "one"}, {Path: "two", SHA256: "two"}},
311+
}}}
312+
current := Manifest{Version: 1, Tables: []TableManifest{{
313+
Name: "things", Columns: []string{"id"}, Files: []string{"one"},
314+
FileManifests: []FileManifest{{Path: "one", SHA256: "one"}},
315+
}}}
316+
317+
plan := PlanMergeImport(previous, current)
318+
if plan.Impact() != ImportImpactReplace || plan.Tables[0].Reason != "files removed" {
319+
t.Fatalf("plan = %+v", plan)
320+
}
321+
}
322+
323+
func TestPlanMergeImportMergesNewTables(t *testing.T) {
324+
current := Manifest{Version: 1, Tables: []TableManifest{{
325+
Name: "things", Columns: []string{"id"}, Files: []string{"one"},
326+
FileManifests: []FileManifest{{Path: "one", SHA256: "one"}},
327+
}}}
328+
329+
plan := PlanMergeImport(Manifest{Version: 1}, current)
330+
if plan.Impact() != ImportImpactMerge || plan.Tables[0].Mode != TableImportFiles {
331+
t.Fatalf("plan = %+v", plan)
332+
}
333+
}
334+
335+
func TestImportPlanImpact(t *testing.T) {
336+
if impact := (ImportPlan{}).Impact(); impact != ImportImpactNone {
337+
t.Fatalf("empty impact = %q", impact)
338+
}
339+
if impact := (ImportPlan{Tables: []TableImportPlan{{Mode: TableImportFiles}}}).Impact(); impact != ImportImpactMerge {
340+
t.Fatalf("merge impact = %q", impact)
341+
}
342+
if impact := (ImportPlan{Full: true}).Impact(); impact != ImportImpactReplace {
343+
t.Fatalf("full impact = %q", impact)
344+
}
345+
}
346+
271347
func TestImportIncrementalImportsOnlyPlannedFiles(t *testing.T) {
272348
ctx := context.Background()
273349
src, err := store.Open(ctx, store.Options{
@@ -390,6 +466,62 @@ func TestImportIncrementalReplacesChangedTailShard(t *testing.T) {
390466
}
391467
}
392468

469+
func TestImportIncrementalMergePreservesDestinationOnlyRows(t *testing.T) {
470+
ctx := context.Background()
471+
src, err := store.Open(ctx, store.Options{
472+
Path: filepath.Join(t.TempDir(), "src.db"),
473+
Schema: `create table things(id text primary key, body text not null);`,
474+
})
475+
if err != nil {
476+
t.Fatal(err)
477+
}
478+
defer src.Close()
479+
mustExec(t, src.DB(), `insert into things(id, body) values('one', 'old')`)
480+
root := t.TempDir()
481+
previous, err := Export(ctx, ExportOptions{DB: src.DB(), RootDir: root, Tables: []string{"things"}})
482+
if err != nil {
483+
t.Fatal(err)
484+
}
485+
486+
dst, err := store.Open(ctx, store.Options{
487+
Path: filepath.Join(t.TempDir(), "dst.db"),
488+
Schema: `create table things(id text primary key, body text not null);`,
489+
})
490+
if err != nil {
491+
t.Fatal(err)
492+
}
493+
defer dst.Close()
494+
if _, err := Import(ctx, ImportOptions{DB: dst.DB(), RootDir: root}); err != nil {
495+
t.Fatal(err)
496+
}
497+
mustExec(t, dst.DB(), `insert into things(id, body) values('local', 'keep')`)
498+
mustExec(t, src.DB(), `update things set body = 'new' where id = 'one'`)
499+
mustExec(t, src.DB(), `insert into things(id, body) values('two', 'added')`)
500+
current, err := Export(ctx, ExportOptions{DB: src.DB(), RootDir: root, Tables: []string{"things"}})
501+
if err != nil {
502+
t.Fatal(err)
503+
}
504+
plan := PlanMergeImport(previous, current)
505+
if plan.Impact() != ImportImpactMerge {
506+
t.Fatalf("plan = %+v", plan)
507+
}
508+
if _, _, err := ImportIncremental(ctx, IncrementalImportOptions{
509+
DB: dst.DB(),
510+
RootDir: root,
511+
Current: current,
512+
Plan: plan,
513+
}); err != nil {
514+
t.Fatal(err)
515+
}
516+
var got string
517+
if err := dst.DB().QueryRowContext(ctx, `select group_concat(id || ':' || body, ',') from (select id, body from things order by id)`).Scan(&got); err != nil {
518+
t.Fatal(err)
519+
}
520+
if got != "local:keep,one:new,two:added" {
521+
t.Fatalf("things = %q", got)
522+
}
523+
}
524+
393525
func TestImportHooks(t *testing.T) {
394526
ctx := context.Background()
395527
src, err := store.Open(ctx, store.Options{

0 commit comments

Comments
 (0)