strip.v

7.8 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 the batch in one transaction; failures leave history unchanged.97pub fn strip_local_batch(store &storage.Store, paths []string, only_save string) ![]LocalStrip {98	store.begin()!99	mut reports := []LocalStrip{}100	for path in paths {101		reports << strip_rows(store, path, only_save) or {102			store.rollback()103			return err104		}105	}106	store.end() or {107		store.rollback()108		return err109	}110	return reports111}112113// strip_rows is the body of strip_local's transaction. Its event has no save id for a whole-history114// strip (spec §10.2), and that empty id is what bans the path from every later walk.115fn strip_rows(store &storage.Store, path string, only_save string) !LocalStrip {116	mut hashes := []string{}117	mut stmt :=118		store.db.prepare('SELECT DISTINCT hash FROM save_files WHERE ${match_clause(only_save)}')!119	bind_match(stmt, path, only_save)!120	for stmt.step()! {121		hashes << stmt.text(0)122	}123	stmt.finalize()124	if hashes.len == 0 {125		// Already stripped: keep the pending note an unfinished remote needs.126		return LocalStrip{}127	}128	removed := remove_path(store, path, only_save)!129	dropped := store.drop_unreferenced_objects()!130	events.strip(store, only_save, path)!131	// An earlier strip of the same path may not have finished on the remote,132	// so the note grows rather than resets.133	mut note := read_pending(store, path)134	if only_save !in note.saves {135		note.saves << only_save136	}137	note.hashes << hashes138	store.set_state(pending_key(path), json2.encode(note))!139	return LocalStrip{140		files_removed:   removed141		objects_dropped: dropped142	}143}144145// send_strip makes a strip visible on the remote: the event travels like any146// other, every remote save record listing the path is rewritten, and objects147// the strip freed are deleted once no remote record and no local save needs148// them. Every step is safe to repeat, and the pending note is cleared only149// when all of them have succeeded.150pub fn send_strip(store &storage.Store, mut backend remote.Backend, path string) !SendReport {151	name := repo_name(store, mut backend)!152	mut report := SendReport{}153	mut upload := OfferReport{154		repo: name155	}156	upload_events(store, mut backend, name, mut upload)!157	report.event_sent = upload.events > 0158	note := read_pending(store, path)159	mut freed := map[string]bool{}160	for hash in note.hashes {161		freed[hash] = true162	}163	every_save := '' in note.saves164	mut needed := map[string]bool{}165	for key in backend.list('${name}/saves')! {166		remote_object := backend.get(key)!167		if !remote_object.found {168			continue169		}170		record := decode_save(remote_object.body)!171		mut kept := []FileRecord{}172		for file in record.files {173			if (every_save || record.id in note.saves) && covers(path, file.path) {174				freed[file.hash] = true175				continue176			}177			kept << file178			needed[file.hash] = true179		}180		if kept.len == record.files.len {181			continue182		}183		if !backend.put_if_match(key, encode_save(rewrite(record, kept)).bytes(), remote_object.tag)! {184			return error('the remote copy of save ${record.id} moved while stripping — run koh steal, then strip again')185		}186		report.records++187	}188	for hash, _ in freed {189		if hash in needed || store.has_object(hash)! {190			continue191		}192		backend.delete(object_key(name, hash))!193		report.objects++194	}195	store.remove_state(pending_key(path))!196	return report197}198199// unapplied_strips are the strip events on the remote this repo has not200// received yet. offer asks before uploading and again after, because a strip that lands201// in between would otherwise leave the stripped path in the bucket.202fn unapplied_strips(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {203	mut known := map[string]bool{}204	for event in events.all(store)! {205		known[event.id] = true206	}207	mut strips := []events.Event{}208	for key in backend.list(events_prefix(repo))! {209		if os.base(key) in known {210			continue211		}212		body := backend.get(key)!213		if !body.found {214			continue215		}216		event := decode_event(body.body)!217		if event.kind == events.kind_strip && !ignored_strip(event) {218			strips << event219		}220	}221	return strips222}223224// rewrite returns the save record with a new file list.225fn rewrite(record SaveRecord, files []FileRecord) SaveRecord {226	return SaveRecord{227		...record228		files: files229	}230}231232// remove_path deletes the rows and counts them; the objects go afterwards, once nothing references them.233fn remove_path(store &storage.Store, path string, only_save string) !int {234	mut stmt := store.db.prepare('DELETE FROM save_files WHERE ${match_clause(only_save)}')!235	defer {236		stmt.finalize()237	}238	bind_match(stmt, path, only_save)!239	stmt.step()!240	return store.db.changes()241}242243// match_clause is `covers` in SQL. LIKE would ignore ASCII case and treat `_`244// and `%` as wildcards; a strip destroys, so the prefix is compared exactly.245fn match_clause(only_save string) string {246	scope := if only_save == '' { '' } else { ' AND save_id = ?3' }247	return '(path = ?1 OR substr(path, 1, length(?2)) = ?2)${scope}'248}249250// bind_match fills what match_clause names: ?1 the path, ?2 its folder prefix, ?3 the save.251fn bind_match(stmt &storage.Stmt, path string, only_save string) ! {252	stmt.bind_text(1, path)!253	stmt.bind_text(2, '${path.trim_right('/')}/')!254	if only_save != '' {255		stmt.bind_text(3, only_save)!256	}257}