strip.v

6.6 KB · raw

1//! Strip: removing a path from history, here and on the remote.2//!3//! The local half is one transaction: the saved files go, the objects nobody4//! references go, a strip event is recorded, and so is a note of which objects5//! the remote may now drop. The remote half reads that note, so it can be6//! re-run after an interruption even though the local rows are long gone.78module exchange910import json211import os12import src.events13import src.remote14import src.storage1516// Scope is what a strip would remove, for the warning shown before it runs.17pub struct Scope {18pub:19	files   int20	saves   int21	objects int22}2324pub struct LocalStrip {25pub:26	files_removed   int27	objects_dropped i6428}2930// SendReport says what a strip managed to make visible on the remote.31pub struct SendReport {32pub mut:33	records    int34	objects    int35	event_sent bool36}3738// Pending is the note a strip leaves until its remote half has finished: which39// saves it covered ('' for every save) and which objects it freed. One note per40// path, so strips of the same path that did not finish are finished together.41struct Pending {42mut:43	saves  []string44	hashes []string45}4647fn pending_key(path string) string {48	return 'strip_pending:${path}'49}5051fn read_pending(store &storage.Store, path string) Pending {52	text := store.state(pending_key(path)) or { return Pending{} }53	return json2.decode[Pending](text) or { Pending{} }54}5556pub fn strip_pending(store &storage.Store, path string) bool {57	return store.state(pending_key(path)) != none58}5960// covers is the one rule for what a strip removes: the path itself, or61// anything beneath it when it names a folder.62fn covers(path string, file string) bool {63	return file == path || file.starts_with('${path.trim_right('/')}/')64}6566pub fn strip_scope(store &storage.Store, path string, only_save string) !Scope {67	mut stmt := store.db.prepare('SELECT COUNT(*), COUNT(DISTINCT save_id), COUNT(DISTINCT hash)68		FROM save_files WHERE ${match_clause(only_save)}')!69	defer {70		stmt.finalize()71	}72	bind_match(stmt, path, only_save)!73	stmt.step()!74	return Scope{75		files:   int(stmt.int(0))76		saves:   int(stmt.int(1))77		objects: int(stmt.int(2))78	}79}8081// strip_local removes the path in one transaction and leaves the note the82// remote half needs.83pub fn strip_local(store &storage.Store, path string, only_save string) !LocalStrip {84	store.begin()!85	report := strip_rows(store, path, only_save) or {86		store.rollback()87		return err88	}89	store.end()!90	return report91}9293fn strip_rows(store &storage.Store, path string, only_save string) !LocalStrip {94	mut hashes := []string{}95	mut stmt :=96		store.db.prepare('SELECT DISTINCT hash FROM save_files WHERE ${match_clause(only_save)}')!97	bind_match(stmt, path, only_save)!98	for stmt.step()! {99		hashes << stmt.text(0)100	}101	stmt.finalize()102	if hashes.len == 0 {103		// Already stripped: keep the pending note an unfinished remote needs.104		return LocalStrip{}105	}106	removed := remove_path(store, path, only_save)!107	dropped := store.drop_unreferenced_objects()!108	events.strip(store, only_save, path)!109	// An earlier strip of the same path may not have finished on the remote,110	// so the note grows rather than resets.111	mut note := read_pending(store, path)112	if only_save !in note.saves {113		note.saves << only_save114	}115	note.hashes << hashes116	store.set_state(pending_key(path), json2.encode(note))!117	return LocalStrip{118		files_removed:   removed119		objects_dropped: dropped120	}121}122123// send_strip makes a strip visible on the remote: the event travels like any124// other, every remote save record listing the path is rewritten, and objects125// the strip freed are deleted once no remote record and no local save needs126// them. Every step is safe to repeat, and the pending note is cleared only127// when all of them have succeeded.128pub fn send_strip(store &storage.Store, mut backend remote.Backend, path string) !SendReport {129	name := repo_name(store, mut backend)!130	mut report := SendReport{}131	mut upload := OfferReport{132		repo: name133	}134	upload_events(store, mut backend, name, mut upload)!135	report.event_sent = upload.events > 0136	note := read_pending(store, path)137	mut freed := map[string]bool{}138	for hash in note.hashes {139		freed[hash] = true140	}141	every_save := '' in note.saves142	mut needed := map[string]bool{}143	for key in backend.list('${name}/saves')! {144		remote_object := backend.get(key)!145		if !remote_object.found {146			continue147		}148		record := decode_save(remote_object.body)!149		mut kept := []FileRecord{}150		for file in record.files {151			if (every_save || record.id in note.saves) && covers(path, file.path) {152				freed[file.hash] = true153				continue154			}155			kept << file156			needed[file.hash] = true157		}158		if kept.len == record.files.len {159			continue160		}161		if !backend.put_if_match(key, encode_save(rewrite(record, kept)).bytes(), remote_object.tag)! {162			return error('the remote copy of save ${record.id} moved while stripping — run koh steal, then strip again')163		}164		report.records++165	}166	for hash, _ in freed {167		if hash in needed || store.has_object(hash)! {168			continue169		}170		backend.delete(object_key(name, hash))!171		report.objects++172	}173	store.remove_state(pending_key(path))!174	return report175}176177// unapplied_strips are the strip events on the remote this repo has not178// received yet.179fn unapplied_strips(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {180	mut known := map[string]bool{}181	for event in events.all(store)! {182		known[event.id] = true183	}184	mut strips := []events.Event{}185	for key in backend.list(events_prefix(repo))! {186		if os.base(key) in known {187			continue188		}189		body := backend.get(key)!190		if !body.found {191			continue192		}193		event := decode_event(body.body)!194		if event.kind == events.kind_strip && !ignored_strip(event) {195			strips << event196		}197	}198	return strips199}200201// rewrite returns the save record with a new file list.202fn rewrite(record SaveRecord, files []FileRecord) SaveRecord {203	return SaveRecord{204		...record205		files: files206	}207}208209fn remove_path(store &storage.Store, path string, only_save string) !int {210	mut stmt := store.db.prepare('DELETE FROM save_files WHERE ${match_clause(only_save)}')!211	defer {212		stmt.finalize()213	}214	bind_match(stmt, path, only_save)!215	stmt.step()!216	return store.db.changes()217}218219// match_clause is `covers` in SQL. LIKE would ignore ASCII case and treat `_`220// and `%` as wildcards; a strip destroys, so the prefix is compared exactly.221fn match_clause(only_save string) string {222	scope := if only_save == '' { '' } else { ' AND save_id = ?3' }223	return '(path = ?1 OR substr(path, 1, length(?2)) = ?2)${scope}'224}225226fn bind_match(stmt &storage.Stmt, path string, only_save string) ! {227	stmt.bind_text(1, path)!228	stmt.bind_text(2, '${path.trim_right('/')}/')!229	if only_save != '' {230		stmt.bind_text(3, only_save)!231	}232}