12345678module exchange910import json211import os12import src.events13import src.fmt14import src.remote15import src.storage161718pub struct Scope {19pub:20 files int21 saves int22 objects int23}2425pub struct LocalStrip {26pub:27 files_removed int28 objects_dropped i6429}303132pub struct SendReport {33pub mut:34 records int35 objects int36 event_sent bool37}3839404142pub struct Pending {43mut:44 saves []string45 hashes []string46}4748fn pending_key(path string) string {49 return 'strip_pending:${path}'50}515253545556pub fn read_pending(store &storage.Store, path string) !Pending {57 text := store.state(pending_key(path)) or { return Pending{} }58 note := json2.decode[Pending](text) or {59 return error('the unfinished strip note for ${path} is unreadable; it is kept for recovery')60 }61 if !note.hashes.all(storage.valid_hash(it)) {62 return error('the unfinished strip note for ${path} names something other than an object; it is kept for recovery')63 }64 recorded := events.strip_events(store)!.filter(events.valid_strip(it) && it.path == path)65 if note.saves.len == 0 || !note.saves.all(fn [recorded] (scope string) bool {66 return recorded.any(it.save_id == scope)67 }) {68 return error('no recorded strip matches the unfinished strip note for ${path}; it is kept for recovery')69 }70 return note71}727374pub fn strip_pending(store &storage.Store, path string) bool {75 return store.state(pending_key(path)) != none76}77787980fn covers(path string, file string) bool {81 return file == path || file.starts_with('${path.trim_right('/')}/')82}83848586pub fn strip_scope(store &storage.Store, path string, only_save string) !Scope {87 mut stmt := store.db.prepare('SELECT COUNT(*), COUNT(DISTINCT save_id), COUNT(DISTINCT hash)88 FROM save_files WHERE ${match_clause(only_save)}')!89 defer {90 stmt.finalize()91 }92 bind_match(stmt, path, only_save)!93 stmt.step()!94 return Scope{95 files: int(stmt.int(0))96 saves: int(stmt.int(1))97 objects: int(stmt.int(2))98 }99}100101102103pub fn strip_local(store &storage.Store, path string, only_save string) !LocalStrip {104 store.begin()!105 report := strip_rows(store, path, only_save) or {106 store.rollback()107 return err108 }109 store.end()!110 return report111}112113114pub fn strip_local_batch(store &storage.Store, paths []string, only_save string) ![]LocalStrip {115 store.begin()!116 mut reports := []LocalStrip{}117 for path in paths {118 reports << strip_rows(store, path, only_save) or {119 store.rollback()120 return err121 }122 }123 store.end() or {124 store.rollback()125 return err126 }127 return reports128}129130131132fn strip_rows(store &storage.Store, path string, only_save string) !LocalStrip {133 hashes := stripped_hashes(store, path, only_save)!134 if hashes.len == 0 {135 136 return LocalStrip{}137 }138 removed := remove_path(store, path, only_save)!139 dropped := store.drop_unused(hashes)!140 events.strip(store, only_save, path)!141 142 143 mut note := read_pending(store, path)!144 if only_save !in note.saves {145 note.saves << only_save146 }147 note.hashes << hashes148 store.set_state(pending_key(path), json2.encode(note))!149 return LocalStrip{150 files_removed: removed151 objects_dropped: dropped152 }153}154155156157158159160pub fn send_strip(store &storage.Store, mut backend remote.Backend, path string) !SendReport {161 mut report := SendReport{}162 send_steps(store, mut backend, path, mut report) or {163 mut done := []string{}164 if report.records + report.objects > 0 {165 done << 'rewrote ${fmt.plural(report.records, 'remote record')} and deleted ${fmt.plural(report.objects, 'remote object')} for ${path}'166 }167 return partway(done, err)168 }169 return report170}171172fn send_steps(store &storage.Store, mut backend remote.Backend, path string, mut report SendReport) ! {173 mut note := read_pending(store, path)!174 name := repo_name(store, mut backend)!175 mut upload := OfferReport{176 repo: name177 }178 upload_events(store, mut backend, name, mut upload)!179 report.event_sent = upload.events > 0180 mut freed := map[string]bool{}181 for hash in note.hashes {182 freed[hash] = true183 }184 every_save := '' in note.saves185 mut needed := map[string]bool{}186 for key in backend.list('${name}/saves')! {187 remote_object := backend.get(key)!188 if !remote_object.found {189 continue190 }191 record := decode_save(remote_object.body)!192 mut kept := []FileRecord{}193 mut dropped := []string{}194 for file in record.files {195 if (every_save || record.id in note.saves) && covers(path, file.path) {196 freed[file.hash] = true197 dropped << file.hash198 continue199 }200 kept << file201 needed[file.hash] = true202 }203 if kept.len == record.files.len {204 continue205 }206 207 208 unnoted := dropped.filter(it !in note.hashes)209 if unnoted.len > 0 {210 note.hashes << unnoted211 store.set_state(pending_key(path), json2.encode(note))!212 }213 if !backend.put_if_match(key, encode_save(rewrite(record, kept)).bytes(), remote_object.tag)! {214 return error('the remote copy of save ${record.id} moved while stripping — run koh steal, then strip again')215 }216 report.records++217 }218 for hash, _ in freed {219 if hash in needed || store.has_object(hash)! {220 continue221 }222 backend.delete(object_key(name, hash))!223 report.objects++224 }225 store.remove_state(pending_key(path))!226}227228229230pub fn requeue_strip(store &storage.Store, path string) ! {231 mut note := read_pending(store, path)!232 recorded := events.strip_events(store)!.filter(events.valid_strip(it) && it.path == path)233 if recorded.len == 0 {234 return error('this repo has no recorded strip of ${path}; run koh steal, then koh doctor')235 }236 for event in recorded {237 if event.save_id !in note.saves {238 note.saves << event.save_id239 }240 }241 store.set_state(pending_key(path), json2.encode(note))!242}243244245246247fn unapplied_strips(store &storage.Store, mut backend remote.Backend, repo string) ![]events.Event {248 mut known := map[string]bool{}249 for event in events.all(store)! {250 known[event.id] = true251 }252 mut strips := []events.Event{}253 for key in backend.list(events_prefix(repo))! {254 if os.base(key) in known {255 continue256 }257 body := backend.get(key)!258 if !body.found {259 continue260 }261 event := decode_event(body.body)!262 if events.valid_strip(event) && !ignored_strip(event) {263 strips << event264 }265 }266 return strips267}268269270fn rewrite(record SaveRecord, files []FileRecord) SaveRecord {271 return SaveRecord{272 ...record273 files: files274 }275}276277278fn stripped_hashes(store &storage.Store, path string, only_save string) ![]string {279 mut stmt :=280 store.db.prepare('SELECT DISTINCT hash FROM save_files WHERE ${match_clause(only_save)}')!281 defer {282 stmt.finalize()283 }284 bind_match(stmt, path, only_save)!285 mut hashes := []string{}286 for stmt.step()! {287 hashes << stmt.text(0)288 }289 return hashes290}291292293fn remove_path(store &storage.Store, path string, only_save string) !int {294 mut stmt := store.db.prepare('DELETE FROM save_files WHERE ${match_clause(only_save)}')!295 defer {296 stmt.finalize()297 }298 bind_match(stmt, path, only_save)!299 stmt.step()!300 return store.db.changes()301}302303304305fn match_clause(only_save string) string {306 scope := if only_save == '' { '' } else { ' AND save_id = ?3' }307 return '(path = ?1 OR substr(path, 1, length(?2)) = ?2)${scope}'308}309310311fn bind_match(stmt &storage.Stmt, path string, only_save string) ! {312 stmt.bind_text(1, path)!313 stmt.bind_text(2, '${path.trim_right('/')}/')!314 if only_save != '' {315 stmt.bind_text(3, only_save)!316 }317}