Forgetting is a real delete. A memory with a recognisable string in its words and its older words, a mention, an embedding and a picture is forgotten, and then the bytes of the database file and of its write-ahead log are searched for every one of them. Done for a device's store and for the service's.
Journal mode: the database is in write-ahead mode, so a deleted row's old bytes can sit in the -wal file
until it is checkpointed. Every forgetting is followed by wal_checkpoint(TRUNCATE) (Store::scrub), which
copies the log into the file, where secure_delete has already zeroed what was deleted, and truncates the log
to nothing. The test checks both files, and that the log really is empty.
10use std::path::{Path, PathBuf};
12use whiskers_conformance::suite::build; 13use whiskers_core::{HouseholdStore, Log, Memory, NewFact, PictureId, Pictures}; 14use whiskers_ports::{Appended, DeviceName, EntryLine, Hub, Journal, PictureShelf, Put, SafePictureId, run_ready}; 15use whiskers_store::{SqliteChat, SqliteHousehold, SqliteJournal, SqliteLog, SqliteMemory, SqliteMemoryHub, SqlitePictureShelf, SqlitePictures, Store}; 16 17const WORDS: &str = "ZEBRAS-4471-QX"; 18const OLD_WORDS: &str = "OLDWORDS-5521-QX";
What a model said a picture showed, which is where a memory made from it first got its words.
An embedding that cannot be mistaken for anything else in a file.
23const VECTOR: [f32; 4] = [0.123_456_79, -7.654_321, 3.1e7, 9.87e-5];
25fn dir(name: &str) -> PathBuf { 26 use std::sync::atomic::{AtomicUsize, Ordering}; 27 static N: AtomicUsize = AtomicUsize::new(0); 28 let d = std::env::temp_dir().join(format!("whiskers-store-forget-{name}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::SeqCst))); 29 let _ = std::fs::remove_dir_all(&d); 30 std::fs::create_dir_all(&d).unwrap(); 31 d 32} 33 34fn vector_bytes() -> Vec<u8> { 35 VECTOR.iter().flat_map(|x| x.to_le_bytes()).collect() 36}
What is on disk for the database at db: its file and its write-ahead log, if there is one.
The parents' log lines that carry a memory's words, as the engine writes them as it goes: it was
remembered (in its first words), she put it away and a parent restored it, it was called to mind, and
the picture it was made from was described. (A Heard line that showed the picture would keep the picture;
that is the next test's.)
54fn lines_with_the_words(gid: &str, picture: &PictureId) -> Vec<Entry> { 55 vec![ 56 Entry::new(11, Event::PictureSeen { description: DESCRIPTION.into(), pictures: vec![picture.clone()] }), 57 Entry::new(12, Event::Remembered { fact: format!("{OLD_WORDS} is her toy"), gid: gid.into() }), 58 Entry::new(13, Event::Recalled { facts: vec!["she likes tea".into(), format!("{WORDS} is her big toy")], gids: vec!["other".into(), gid.into()] }), 59 Entry::new(14, Event::PutAway { fact: format!("{WORDS} is her big toy"), gid: gid.into() }), 60 Entry::new(15, Event::Restored { fact: format!("{WORDS} is her big toy"), gid: gid.into() }), 61 Entry::new(16, Event::Remembered { fact: "she likes tea".into(), gid: "other".into() }), 62 ] 63}
What the log of device holds, as the entries it was written as.
Every needle that a forgotten memory must take with it.
71fn needles() -> Vec<(&'static str, Vec<u8>)> { 72 vec![ 73 ("its words", WORDS.as_bytes().to_vec()), 74 ("its older words", OLD_WORDS.as_bytes().to_vec()), 75 ("its picture", PICTURE.to_vec()), 76 ("its embedding", vector_bytes()), 77 ("the description of its picture", DESCRIPTION.as_bytes().to_vec()), 78 ] 79}
81#[test] 82fn a_forgotten_memory_is_in_neither_the_device_database_nor_its_log() { 83 let d = dir("device"); 84 let db = d.join("device.db"); 85 let store = Store::open(&db).unwrap(); 86 let mut memory = SqliteMemory::open(store.clone(), "phone").unwrap(); 87 let mut pictures = SqlitePictures::new(store.clone()); 88 let kept_picture = pictures.save(&whiskers_core::Image { media_type: "image/jpeg".into(), bytes: b"ANOTHER-PICTURE-1234".to_vec() }).unwrap(); 89 let id = PictureId("0000000000000007-0000.jpg".into()); 90 pictures.store(&id, PICTURE).unwrap(); 91 92 memory.add(NewFact { text: "she likes tea".into(), pictures: vec![kept_picture.clone()], ..NewFact::default() }, 1).unwrap(); 93 let f = memory.add(NewFact { text: format!("{OLD_WORDS} is her toy"), pictures: vec![id.clone()], ..NewFact::default() }, 2).unwrap(); 94 memory.mention(f.id, 50, "").unwrap(); 95 memory.reword(f.id, format!("{WORDS} is her big toy"), 100).unwrap(); 96 memory.set_embedding(f.id, VECTOR.to_vec()).unwrap(); 97 let before = memory.facts().into_iter().find(|x| x.id == f.id).unwrap(); 98 assert_eq!((before.mentions.len(), before.revisions.len(), before.embedding.len(), before.pictures.len()), (2, 1, 4, 1)); 99 100 // The log holds the words in its own lines, on this device's own and in the copy it keeps of the tablet's. 101 let mut log = SqliteLog::new(store.clone(), "phone"); 102 let lines = lines_with_the_words(&before.gid, &id); 103 for l in &lines { 104 log.append(l).unwrap(); 105 } 106 let from_tablet = lines_with_the_words(&before.gid, &id); 107 let mut taken: Vec<String> = from_tablet.iter().map(|e| format!(r#"{{"device":"tablet","entry":{}}}"#, serde_json::to_string(e).unwrap())).collect(); 108 // A line from before the lines carried an identity: found by its exact words. And a `Forgot` as it used to be written. 109 taken.push(format!(r#"{{"device":"tablet","entry":{{"at_ms":20,"event":{{"Remembered":{{"fact":"{WORDS} is her big toy"}}}}}}}}"#)); 110 taken.push(format!(r#"{{"device":"tablet","entry":{{"at_ms":21,"event":{{"Forgot":{{"fact":"{OLD_WORDS} is her toy"}}}}}}}}"#)); 111 assert_eq!(log.take(0, &taken).unwrap(), taken.len()); 112 113 // The instrument can see them: before the forgetting every one is in the file or its log. 114 let (file, wal) = on_disk(&db); 115 for (what, n) in needles() { 116 assert!(contains(&file, &n) || contains(&wal, &n), "{what} should be on disk before it is forgotten"); 117 } 118 119 memory.forget(f.id, 999).unwrap(); 120 121 let (file, wal) = on_disk(&db); 122 for (what, n) in needles() { 123 assert!(!contains(&file, &n), "{what} is still in the database file"); 124 assert!(!contains(&wal, &n), "{what} is still in the write-ahead log"); 125 } 126 assert!(wal.is_empty(), "the write-ahead log was truncated, not merely scanned: {} bytes", wal.len()); 127 // A line that arrives later, from a device that had not heard of the forgetting, is emptied as it is written 128 // (so the words were never in a committed page, which is what the search below checks). 129 for l in lines_with_the_words(&before.gid, &id) { 130 SqliteLog::new(store.clone(), "laptop").append(&l).unwrap(); 131 } 132 let (file, wal) = on_disk(&db); 133 for (what, n) in needles() { 134 assert!(!contains(&file, &n) && !contains(&wal, &n), "{what} came back with a late line"); 135 } 136 // The log lines are still there, with their times, and say nothing of it: the words are empty. A line about 137 // another memory is untouched. 138 for device in ["phone", "tablet", "laptop"] { 139 let kept = entries(&store, device); 140 assert!(kept.len() >= 6, "{device}: no line was removed"); 141 for e in &kept { 142 match &e.event { 143 Event::Remembered { fact, gid } if gid != "other" => assert_eq!(fact, ""), 144 Event::PutAway { fact, .. } | Event::Restored { fact, .. } => assert_eq!(fact, ""), 145 Event::Recalled { facts, .. } => assert_eq!(facts, &vec!["she likes tea".to_string(), String::new()]), 146 Event::PictureSeen { description, .. } => assert_eq!(description, ""), 147 Event::Remembered { fact, .. } => assert_eq!(fact, "she likes tea"), 148 _ => {} 149 } 150 } 151 let times: Vec<u64> = kept.iter().map(|e| e.at_ms).collect(); 152 assert!(times.contains(&12) && times.contains(&16), "{device}: a line kept its time ({times:?})"); 153 } 154 assert!(entries(&store, "tablet").iter().any(|e| matches!(&e.event, Event::Forgot { gid } if gid.is_empty())), "a Forgot of the old kind has no words left"); 155 // What remains of it is its identity, when and by whom. 156 let conn = rusqlite::Connection::open(&db).unwrap(); 157 let count = |sql: &str| conn.query_row(sql, [], |r| r.get::<_, i64>(0)).unwrap(); 158 assert_eq!(count("SELECT COUNT(*) FROM tombstone"), 1); 159 assert_eq!(count(&format!("SELECT COUNT(*) FROM tombstone WHERE gid = '{}' AND at_ms = 999 AND device = 'phone'", before.gid)), 1); 160 for table in ["fact_mention", "fact_revision", "fact_embedding", "fact_picture"] { 161 assert_eq!(count(&format!("SELECT COUNT(*) FROM {table} WHERE gid = '{}'", before.gid)), 0, "{table} still has rows of the forgotten memory"); 162 } 163 assert_eq!(count("SELECT COUNT(*) FROM fact"), 1, "the other memory is untouched"); 164 assert_eq!(count("SELECT COUNT(*) FROM picture"), 2 - 1, "its picture is gone and the other memory's stays"); 165 assert!(memory.facts()[0].pictures == vec![kept_picture]); 166 // And it is gone after a restart too. 167 drop((memory, pictures, store)); 168 let again = SqliteMemory::open(Store::open(&db).unwrap(), "phone").unwrap(); 169 assert_eq!(again.facts().len(), 1); 170 assert_eq!(again.snapshot().forgotten, vec![before.gid]); 171} 172 173#[test] 174fn a_forgotten_memory_is_in_neither_the_service_database_nor_its_log_and_stays_forgotten() { 175 let d = dir("service"); 176 let db = d.join("service.db"); 177 let store = Store::open(&db).unwrap(); 178 let hub = SqliteMemoryHub::open(store.clone()).unwrap(); 179 let shelf = SqlitePictureShelf::new(store.clone()); 180 let picture = SafePictureId::parse("0000000000000007-0000.jpg").unwrap(); 181 assert_eq!(run_ready(shelf.put(&picture, PICTURE)).unwrap(), Put::Stored); 182 183 // A device learns it and tells the service. 184 let phone_dir = dir("service-phone"); 185 let mut phone = SqliteMemory::open(Store::open(&phone_dir.join("phone.db")).unwrap(), "phone").unwrap(); 186 let f = phone.add(NewFact { text: format!("{OLD_WORDS} is her toy"), pictures: vec![PictureId(picture.as_str().into())], ..NewFact::default() }, 2).unwrap(); 187 phone.mention(f.id, 50, "").unwrap(); 188 phone.reword(f.id, format!("{WORDS} is her big toy"), 100).unwrap(); 189 phone.set_embedding(f.id, VECTOR.to_vec()).unwrap(); 190 let stale = phone.snapshot(); 191 run_ready(hub.merge(stale.clone())).unwrap(); 192 // The phone's log lines are on the service too, as every device's are. 193 let journal = SqliteJournal::new(store.clone()); 194 let device = DeviceName::new("phone").unwrap(); 195 let lines: Vec<EntryLine> = lines_with_the_words(&f.gid, &PictureId(picture.as_str().into())).iter().map(|e| EntryLine::parse(&serde_json::to_string(e).unwrap()).unwrap()).collect(); 196 let held = run_ready(journal.held(&device)).unwrap(); 197 assert!(matches!(run_ready(journal.append(&device, held, &lines)).unwrap(), Appended::Accepted { .. })); 198 let (file, wal) = on_disk(&db); 199 for (what, n) in needles() { 200 assert!(contains(&file, &n) || contains(&wal, &n), "{what} should be on the service before it is forgotten"); 201 } 202 203 // The parents forget it on the phone; the next sync carries the tombstone. 204 phone.forget(f.id, 999).unwrap(); 205 let merged = run_ready(hub.merge(phone.snapshot())).unwrap(); 206 assert!(merged.document.facts.is_empty()); 207 208 let (file, wal) = on_disk(&db); 209 for (what, n) in needles() { 210 assert!(!contains(&file, &n), "{what} is still in the service's database file"); 211 assert!(!contains(&wal, &n), "{what} is still in the service's write-ahead log"); 212 } 213 assert!(wal.is_empty(), "the service's log was truncated: {} bytes", wal.len()); 214 assert_eq!(run_ready(shelf.get(&picture)).unwrap(), None, "the picture nothing else uses went with it"); 215 let page = run_ready(journal.pull(whiskers_ports::LogCursor::new(0))).unwrap(); 216 assert_eq!(page.lines.len(), 6, "every line is still there"); 217 // A device that had not heard yet pushes its lines afterwards: emptied as they arrive. 218 let late = DeviceName::new("laptop").unwrap(); 219 let held = run_ready(journal.held(&late)).unwrap(); 220 run_ready(journal.append(&late, held, &lines)).unwrap(); 221 let (file, wal) = on_disk(&db); 222 for (what, n) in needles() { 223 assert!(!contains(&file, &n) && !contains(&wal, &n), "{what} came back with a late line on the service"); 224 } 225 226 // The tombstone wins over any later state from another device: a stale copy that still has it brings nothing back. 227 let again = run_ready(hub.merge(stale)).unwrap(); 228 assert!(again.document.facts.is_empty() && again.changed == 0); 229 assert_eq!(again.document.forgotten, vec![f.gid.clone()]); 230 assert_eq!(again.document.forgotten_when.len(), 1, "who and when travel with the identity"); 231 // The schema itself refuses to hold it again, whatever code asks. 232 let conn = rusqlite::Connection::open(&db).unwrap(); 233 let err = conn.execute("INSERT INTO fact (gid, text, learned_at_ms, kind) VALUES (?1, 'x', 1, 'other')", [&f.gid]).unwrap_err(); 234 assert!(err.to_string().contains("forgotten"), "{err}"); 235} 236 237#[test] 238fn a_picture_that_another_memory_or_a_logged_conversation_still_shows_is_kept() { 239 let d = dir("shared-picture"); 240 let db = d.join("d.db"); 241 let store = Store::open(&db).unwrap(); 242 let mut memory = SqliteMemory::open(store.clone(), "phone").unwrap(); 243 let pictures = SqlitePictures::new(store.clone()); 244 let (shared, shown) = (PictureId("0000000000000001-0000.jpg".into()), PictureId("0000000000000002-0000.jpg".into())); 245 pictures.store(&shared, b"SHARED-PICTURE").unwrap(); 246 pictures.store(&shown, b"SHOWN-IN-CONVERSATION").unwrap(); 247 let a = memory.add(NewFact { text: "one".into(), pictures: vec![shared.clone(), shown.clone()], ..NewFact::default() }, 1).unwrap(); 248 memory.add(NewFact { text: "two".into(), pictures: vec![shared.clone()], ..NewFact::default() }, 2).unwrap(); 249 let mut log = SqliteLog::new(store.clone(), "phone"); 250 log.append(&whiskers_core::Entry::new(3, whiskers_core::Event::Heard { text: "look".into(), pictures: vec![shown.clone()] })).unwrap(); 251 memory.forget(a.id, 10).unwrap(); 252 assert!(pictures.has(&shared), "another memory still has it"); 253 assert!(pictures.has(&shown), "a logged conversation still shows it"); 254} 255 256#[test] 257fn hiding_by_the_child_deletes_nothing() { 258 let d = dir("hide"); 259 let db = d.join("d.db"); 260 let store = Store::open(&db).unwrap(); 261 let mut memory = SqliteMemory::open(store.clone(), "phone").unwrap(); 262 let f = memory.add(NewFact { text: format!("{WORDS} is her toy"), ..NewFact::default() }, 1).unwrap(); 263 memory.set_embedding(f.id, VECTOR.to_vec()).unwrap(); 264 memory.hide(f.id, 5).unwrap(); 265 store.scrub().unwrap(); 266 let (file, _) = on_disk(&db); 267 assert!(contains(&file, WORDS.as_bytes()) && contains(&file, &vector_bytes()), "hiding is a soft delete: everything is kept"); 268 assert!(memory.facts()[0].visibility.is_hidden()); 269} 270 271#[test] 272fn the_store_leaves_emptying_the_chat_to_the_documents_rule() { 273 // The chat is a document that holders keep in memory and merge (`ChatState::scrub` and `merge`), so a trigger that 274 // emptied its rows would be overwritten by the copy in memory. Forgetting a memory at the store does not touch it; 275 // the engine empties the chat as part of forgetting (see the engine's tests). 276 let d = dir("others"); 277 let store = Store::open(&d.join("d.db")).unwrap(); 278 let mut chat = SqliteChat::new(store.clone()); 279 let mut h = SqliteHousehold::new(store.clone()); 280 whiskers_core::ChatStore::save(&mut chat, &build::chat(3, 7, "a summary")).unwrap(); 281 h.save(&whiskers_core::Household::default()).unwrap(); 282 let mut memory = SqliteMemory::open(store.clone(), "phone").unwrap(); 283 let f = memory.add(NewFact { text: "x".into(), ..NewFact::default() }, 1).unwrap(); 284 memory.forget(f.id, 2).unwrap(); 285 assert_eq!(whiskers_core::ChatStore::load(&chat).unwrap().summary, "a summary"); 286}