strip.v

8.9 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, one naming something other52// than an object, or one claiming a scope no recorded strip of the path has:53// sending it would lose the retry, delete outside the repo's objects, or54// remove the path where no strip asked. The note stays for recovery.55pub fn read_pending(store &storage.Store, path string) !Pending {56	text := store.state(pending_key(path)) or { return Pending{} }57	note := json2.decode[Pending](text) or {58		return error('the unfinished strip note for ${path} is unreadable; it is kept for recovery')59	}60	if !note.hashes.all(storage.valid_hash(it)) {61		return error('the unfinished strip note for ${path} names something other than an object; it is kept for recovery')62	}63	recorded := events.strip_events(store)!.filter(events.valid_strip(it) && it.path == path)64	if note.saves.len == 0 || !note.saves.all(fn [recorded] (scope string) bool {65		return recorded.any(it.save_id == scope)66	}) {67		return error('no recorded strip matches the unfinished strip note for ${path}; it is kept for recovery')68	}69	return note70}7172// strip_pending is true while the remote half of a strip has not finished.73pub fn strip_pending(store &storage.Store, path string) bool {74	return store.state(pending_key(path)) != none75}7677// covers is the one rule for what a strip removes: the path itself, or78// anything beneath it when it names a folder.79fn covers(path string, file string) bool {80	return file == path || file.starts_with('${path.trim_right('/')}/')81}8283// strip_scope counts the rows strip_rows will delete, using the same match. `objects` is the distinct84// content they hold; content another path still uses is kept, so fewer may be deleted.85pub fn strip_scope(store &storage.Store, path string, only_save string) !Scope {86	mut stmt := store.db.prepare('SELECT COUNT(*), COUNT(DISTINCT save_id), COUNT(DISTINCT hash)87		FROM save_files WHERE ${match_clause(only_save)}')!88	defer {89		stmt.finalize()90	}91	bind_match(stmt, path, only_save)!92	stmt.step()!93	return Scope{94		files:   int(stmt.int(0))95		saves:   int(stmt.int(1))96		objects: int(stmt.int(2))97	}98}99100// strip_local removes the path in one transaction and leaves the note the101// remote half needs.102pub fn strip_local(store &storage.Store, path string, only_save string) !LocalStrip {103	store.begin()!104	report := strip_rows(store, path, only_save) or {105		store.rollback()106		return err107	}108	store.end()!109	return report110}111112/// Strip the batch in one transaction; failures leave history unchanged.113pub fn strip_local_batch(store &storage.Store, paths []string, only_save string) ![]LocalStrip {114	store.begin()!115	mut reports := []LocalStrip{}116	for path in paths {117		reports << strip_rows(store, path, only_save) or {118			store.rollback()119			return err120		}121	}122	store.end() or {123		store.rollback()124		return err125	}126	return reports127}128129// strip_rows is the body of strip_local's transaction. Its event has no save id for a whole-history130// strip (spec §10.2), and that empty id is what bans the path from every later walk.131fn strip_rows(store &storage.Store, path string, only_save string) !LocalStrip {132	hashes := stripped_hashes(store, path, only_save)!133	if hashes.len == 0 {134		// Already stripped: keep the pending note an unfinished remote needs.135		return LocalStrip{}136	}137	removed := remove_path(store, path, only_save)!138	dropped := store.drop_unused(hashes)!139	events.strip(store, only_save, path)!140	// An earlier strip of the same path may not have finished on the remote,141	// so the note grows rather than resets.142	mut note := read_pending(store, path)!143	if only_save !in note.saves {144		note.saves << only_save145	}146	note.hashes << hashes147	store.set_state(pending_key(path), json2.encode(note))!148	return LocalStrip{149		files_removed:   removed150		objects_dropped: dropped151	}152}153154// send_strip makes a strip visible on the remote: the event travels like any155// other, every remote save record listing the path is rewritten, and objects156// the strip freed are deleted once no remote record and no local save needs157// them. Every step is safe to repeat, and the pending note is cleared only158// when all of them have succeeded.159pub fn send_strip(store &storage.Store, mut backend remote.Backend, path string) !SendReport {160	note := read_pending(store, path)!161	name := repo_name(store, mut backend)!162	mut report := SendReport{}163	mut upload := OfferReport{164		repo: name165	}166	upload_events(store, mut backend, name, mut upload)!167	report.event_sent = upload.events > 0168	mut freed := map[string]bool{}169	for hash in note.hashes {170		freed[hash] = true171	}172	every_save := '' in note.saves173	mut needed := map[string]bool{}174	for key in backend.list('${name}/saves')! {175		remote_object := backend.get(key)!176		if !remote_object.found {177			continue178		}179		record := decode_save(remote_object.body)!180		mut kept := []FileRecord{}181		for file in record.files {182			if (every_save || record.id in note.saves) && covers(path, file.path) {183				freed[file.hash] = true184				continue185			}186			kept << file187			needed[file.hash] = true188		}189		if kept.len == record.files.len {190			continue191		}192		if !backend.put_if_match(key, encode_save(rewrite(record, kept)).bytes(), remote_object.tag)! {193			return error('the remote copy of save ${record.id} moved while stripping — run koh steal, then strip again')194		}195		report.records++196	}197	for hash, _ in freed {198		if hash in needed || store.has_object(hash)! {199			continue200		}201		backend.delete(object_key(name, hash))!202		report.objects++203	}204	store.remove_state(pending_key(path))!205	return report206}207208// unapplied_strips are the strip events on the remote this repo has not209// received yet. offer asks before uploading and again after, because a strip that lands210// in between would otherwise leave the stripped path in the bucket.211fn unapplied_strips(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {212	mut known := map[string]bool{}213	for event in events.all(store)! {214		known[event.id] = true215	}216	mut strips := []events.Event{}217	for key in backend.list(events_prefix(repo))! {218		if os.base(key) in known {219			continue220		}221		body := backend.get(key)!222		if !body.found {223			continue224		}225		event := decode_event(body.body)!226		if event.kind == events.kind_strip && !ignored_strip(event) {227			strips << event228		}229	}230	return strips231}232233// rewrite returns the save record with a new file list.234fn rewrite(record SaveRecord, files []FileRecord) SaveRecord {235	return SaveRecord{236		...record237		files: files238	}239}240241// stripped_hashes is the content of the rows remove_path will delete.242fn stripped_hashes(store &storage.Store, path string, only_save string) ![]string {243	mut stmt :=244		store.db.prepare('SELECT DISTINCT hash FROM save_files WHERE ${match_clause(only_save)}')!245	defer {246		stmt.finalize()247	}248	bind_match(stmt, path, only_save)!249	mut hashes := []string{}250	for stmt.step()! {251		hashes << stmt.text(0)252	}253	return hashes254}255256// remove_path deletes the rows and counts them; the objects go afterwards, once nothing references them.257fn remove_path(store &storage.Store, path string, only_save string) !int {258	mut stmt := store.db.prepare('DELETE FROM save_files WHERE ${match_clause(only_save)}')!259	defer {260		stmt.finalize()261	}262	bind_match(stmt, path, only_save)!263	stmt.step()!264	return store.db.changes()265}266267// match_clause is `covers` in SQL. LIKE would ignore ASCII case and treat `_`268// and `%` as wildcards; a strip destroys, so the prefix is compared exactly.269fn match_clause(only_save string) string {270	scope := if only_save == '' { '' } else { ' AND save_id = ?3' }271	return '(path = ?1 OR substr(path, 1, length(?2)) = ?2)${scope}'272}273274// bind_match fills what match_clause names: ?1 the path, ?2 its folder prefix, ?3 the save.275fn bind_match(stmt &storage.Stmt, path string, only_save string) ! {276	stmt.bind_text(1, path)!277	stmt.bind_text(2, '${path.trim_right('/')}/')!278	if only_save != '' {279		stmt.bind_text(3, only_save)!280	}281}