@@ -12,7 +12,8 @@ use aruna_core::structs::{
1212 RealmConfigDocument , RealmId , WATCH_INTEREST_DIRTY_PREFIX , WatchInterestDigest ,
1313 WatchInterestEntry , WatchInterestTable , watch_interest_dirty_key,
1414 watch_interest_dirty_realm_id, watch_interest_key_node_id, watch_interest_key_realm_id,
15- watch_interest_node_key, watch_interest_node_prefix, watch_interest_realm_prefix,
15+ watch_interest_node_key, watch_interest_node_prefix, watch_interest_pending_key,
16+ watch_interest_realm_prefix,
1617} ;
1718use aruna_core:: task:: { TaskEffect , TaskEvent , TaskKey } ;
1819use aruna_core:: types:: { Key , KeySpace , Value } ;
@@ -179,7 +180,7 @@ pub async fn publish_watch_interest(ctx: &DriverContext, node_id: NodeId) -> Res
179180 return Ok ( false ) ;
180181 }
181182
182- let mut writes: Vec < ( KeySpace , Key , Value ) > = Vec :: with_capacity ( realms. len ( ) ) ;
183+ let mut writes: Vec < ( KeySpace , Key , Value ) > = Vec :: with_capacity ( realms. len ( ) * 2 ) ;
183184 let mut targets: Vec < ( RealmId , DocumentSyncTarget ) > = Vec :: with_capacity ( realms. len ( ) ) ;
184185 let mut authorization_retries = Vec :: new ( ) ;
185186 for realm_id in & realms {
@@ -191,11 +192,48 @@ pub async fn publish_watch_interest(ctx: &DriverContext, node_id: NodeId) -> Res
191192 if check_failed {
192193 authorization_retries. push ( * realm_id) ;
193194 }
194- writes. push ( (
195- NOTIFICATION_WATCH_INTEREST_KEYSPACE . to_string ( ) ,
196- Key :: from ( watch_interest_node_key ( * realm_id, node_id) ) ,
197- Value :: from ( digest. to_bytes ( ) . map_err ( |e| e. to_string ( ) ) ?) ,
198- ) ) ;
195+ let digest_key = Key :: from ( watch_interest_node_key ( * realm_id, node_id) ) ;
196+ let pending_key = Key :: from ( watch_interest_pending_key ( * realm_id) ) ;
197+ let digest_value = Value :: from ( digest. to_bytes ( ) . map_err ( |e| e. to_string ( ) ) ?) ;
198+ let current = match storage
199+ . send_storage_effect ( StorageEffect :: BatchRead {
200+ reads : vec ! [
201+ (
202+ NOTIFICATION_WATCH_INTEREST_KEYSPACE . to_string( ) ,
203+ digest_key. clone( ) ,
204+ ) ,
205+ (
206+ NOTIFICATION_WATCH_INTEREST_KEYSPACE . to_string( ) ,
207+ pending_key. clone( ) ,
208+ ) ,
209+ ] ,
210+ txn_id : None ,
211+ } )
212+ . await
213+ {
214+ Event :: Storage ( StorageEvent :: BatchReadResult { values } ) => values,
215+ Event :: Storage ( StorageEvent :: Error { error } ) => return Err ( error. to_string ( ) ) ,
216+ other => return Err ( format ! ( "watch interest digest read failed: {other:?}" ) ) ,
217+ } ;
218+ let [ ( _, current_digest) , ( _, pending) ] = current. as_slice ( ) else {
219+ return Err ( "watch interest digest read count mismatch" . to_string ( ) ) ;
220+ } ;
221+ let changed = current_digest. as_ref ( ) != Some ( & digest_value) ;
222+ if !changed && pending. is_none ( ) {
223+ continue ;
224+ }
225+ if changed {
226+ writes. push ( (
227+ NOTIFICATION_WATCH_INTEREST_KEYSPACE . to_string ( ) ,
228+ digest_key,
229+ digest_value,
230+ ) ) ;
231+ writes. push ( (
232+ NOTIFICATION_WATCH_INTEREST_KEYSPACE . to_string ( ) ,
233+ pending_key,
234+ Value :: from ( Vec :: new ( ) ) ,
235+ ) ) ;
236+ }
199237 targets. push ( (
200238 * realm_id,
201239 DocumentSyncTarget :: WatchInterest {
@@ -205,11 +243,12 @@ pub async fn publish_watch_interest(ctx: &DriverContext, node_id: NodeId) -> Res
205243 ) ) ;
206244 }
207245
208- // Persist the refreshed digests but keep the dirty markers until the
209- // documents have been durably handed to replication.
246+ // Persist changed digests with pending markers, but keep the dirty markers
247+ // until the documents have been durably handed to replication.
210248 write_documents ( storage, writes) . await ?;
211249
212250 // Each realm rides its own realm-scoped topic, so replicate per realm.
251+ let published = !targets. is_empty ( ) ;
213252 for ( realm_id, target) in targets {
214253 drive (
215254 ReplicateDocumentsOperation :: new ( ReplicateDocumentsConfig {
@@ -225,6 +264,22 @@ pub async fn publish_watch_interest(ctx: &DriverContext, node_id: NodeId) -> Res
225264 )
226265 . await
227266 . map_err ( |error| format ! ( "watch interest replication failed: {error}" ) ) ?;
267+ match storage
268+ . send_storage_effect ( StorageEffect :: Delete {
269+ key_space : NOTIFICATION_WATCH_INTEREST_KEYSPACE . to_string ( ) ,
270+ key : Key :: from ( watch_interest_pending_key ( realm_id) ) ,
271+ txn_id : None ,
272+ } )
273+ . await
274+ {
275+ Event :: Storage ( StorageEvent :: DeleteResult { .. } ) => { }
276+ Event :: Storage ( StorageEvent :: Error { error } ) => return Err ( error. to_string ( ) ) ,
277+ other => {
278+ return Err ( format ! (
279+ "watch interest pending marker delete failed: {other:?}"
280+ ) ) ;
281+ }
282+ }
228283 }
229284
230285 // Preserve a retry generation when a permission lookup failed. The current
@@ -237,7 +292,7 @@ pub async fn publish_watch_interest(ctx: &DriverContext, node_id: NodeId) -> Res
237292 // those a concurrent CRUD did not re-dirty in the meantime.
238293 clear_consumed_markers ( storage, observed_markers) . await ?;
239294
240- Ok ( true )
295+ Ok ( published )
241296}
242297
243298/// Builds this node's digest for one realm from subscriptions whose owners are
@@ -267,8 +322,7 @@ async fn build_realm_digest(
267322 ) )
268323}
269324
270- /// Persists the refreshed digest documents. Each digest key has a single writer
271- /// (this node), so a plain batch write is enough.
325+ /// Persists changed digests and their pending markers atomically.
272326async fn write_documents (
273327 storage : & StorageHandle ,
274328 writes : Vec < ( KeySpace , Key , Value ) > ,
@@ -820,6 +874,21 @@ mod tests {
820874 }
821875 }
822876
877+ async fn read_pending ( ctx : & DriverContext , realm_id : RealmId ) -> Option < Value > {
878+ match ctx
879+ . storage_handle
880+ . send_storage_effect ( StorageEffect :: Read {
881+ key_space : NOTIFICATION_WATCH_INTEREST_KEYSPACE . to_string ( ) ,
882+ key : Key :: from ( watch_interest_pending_key ( realm_id) ) ,
883+ txn_id : None ,
884+ } )
885+ . await
886+ {
887+ Event :: Storage ( StorageEvent :: ReadResult { value, .. } ) => value,
888+ other => panic ! ( "unexpected pending marker read event: {other:?}" ) ,
889+ }
890+ }
891+
823892 async fn read_digest (
824893 ctx : & DriverContext ,
825894 realm_id : RealmId ,
@@ -924,6 +993,106 @@ mod tests {
924993 ) ;
925994 }
926995
996+ // An acknowledged unchanged digest consumes a fresh dirty marker without
997+ // another replication handoff.
998+ #[ tokio:: test]
999+ async fn unchanged_skips_publish ( ) {
1000+ let temp = tempdir ( ) . unwrap ( ) ;
1001+ let ctx = test_ctx ( temp. path ( ) . to_str ( ) . unwrap ( ) ) ;
1002+ let realm_id = RealmId ( [ 7u8 ; 32 ] ) ;
1003+ let node_id = node ( 5 ) ;
1004+ let owner = user ( 7 , 2 ) ;
1005+ let group_id = Ulid :: r#gen ( ) ;
1006+ install_realm_config ( & ctx, realm_id, & [ node_id] ) . await ;
1007+ install_authorization ( & ctx, realm_id, node_id, group_id, owner, & [ ] ) . await ;
1008+ create_watch_subscription (
1009+ & ctx. storage_handle ,
1010+ owner,
1011+ metadata_prefix ( group_id, "a" ) ,
1012+ mask ( ) ,
1013+ 1 ,
1014+ )
1015+ . await
1016+ . expect ( "create" ) ;
1017+
1018+ assert ! (
1019+ publish_watch_interest( & ctx, node_id)
1020+ . await
1021+ . expect( "publish" )
1022+ ) ;
1023+ let before = read_digest ( & ctx, realm_id, node_id)
1024+ . await
1025+ . expect ( "digest" )
1026+ . to_bytes ( )
1027+ . unwrap ( ) ;
1028+ assert ! ( read_pending( & ctx, realm_id) . await . is_none( ) ) ;
1029+
1030+ mark_watch_interest_dirty ( & ctx, realm_id)
1031+ . await
1032+ . expect ( "mark dirty" ) ;
1033+ assert ! (
1034+ !publish_watch_interest( & ctx, node_id)
1035+ . await
1036+ . expect( "republish" )
1037+ ) ;
1038+
1039+ let after = read_digest ( & ctx, realm_id, node_id)
1040+ . await
1041+ . expect ( "digest" )
1042+ . to_bytes ( )
1043+ . unwrap ( ) ;
1044+ assert_eq ! ( after, before) ;
1045+ assert ! ( read_marker( & ctx, realm_id) . await . is_none( ) ) ;
1046+ assert ! ( read_pending( & ctx, realm_id) . await . is_none( ) ) ;
1047+ }
1048+
1049+ // Persistent authorization unavailability retains retries while identical
1050+ // empty digests stop producing replicated events.
1051+ #[ tokio:: test]
1052+ async fn retry_stays_dirty ( ) {
1053+ let temp = tempdir ( ) . unwrap ( ) ;
1054+ let ctx = test_ctx ( temp. path ( ) . to_str ( ) . unwrap ( ) ) ;
1055+ let realm_id = RealmId ( [ 8u8 ; 32 ] ) ;
1056+ let node_id = node ( 6 ) ;
1057+ let owner = user ( 8 , 2 ) ;
1058+ let group_id = Ulid :: r#gen ( ) ;
1059+ install_realm_config ( & ctx, realm_id, & [ node_id] ) . await ;
1060+ create_watch_subscription (
1061+ & ctx. storage_handle ,
1062+ owner,
1063+ metadata_prefix ( group_id, "a" ) ,
1064+ mask ( ) ,
1065+ 1 ,
1066+ )
1067+ . await
1068+ . expect ( "create" ) ;
1069+
1070+ assert ! (
1071+ publish_watch_interest( & ctx, node_id)
1072+ . await
1073+ . expect( "publish" )
1074+ ) ;
1075+ assert ! ( read_marker( & ctx, realm_id) . await . is_some( ) ) ;
1076+ let digest = read_digest ( & ctx, realm_id, node_id)
1077+ . await
1078+ . expect ( "empty digest" ) ;
1079+ assert ! ( digest. entries. is_empty( ) ) ;
1080+ let before = digest. to_bytes ( ) . unwrap ( ) ;
1081+
1082+ assert ! (
1083+ !publish_watch_interest( & ctx, node_id)
1084+ . await
1085+ . expect( "republish" )
1086+ ) ;
1087+ let after = read_digest ( & ctx, realm_id, node_id)
1088+ . await
1089+ . expect ( "empty digest" )
1090+ . to_bytes ( )
1091+ . unwrap ( ) ;
1092+ assert_eq ! ( after, before) ;
1093+ assert ! ( read_marker( & ctx, realm_id) . await . is_some( ) ) ;
1094+ }
1095+
9271096 #[ tokio:: test]
9281097 async fn deleting_last_watch_publishes_empty_digest ( ) {
9291098 let temp = tempdir ( ) . unwrap ( ) ;
0 commit comments