strip.v

7.3 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 what strip_rows will delete, using the same match, so the warning is exact.68pub fn strip_scope(store &storage.Store, path string, only_save string) !Scope {69	mut stmt := store.db.prepare('SELECT COUNT(*), COUNT(DISTINCT save_id), COUNT(DISTINCT hash)70		FROM save_files WHERE ${match_clause(only_save)}')!71	defer {72		stmt.finalize()73	}74	bind_match(stmt, path, only_save)!75	stmt.step()!76	return Scope{77		files:   int(stmt.int(0))78		saves:   int(stmt.int(1))79		objects: int(stmt.int(2))80	}81}8283// strip_local removes the path in one transaction and leaves the note the84// remote half needs.85pub fn strip_local(store &storage.Store, path string, only_save string) !LocalStrip {86	store.begin()!87	report := strip_rows(store, path, only_save) or {88		store.rollback()89		return err90	}91	store.end()!92	return report93}9495// strip_rows is the body of strip_local's transaction. Its event has no save id for a whole-history96// strip (spec §10.2), and that empty id is what bans the path from every later walk.97fn strip_rows(store &storage.Store, path string, only_save string) !LocalStrip {98	mut hashes := []string{}99	mut stmt :=100		store.db.prepare('SELECT DISTINCT hash FROM save_files WHERE ${match_clause(only_save)}')!101	bind_match(stmt, path, only_save)!102	for stmt.step()! {103		hashes << stmt.text(0)104	}105	stmt.finalize()106	if hashes.len == 0 {107		// Already stripped: keep the pending note an unfinished remote needs.108		return LocalStrip{}109	}110	removed := remove_path(store, path, only_save)!111	dropped := store.drop_unreferenced_objects()!112	events.strip(store, only_save, path)!113	// An earlier strip of the same path may not have finished on the remote,114	// so the note grows rather than resets.115	mut note := read_pending(store, path)116	if only_save !in note.saves {117		note.saves << only_save118	}119	note.hashes << hashes120	store.set_state(pending_key(path), json2.encode(note))!121	return LocalStrip{122		files_removed:   removed123		objects_dropped: dropped124	}125}126127// send_strip makes a strip visible on the remote: the event travels like any128// other, every remote save record listing the path is rewritten, and objects129// the strip freed are deleted once no remote record and no local save needs130// them. Every step is safe to repeat, and the pending note is cleared only131// when all of them have succeeded.132pub fn send_strip(store &storage.Store, mut backend remote.Backend, path string) !SendReport {133	name := repo_name(store, mut backend)!134	mut report := SendReport{}135	mut upload := OfferReport{136		repo: name137	}138	upload_events(store, mut backend, name, mut upload)!139	report.event_sent = upload.events > 0140	note := read_pending(store, path)141	mut freed := map[string]bool{}142	for hash in note.hashes {143		freed[hash] = true144	}145	every_save := '' in note.saves146	mut needed := map[string]bool{}147	for key in backend.list('${name}/saves')! {148		remote_object := backend.get(key)!149		if !remote_object.found {150			continue151		}152		record := decode_save(remote_object.body)!153		mut kept := []FileRecord{}154		for file in record.files {155			if (every_save || record.id in note.saves) && covers(path, file.path) {156				freed[file.hash] = true157				continue158			}159			kept << file160			needed[file.hash] = true161		}162		if kept.len == record.files.len {163			continue164		}165		if !backend.put_if_match(key, encode_save(rewrite(record, kept)).bytes(), remote_object.tag)! {166			return error('the remote copy of save ${record.id} moved while stripping — run koh steal, then strip again')167		}168		report.records++169	}170	for hash, _ in freed {171		if hash in needed || store.has_object(hash)! {172			continue173		}174		backend.delete(object_key(name, hash))!175		report.objects++176	}177	store.remove_state(pending_key(path))!178	return report179}180181// unapplied_strips are the strip events on the remote this repo has not182// received yet. offer asks before uploading and again after, because a strip that lands183// in between would otherwise leave the stripped path in the bucket.184fn unapplied_strips(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {185	mut known := map[string]bool{}186	for event in events.all(store)! {187		known[event.id] = true188	}189	mut strips := []events.Event{}190	for key in backend.list(events_prefix(repo))! {191		if os.base(key) in known {192			continue193		}194		body := backend.get(key)!195		if !body.found {196			continue197		}198		event := decode_event(body.body)!199		if event.kind == events.kind_strip && !ignored_strip(event) {200			strips << event201		}202	}203	return strips204}205206// rewrite returns the save record with a new file list.207fn rewrite(record SaveRecord, files []FileRecord) SaveRecord {208	return SaveRecord{209		...record210		files: files211	}212}213214// remove_path deletes the rows and counts them; the objects go afterwards, once nothing references them.215fn remove_path(store &storage.Store, path string, only_save string) !int {216	mut stmt := store.db.prepare('DELETE FROM save_files WHERE ${match_clause(only_save)}')!217	defer {218		stmt.finalize()219	}220	bind_match(stmt, path, only_save)!221	stmt.step()!222	return store.db.changes()223}224225// match_clause is `covers` in SQL. LIKE would ignore ASCII case and treat `_`226// and `%` as wildcards; a strip destroys, so the prefix is compared exactly.227fn match_clause(only_save string) string {228	scope := if only_save == '' { '' } else { ' AND save_id = ?3' }229	return '(path = ?1 OR substr(path, 1, length(?2)) = ?2)${scope}'230}231232// bind_match fills what match_clause names: ?1 the path, ?2 its folder prefix, ?3 the save.233fn bind_match(stmt &storage.Stmt, path string, only_save string) ! {234	stmt.bind_text(1, path)!235	stmt.bind_text(2, '${path.trim_right('/')}/')!236	if only_save != '' {237		stmt.bind_text(3, only_save)!238	}239}