strip.v

8.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.41pub struct Pending {42mut:43	saves  []string44	hashes []string45}4647fn pending_key(path string) string {48	return 'strip_pending:${path}'49}5051// read_pending refuses a note it cannot parse, or one claiming a scope no52// recorded strip of the path has: sending it would lose the retry or remove53// the path where no strip asked. The note stays for recovery either way.54pub fn read_pending(store &storage.Store, path string) !Pending {55	text := store.state(pending_key(path)) or { return Pending{} }56	note := json2.decode[Pending](text) or {57		return error('the unfinished strip note for ${path} is unreadable; it is kept for recovery')58	}59	recorded := events.strip_events(store)!.filter(events.valid_strip(it) && it.path == path)60	if note.saves.len == 0 || !note.saves.all(fn [recorded] (scope string) bool {61		return recorded.any(it.save_id == scope)62	}) {63		return error('no recorded strip matches the unfinished strip note for ${path}; it is kept for recovery')64	}65	return note66}6768// strip_pending is true while the remote half of a strip has not finished.69pub fn strip_pending(store &storage.Store, path string) bool {70	return store.state(pending_key(path)) != none71}7273// covers is the one rule for what a strip removes: the path itself, or74// anything beneath it when it names a folder.75fn covers(path string, file string) bool {76	return file == path || file.starts_with('${path.trim_right('/')}/')77}7879// strip_scope counts the rows strip_rows will delete, using the same match. `objects` is the distinct80// content they hold; content another path still uses is kept, so fewer may be deleted.81pub fn strip_scope(store &storage.Store, path string, only_save string) !Scope {82	mut stmt := store.db.prepare('SELECT COUNT(*), COUNT(DISTINCT save_id), COUNT(DISTINCT hash)83		FROM save_files WHERE ${match_clause(only_save)}')!84	defer {85		stmt.finalize()86	}87	bind_match(stmt, path, only_save)!88	stmt.step()!89	return Scope{90		files:   int(stmt.int(0))91		saves:   int(stmt.int(1))92		objects: int(stmt.int(2))93	}94}9596// strip_local removes the path in one transaction and leaves the note the97// remote half needs.98pub fn strip_local(store &storage.Store, path string, only_save string) !LocalStrip {99	store.begin()!100	report := strip_rows(store, path, only_save) or {101		store.rollback()102		return err103	}104	store.end()!105	return report106}107108/// Strip the batch in one transaction; failures leave history unchanged.109pub fn strip_local_batch(store &storage.Store, paths []string, only_save string) ![]LocalStrip {110	store.begin()!111	mut reports := []LocalStrip{}112	for path in paths {113		reports << strip_rows(store, path, only_save) or {114			store.rollback()115			return err116		}117	}118	store.end() or {119		store.rollback()120		return err121	}122	return reports123}124125// strip_rows is the body of strip_local's transaction. Its event has no save id for a whole-history126// strip (spec §10.2), and that empty id is what bans the path from every later walk.127fn strip_rows(store &storage.Store, path string, only_save string) !LocalStrip {128	hashes := stripped_hashes(store, path, only_save)!129	if hashes.len == 0 {130		// Already stripped: keep the pending note an unfinished remote needs.131		return LocalStrip{}132	}133	removed := remove_path(store, path, only_save)!134	dropped := store.drop_unused(hashes)!135	events.strip(store, only_save, path)!136	// An earlier strip of the same path may not have finished on the remote,137	// so the note grows rather than resets.138	mut note := read_pending(store, path)!139	if only_save !in note.saves {140		note.saves << only_save141	}142	note.hashes << hashes143	store.set_state(pending_key(path), json2.encode(note))!144	return LocalStrip{145		files_removed:   removed146		objects_dropped: dropped147	}148}149150// send_strip makes a strip visible on the remote: the event travels like any151// other, every remote save record listing the path is rewritten, and objects152// the strip freed are deleted once no remote record and no local save needs153// them. Every step is safe to repeat, and the pending note is cleared only154// when all of them have succeeded.155pub fn send_strip(store &storage.Store, mut backend remote.Backend, path string) !SendReport {156	note := read_pending(store, path)!157	name := repo_name(store, mut backend)!158	mut report := SendReport{}159	mut upload := OfferReport{160		repo: name161	}162	upload_events(store, mut backend, name, mut upload)!163	report.event_sent = upload.events > 0164	mut freed := map[string]bool{}165	for hash in note.hashes {166		freed[hash] = true167	}168	every_save := '' in note.saves169	mut needed := map[string]bool{}170	for key in backend.list('${name}/saves')! {171		remote_object := backend.get(key)!172		if !remote_object.found {173			continue174		}175		record := decode_save(remote_object.body)!176		mut kept := []FileRecord{}177		for file in record.files {178			if (every_save || record.id in note.saves) && covers(path, file.path) {179				freed[file.hash] = true180				continue181			}182			kept << file183			needed[file.hash] = true184		}185		if kept.len == record.files.len {186			continue187		}188		if !backend.put_if_match(key, encode_save(rewrite(record, kept)).bytes(), remote_object.tag)! {189			return error('the remote copy of save ${record.id} moved while stripping — run koh steal, then strip again')190		}191		report.records++192	}193	for hash, _ in freed {194		if hash in needed || store.has_object(hash)! {195			continue196		}197		backend.delete(object_key(name, hash))!198		report.objects++199	}200	store.remove_state(pending_key(path))!201	return report202}203204// unapplied_strips are the strip events on the remote this repo has not205// received yet. offer asks before uploading and again after, because a strip that lands206// in between would otherwise leave the stripped path in the bucket.207fn unapplied_strips(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {208	mut known := map[string]bool{}209	for event in events.all(store)! {210		known[event.id] = true211	}212	mut strips := []events.Event{}213	for key in backend.list(events_prefix(repo))! {214		if os.base(key) in known {215			continue216		}217		body := backend.get(key)!218		if !body.found {219			continue220		}221		event := decode_event(body.body)!222		if event.kind == events.kind_strip && !ignored_strip(event) {223			strips << event224		}225	}226	return strips227}228229// rewrite returns the save record with a new file list.230fn rewrite(record SaveRecord, files []FileRecord) SaveRecord {231	return SaveRecord{232		...record233		files: files234	}235}236237// stripped_hashes is the content of the rows remove_path will delete.238fn stripped_hashes(store &storage.Store, path string, only_save string) ![]string {239	mut stmt :=240		store.db.prepare('SELECT DISTINCT hash FROM save_files WHERE ${match_clause(only_save)}')!241	defer {242		stmt.finalize()243	}244	bind_match(stmt, path, only_save)!245	mut hashes := []string{}246	for stmt.step()! {247		hashes << stmt.text(0)248	}249	return hashes250}251252// remove_path deletes the rows and counts them; the objects go afterwards, once nothing references them.253fn remove_path(store &storage.Store, path string, only_save string) !int {254	mut stmt := store.db.prepare('DELETE FROM save_files WHERE ${match_clause(only_save)}')!255	defer {256		stmt.finalize()257	}258	bind_match(stmt, path, only_save)!259	stmt.step()!260	return store.db.changes()261}262263// match_clause is `covers` in SQL. LIKE would ignore ASCII case and treat `_`264// and `%` as wildcards; a strip destroys, so the prefix is compared exactly.265fn match_clause(only_save string) string {266	scope := if only_save == '' { '' } else { ' AND save_id = ?3' }267	return '(path = ?1 OR substr(path, 1, length(?2)) = ?2)${scope}'268}269270// bind_match fills what match_clause names: ?1 the path, ?2 its folder prefix, ?3 the save.271fn bind_match(stmt &storage.Stmt, path string, only_save string) ! {272	stmt.bind_text(1, path)!273	stmt.bind_text(2, '${path.trim_right('/')}/')!274	if only_save != '' {275		stmt.bind_text(3, only_save)!276	}277}