okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
597cb84
main
1"""Deleting a session removes every row any table keeps for it. 2 3The delete route clears a list of tables the daemon names itself, so a table an 4extension adds is forgotten until someone remembers to add it. The check reads 5the schema, so it covers a table nobody has listed yet. 6""" 7 8import json 9import sqlite3 10import threading 11import unittest 12import urllib .parse 13import urllib .error 14 15from harness import Albedo ,Provider ,exclusive ,operation_id ,text 16 17# Paperclip provenance and retained input outcomes outlive the session. 18KEPT = {"paperclips" ,"input_turns" } 19 20 21def reply (request ): 22return text ("older conversation summary" ) 23 24 25class SessionDeleteTests (unittest .TestCase ): 26def setUp (self ): 27self .provider = Provider (reply ) 28self .addCleanup (self .provider .close ) 29self .app = Albedo (self .provider ,protocol = "responses" ) 30self .app .__enter__ () 31self .addCleanup (self .app .__exit__ ,None ,None ,None ) 32 33def database (self ): 34return sqlite3 .connect ( 35f"file: { self . app . home / 'albedo.sqlite' } ?mode=ro" ,uri = True 36 ) 37 38def keyed_tables (self ,db ): 39tables = [ 40row [0 ] 41for row in db .execute ("SELECT name FROM sqlite_master WHERE type='table'" ) 42 ] 43return [ 44table 45for table in tables 46if table not in KEPT 47and any ( 48column [1 ]== "session" 49for column in db .execute (f"PRAGMA table_info( { table } )" ) 50 ) 51 ] 52 53def rows (self ,db ,table ,session ): 54return db .execute ( 55f"SELECT count(*) FROM { table } WHERE session=?" , (session ,) 56 ).fetchone ()[0 ] 57 58def compacted_session (self ,strategy ): 59session = self .app .session () 60# Long enough that a forced compaction has something to evict. 61for prompt in ("first" ,"second" ,"third" ,"fourth" ): 62self .app .prompt (session ,prompt + " " + "x" * 6000 ).close () 63self .app .idle (session ) 64self .compact (session ,strategy ) 65return session 66 67def compact (self ,session ,strategy ): 68with self .app .api ( 69f"/sessions/ { session } /compaction" , 70 {"strategy" :strategy }, 71 )as response : 72self .assertEqual (json .load (response )["state" ],"compacted" ) 73self .app .idle (session ) 74 75def delete_session (self ,session ): 76resource = f"/sessions/ { session } ?view=configuration" 77with self .app .api (resource )as response : 78validator = response .getheader ("ETag" ) 79response .read () 80with self .app .api ( 81resource ,method = "DELETE" ,headers = {"If-Match" :validator } 82 )as response : 83self .assertEqual (json .load (response )["state" ],"complete" ) 84 85def assert_delete_clears (self ,strategy ,table ,then = None ): 86session = self .compacted_session (strategy ) 87with self .database ()as db : 88self .assertGreater (self .rows (db ,table ,session ),0 ,table ) 89self .assertIn (table ,self .keyed_tables (db )) 90if then : 91self .compact (session ,then ) 92with self .database ()as db : 93self .assertGreater (self .rows (db ,table ,session ),0 ,table ) 94self .delete_session (session ) 95with self .database ()as db : 96left = { 97name :self .rows (db ,name ,session )for name in self .keyed_tables (db ) 98 } 99self .assertEqual ({k :v for k ,v in left .items ()if v }, {}) 100orphans = db .execute ( 101"SELECT count(*) FROM lcm_compaction_edge WHERE child NOT IN " 102"(SELECT id FROM lcm_compaction_node) OR parent NOT IN " 103"(SELECT id FROM lcm_compaction_node)" 104 ).fetchone ()[0 ] 105self .assertEqual (orphans ,0 ) 106 107def test_leaf_deletion_refuses_busy_and_stale_observations (self ): 108entered ,release = threading .Event (),threading .Event () 109self .addCleanup (release .set ) 110 111def held (request ): 112entered .set () 113release .wait (30 ) 114return text ("completed before deletion" ) 115 116self .provider .script = held 117session = self .app .session () 118resource = f"/sessions/ { session } ?view=configuration" 119with self .app .api (resource )as response : 120revision = response .headers ["ETag" ] 121response .read () 122identity = operation_id () 123with self .app .api ( 124f"/sessions/ { session } /inputs/ { identity } " , 125 {"kind" :"message" ,"text" :"hold deletion" }, 126method = "PUT" , 127 )as response : 128response .read () 129self .assertTrue (entered .wait (30 )) 130with self .app .api (resource )as response : 131revision = response .headers ["ETag" ] 132response .read () 133with self .assertRaises (urllib .error .HTTPError )as caught : 134self .app .api (resource ,method = "DELETE" ,headers = {"If-Match" :revision }) 135self .assertEqual (caught .exception .code ,409 ) 136release .set () 137self .app .idle (session ) 138with self .app .api (resource )as response : 139revision = response .headers ["ETag" ] 140response .read () 141with self .app .api ( 142resource , 143 {"name" :"changed before deletion" }, 144method = "PATCH" , 145headers = {"If-Match" :revision }, 146 )as response : 147response .read () 148with self .assertRaises (urllib .error .HTTPError )as caught : 149self .app .api (resource ,method = "DELETE" ,headers = {"If-Match" :revision }) 150self .assertEqual (caught .exception .code ,412 ) 151with self .app .api (f"/sessions/ { session } " )as response : 152self .assertEqual (json .load (response )["name" ],"changed before deletion" ) 153self .delete_session (session ) 154with self .app .api (f"/sessions/ { session } /inputs/ { identity } " )as response : 155receipt = json .load (response ) 156self .assertEqual (receipt ["delivery" ],"committed" ) 157self .assertEqual (receipt ["turn" ]["state" ],"completed" ) 158with self .assertRaises (urllib .error .HTTPError )as caught : 159self .app .api (f"/sessions/ { session } " ) 160self .assertEqual (caught .exception .code ,410 ) 161self .assertEqual (json .load (caught .exception )["code" ],"session_deleted" ) 162 163def test_rolling_and_notes_rows_go_with_the_session (self ): 164self .assert_delete_clears ("rolling" ,"compaction_notes" ) 165 166def test_lcm_rows_go_with_the_session (self ): 167self .assert_delete_clears ("lcm" ,"lcm_compaction_node" ) 168 169def test_rows_of_a_strategy_switched_off_go_too (self ): 170# Switching to rolling disables lcm; its graph still belongs to the session. 171self .assert_delete_clears ("lcm" ,"lcm_compaction_node" ,then = "rolling" ) 172 173def test_work_and_schedule_rows_go_with_the_session (self ): 174session = self .app .session () 175with self .app .api ( 176"/extensions/schedule/jobs" , 177 { 178"session_id" :session , 179"kind" :"once" , 180"delay_seconds" :3600 , 181"prompt" :"build" , 182 }, 183 )as response : 184response .read () 185with self .app .api ( 186"/extensions/work/items?" 187+ urllib .parse .urlencode ({"workspace" :str (self .app .workspace )}), 188 {"title" :"assigned" ,"session_id" :session }, 189 )as response : 190response .read () 191with self .database ()as db : 192for table in ("work" ,"schedules" ): 193self .assertGreater (self .rows (db ,table ,session ),0 ,table ) 194self .delete_session (session ) 195with self .database ()as db : 196for table in ("work" ,"schedules" ): 197self .assertEqual (self .rows (db ,table ,session ),0 ,table ) 198 199def test_snapcompact_archive_goes_with_the_session (self ): 200self .assert_delete_clears ("snapcompact" ,"snapcompact_archive" ) 201 202@exclusive 203def test_snapcompact_selection_keeps_archive_across_switch_and_restart (self ): 204self .app .write_extensions ({"snapcompact" : {"enabled" :False }}) 205self .app .restart () 206session = self .compacted_session ("snapcompact" ) 207 208def selected (): 209with self .app .api (f"/sessions/ { session } ?tail=0" )as response : 210return json .load (response )["selection" ]["effective" ]["extensions" ] 211 212def archive (): 213with self .database ()as db : 214return db .execute ( 215"SELECT * FROM snapcompact_archive WHERE session=?" , (session ,) 216 ).fetchall () 217 218self .assertTrue (selected ()["snapcompact" ]) 219self .assertFalse (selected ()["rolling" ]) 220saved = archive () 221self .assertTrue (saved ) 222self .compact (session ,"rolling" ) 223self .assertTrue (selected ()["rolling" ]) 224self .assertFalse (selected ()["snapcompact" ]) 225self .assertEqual (archive (),saved ) 226self .app .restart () 227self .assertTrue (selected ()["rolling" ]) 228self .assertFalse (selected ()["snapcompact" ]) 229self .assertEqual (archive (),saved ) 230 231 232if __name__ == "__main__" : 233unittest .main ()