strip.v

7.4 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}5556// strip_pending is true while the remote half of a strip has not finished.57pub fn strip_pending(store &storage.Store, path string) bool {58	return store.state(pending_key(path)) != none59}6061// covers is the one rule for what a strip removes: the path itself, or62// anything beneath it when it names a folder.63fn covers(path string, file string) bool {64	return file == path || file.starts_with('${path.trim_right('/')}/')65}6667// strip_scope counts the rows strip_rows will delete, using the same match. `objects` is the distinct68// content they hold; content another path still uses is kept, so fewer may be deleted.69pub fn strip_scope(store &storage.Store, path string, only_save string) !Scope {70	mut stmt := store.db.prepare('SELECT COUNT(*), COUNT(DISTINCT save_id), COUNT(DISTINCT hash)71		FROM save_files WHERE ${match_clause(only_save)}')!72	defer {73		stmt.finalize()74	}75	bind_match(stmt, path, only_save)!76	stmt.step()!77	return Scope{78		files:   int(stmt.int(0))79		saves:   int(stmt.int(1))80		objects: int(stmt.int(2))81	}82}8384// strip_local removes the path in one transaction and leaves the note the85// remote half needs.86pub fn strip_local(store &storage.Store, path string, only_save string) !LocalStrip {87	store.begin()!88	report := strip_rows(store, path, only_save) or {89		store.rollback()90		return err91	}92	store.end()!93	return report94}9596// strip_rows is the body of strip_local's transaction. Its event has no save id for a whole-history97// strip (spec §10.2), and that empty id is what bans the path from every later walk.98fn strip_rows(store &storage.Store, path string, only_save string) !LocalStrip {99	mut hashes := []string{}100	mut stmt :=101		store.db.prepare('SELECT DISTINCT hash FROM save_files WHERE ${match_clause(only_save)}')!102	bind_match(stmt, path, only_save)!103	for stmt.step()! {104		hashes << stmt.text(0)105	}106	stmt.finalize()107	if hashes.len == 0 {108		// Already stripped: keep the pending note an unfinished remote needs.109		return LocalStrip{}110	}111	removed := remove_path(store, path, only_save)!112	dropped := store.drop_unreferenced_objects()!113	events.strip(store, only_save, path)!114	// An earlier strip of the same path may not have finished on the remote,115	// so the note grows rather than resets.116	mut note := read_pending(store, path)117	if only_save !in note.saves {118		note.saves << only_save119	}120	note.hashes << hashes121	store.set_state(pending_key(path), json2.encode(note))!122	return LocalStrip{123		files_removed:   removed124		objects_dropped: dropped125	}126}127128// send_strip makes a strip visible on the remote: the event travels like any129// other, every remote save record listing the path is rewritten, and objects130// the strip freed are deleted once no remote record and no local save needs131// them. Every step is safe to repeat, and the pending note is cleared only132// when all of them have succeeded.133pub fn send_strip(store &storage.Store, mut backend remote.Backend, path string) !SendReport {134	name := repo_name(store, mut backend)!135	mut report := SendReport{}136	mut upload := OfferReport{137		repo: name138	}139	upload_events(store, mut backend, name, mut upload)!140	report.event_sent = upload.events > 0141	note := read_pending(store, path)142	mut freed := map[string]bool{}143	for hash in note.hashes {144		freed[hash] = true145	}146	every_save := '' in note.saves147	mut needed := map[string]bool{}148	for key in backend.list('${name}/saves')! {149		remote_object := backend.get(key)!150		if !remote_object.found {151			continue152		}153		record := decode_save(remote_object.body)!154		mut kept := []FileRecord{}155		for file in record.files {156			if (every_save || record.id in note.saves) && covers(path, file.path) {157				freed[file.hash] = true158				continue159			}160			kept << file161			needed[file.hash] = true162		}163		if kept.len == record.files.len {164			continue165		}166		if !backend.put_if_match(key, encode_save(rewrite(record, kept)).bytes(), remote_object.tag)! {167			return error('the remote copy of save ${record.id} moved while stripping — run koh steal, then strip again')168		}169		report.records++170	}171	for hash, _ in freed {172		if hash in needed || store.has_object(hash)! {173			continue174		}175		backend.delete(object_key(name, hash))!176		report.objects++177	}178	store.remove_state(pending_key(path))!179	return report180}181182// unapplied_strips are the strip events on the remote this repo has not183// received yet. offer asks before uploading and again after, because a strip that lands184// in between would otherwise leave the stripped path in the bucket.185fn unapplied_strips(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {186	mut known := map[string]bool{}187	for event in events.all(store)! {188		known[event.id] = true189	}190	mut strips := []events.Event{}191	for key in backend.list(events_prefix(repo))! {192		if os.base(key) in known {193			continue194		}195		body := backend.get(key)!196		if !body.found {197			continue198		}199		event := decode_event(body.body)!200		if event.kind == events.kind_strip && !ignored_strip(event) {201			strips << event202		}203	}204	return strips205}206207// rewrite returns the save record with a new file list.208fn rewrite(record SaveRecord, files []FileRecord) SaveRecord {209	return SaveRecord{210		...record211		files: files212	}213}214215// remove_path deletes the rows and counts them; the objects go afterwards, once nothing references them.216fn remove_path(store &storage.Store, path string, only_save string) !int {217	mut stmt := store.db.prepare('DELETE FROM save_files WHERE ${match_clause(only_save)}')!218	defer {219		stmt.finalize()220	}221	bind_match(stmt, path, only_save)!222	stmt.step()!223	return store.db.changes()224}225226// match_clause is `covers` in SQL. LIKE would ignore ASCII case and treat `_`227// and `%` as wildcards; a strip destroys, so the prefix is compared exactly.228fn match_clause(only_save string) string {229	scope := if only_save == '' { '' } else { ' AND save_id = ?3' }230	return '(path = ?1 OR substr(path, 1, length(?2)) = ?2)${scope}'231}232233// bind_match fills what match_clause names: ?1 the path, ?2 its folder prefix, ?3 the save.234fn bind_match(stmt &storage.Stmt, path string, only_save string) ! {235	stmt.bind_text(1, path)!236	stmt.bind_text(2, '${path.trim_right('/')}/')!237	if only_save != '' {238		stmt.bind_text(3, only_save)!239	}240}