okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
61c43ca
main
1"""Retention preserves pending and unfinished turns, and deleted identities cannot revive work.""" 2 3import json 4import sqlite3 5import threading 6import time 7import urllib .error 8 9from harness import Albedo ,Provider ,exclusive ,operation_id ,text 10from operations_test import InputScenario 11 12RETENTION_MS = 7 * 24 * 60 * 60 * 1000 13 14 15@exclusive 16class OperationRetentionTests (InputScenario ): 17def setUp (self ): 18self .provider = Provider (lambda _ :text ("done" )) 19self .addCleanup (self .provider .close ) 20self .app = Albedo ( 21self .provider , 22protocol = "responses" , 23prepare = lambda app :app .env .update (ALBEDO_SCHEDULE_TICK_MS = "50" ), 24 ) 25self .app .__enter__ () 26self .addCleanup (self .app .__exit__ ,None ,None ,None ) 27 28def database (self ): 29return sqlite3 .connect (self .app .home / "albedo.sqlite" ,timeout = 10 ) 30 31def wait_expiry (self ,session ,input_id ,status ): 32deadline = time .monotonic ()+ 10 33while time .monotonic ()< deadline : 34try : 35self .receipt (session ,input_id ) 36except urllib .error .HTTPError as failure : 37self .assertEqual (failure .code ,status ) 38return 39time .sleep (0.05 ) 40self .fail ("terminal receipt did not expire" ) 41 42def delete (self ,session ): 43with self .app .api (f"/sessions/ { session } ?view=configuration" )as response : 44validator = response .getheader ("ETag" ) 45response .read () 46with self .app .api ( 47f"/sessions/ { session } ?view=configuration" , 48method = "DELETE" , 49headers = {"If-Match" :validator }, 50 )as response : 51outcome = json .load (response ) 52self .assertEqual (outcome ["state" ],"complete" ,outcome ) 53 54def test_terminal_receipt_expiry_prevents_reexecution (self ): 55session = self .app .session () 56input_id = operation_id () 57intent = {"kind" :"message" ,"text" :"terminal input" } 58self .put_input (session ,input_id ,intent ) 59self .committed (session ,input_id ) 60self .app .idle (session ) 61old_time = int (time .time ()* 1000 )- RETENTION_MS - 10000 62aged_id = operation_id (old_time ) 63with self .database ()as database : 64database .execute ( 65"UPDATE operations SET id=?,created_at=?,terminal_at=? WHERE id=?" , 66 (aged_id ,old_time ,old_time ,input_id ), 67 ) 68self .wait_expiry (session ,aged_id ,410 ) 69with self .assertRaises (urllib .error .HTTPError )as failure : 70self .put_input (session ,aged_id ,intent ) 71self .assertEqual (failure .exception .code ,410 ) 72self .assertEqual (json .load (failure .exception )["code" ],"identity_expired" ) 73self .assertEqual (len (self .users (session )),1 ) 74self .assertEqual (len (self .provider .requests ),1 ) 75with self .app .api (f"/sessions/ { session } " )as response : 76self .assertIsNotNone (json .load (response )["creation" ]) 77 78def test_aged_pending_receipt_remains_recoverable_after_restart (self ): 79session = self .app .session () 80input_id = operation_id () 81intent = {"kind" :"message" ,"text" :"old pending input" } 82with self .database ()as database : 83database .execute ( 84"CREATE TRIGGER hold_pending BEFORE INSERT ON transcript WHEN NEW.row_class='user' BEGIN SELECT RAISE(ABORT,'fixture'); END" 85 ) 86accepted = self .put_input (session ,input_id ,intent ) 87self .assertEqual (self .receipt (session ,input_id )["delivery" ],"pending" ) 88old_time = int (time .time ()* 1000 )- RETENTION_MS - 10000 89aged_id = operation_id (old_time ) 90 91def age_pending (_ ): 92with self .database ()as database : 93payload = database .execute ( 94"SELECT payload FROM pending_inputs WHERE operation_id=?" , 95 (input_id ,), 96 ).fetchone ()[0 ] 97payload = payload .replace (input_id .encode (),aged_id .encode ()) 98response = database .execute ( 99"SELECT response FROM operations WHERE id=?" , (input_id ,) 100 ).fetchone ()[0 ] 101response = response .replace (input_id ,aged_id ) 102database .execute ( 103"UPDATE operations SET id=?,created_at=?,response=? WHERE id=?" , 104 (aged_id ,old_time ,response ,input_id ), 105 ) 106database .execute ( 107"UPDATE pending_inputs SET operation_id=?,payload=?,accepted_at=? WHERE operation_id=?" , 108 (aged_id ,payload ,old_time ,input_id ), 109 ) 110 111self .app .restart (crash = True ,prepare = age_pending ) 112self .assertEqual (self .receipt (session ,aged_id )["delivery" ],"pending" ) 113self .assertEqual ( 114self .put_input (session ,aged_id ,intent )["acceptance_order" ], 115accepted ["acceptance_order" ], 116 ) 117with self .database ()as database : 118database .execute ("DROP TRIGGER hold_pending" ) 119self .committed (session ,aged_id ) 120self .app .idle (session ) 121self .app .restart (crash = True ) 122self .assertEqual (self .receipt (session ,aged_id )["delivery" ],"committed" ) 123self .assertEqual ( 124 [entry ["input_id" ]for entry in self .users (session )], [aged_id ] 125 ) 126 127def test_unfinished_consumed_turn_does_not_start_receipt_retention (self ): 128session = self .app .session () 129completed_id = operation_id () 130self .put_input ( 131session ,completed_id , {"kind" :"message" ,"text" :"completed control" } 132 ) 133self .committed (session ,completed_id ) 134self .app .idle (session ) 135started ,release = threading .Event (),threading .Event () 136self .addCleanup (release .set ) 137 138def held_reply (_ ): 139started .set () 140if not release .wait (30 ): 141raise AssertionError ("unfinished turn was not released" ) 142return text ("finished held turn" ) 143 144self .provider .script = held_reply 145held_id = operation_id () 146self .put_input ( 147session ,held_id , {"kind" :"message" ,"text" :"unfinished control" } 148 ) 149self .assertTrue (started .wait (10 )) 150self .assertEqual (self .receipt (session ,held_id )["delivery" ],"committed" ) 151old_time = int (time .time ()* 1000 )- RETENTION_MS - 10000 152with self .database ()as database : 153database .execute ( 154"UPDATE operations SET terminal_at=? WHERE id=?" , 155 (old_time ,completed_id ), 156 ) 157database .execute ( 158"UPDATE operations SET created_at=? WHERE id=?" , (old_time ,held_id ) 159 ) 160self .assertIsNone ( 161database .execute ( 162"SELECT terminal_at FROM operations WHERE id=?" , (held_id ,) 163 ).fetchone ()[0 ] 164 ) 165# Expiration of the completed control proves the real prune pass ran. 166self .wait_expiry (session ,completed_id ,404 ) 167self .assertEqual (self .receipt (session ,held_id )["turn" ]["state" ],"running" ) 168with self .database ()as database : 169self .assertIsNone ( 170database .execute ( 171"SELECT terminal_at FROM operations WHERE id=?" , (held_id ,) 172 ).fetchone ()[0 ] 173 ) 174release .set () 175self .app .idle (session ) 176self .assertEqual (self .receipt (session ,held_id )["turn" ]["state" ],"completed" ) 177with self .database ()as database : 178self .assertGreater ( 179database .execute ( 180"SELECT terminal_at FROM operations WHERE id=?" , (held_id ,) 181 ).fetchone ()[0 ], 182old_time , 183 ) 184 185def test_deleted_session_keeps_creation_and_cancelled_input_receipts (self ): 186session = self .app .session () 187with self .app .api (f"/sessions/ { session } " )as response : 188creation = json .load (response )["creation" ] 189with self .database ()as database : 190database .execute ( 191"CREATE TRIGGER hold_pending BEFORE INSERT ON transcript WHEN NEW.row_class='user' BEGIN SELECT RAISE(ABORT,'fixture'); END" 192 ) 193input_id = operation_id () 194intent = {"kind" :"message" ,"text" :"cancel when deleted" } 195accepted = self .put_input (session ,input_id ,intent ) 196deadline = time .monotonic ()+ 10 197while time .monotonic ()< deadline : 198if self .receipt (session ,input_id )["blocking_reason" ]: 199break 200time .sleep (0.03 ) 201else : 202self .fail ("input did not reach blocked consumption" ) 203self .delete (session ) 204self .assertEqual (self .receipt (session ,input_id )["delivery" ],"cancelled" ) 205self .app .restart (crash = True ) 206with self .assertRaises (urllib .error .HTTPError )as lookup : 207self .app .api (f"/sessions/ { session } " ) 208self .assertEqual (lookup .exception .code ,410 ) 209self .assertEqual (json .load (lookup .exception )["decision" ]["creation" ],creation ) 210with self .assertRaises (urllib .error .HTTPError )as retry_creation : 211self .app .api ( 212f"/sessions/ { session } " , 213creation ["submitted" ], 214method = "PUT" , 215headers = {"If-None-Match" :"*" }, 216 ) 217self .assertEqual (retry_creation .exception .code ,410 ) 218replay = self .put_input (session ,input_id ,intent ) 219self .assertEqual (replay ["acceptance_order" ],accepted ["acceptance_order" ]) 220self .assertEqual (replay ["delivery" ],"cancelled" ) 221with self .database ()as database : 222self .assertEqual ( 223database .execute ("SELECT count(*) FROM sessions" ).fetchone ()[0 ],0 224 ) 225self .assertEqual ( 226database .execute ("SELECT count(*) FROM pending_inputs" ).fetchone ()[0 ],0 227 ) 228 229def test_continuation_marker_survives_receipt_pruning_and_restart (self ): 230session = self .app .session () 231self .app .prompt (session ,"start" ).close () 232self .app .idle (session ) 233input_id = operation_id () 234self .put_input (session ,input_id , {"kind" :"continue" }) 235self .committed (session ,input_id ) 236self .app .idle (session ) 237old_time = int (time .time ()* 1000 )- RETENTION_MS - 10000 238with self .database ()as database : 239database .execute ( 240"UPDATE operations SET terminal_at=? WHERE id=?" , (old_time ,input_id ) 241 ) 242self .wait_expiry (session ,input_id ,404 ) 243self .app .restart (crash = True ) 244with self .database ()as database : 245marker = database .execute ( 246"SELECT session,seq FROM continuation_markers WHERE operation_id=?" , 247 (input_id ,), 248 ).fetchall () 249self .assertEqual (len (marker ),1 ) 250self .assertEqual (marker [0 ][0 ],session ) 251self .assertGreater (marker [0 ][1 ],0 ) 252self .assertEqual (len (self .users (session )),1 ) 253self .assertEqual (len (self .provider .requests ),2 ) 254deadline = time .monotonic ()+ 15 255while time .monotonic ()< deadline : 256with self .app .api (f"/sessions/ { session } ?tail=0" )as response : 257kernel = json .load (response )["kernel" ] 258if kernel ["state" ]== "attached" : 259break 260time .sleep (0.05 ) 261else : 262self .fail ( 263f"the retained kernel did not finish reattachment before deletion: { kernel } " 264 ) 265self .delete (session ) 266with self .database ()as database : 267self .assertEqual ( 268database .execute ( 269"SELECT count(*) FROM continuation_markers WHERE operation_id=?" , 270 (input_id ,), 271 ).fetchone ()[0 ], 2720 , 273 )