Skip to content

Commit 555c6bd

Browse files
committed
fix: skip republishing unchanged watch interest digests
1 parent 933dc31 commit 555c6bd

2 files changed

Lines changed: 191 additions & 15 deletions

File tree

core/src/structs/notification_watch.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,11 +291,11 @@ pub fn parse_watch_subscription_key(key: &[u8]) -> Result<(UserId, Ulid), Conver
291291

292292
/// Keys in the watch-interest keyspace. Per-node digests use fixed-length keys
293293
/// so their prefixes are unambiguous: `n/` + realm id + node id (realm first so
294-
/// all nodes' digests for one realm form a single scan range). Local-only dirty
295-
/// markers use a text prefix (`dirty/`) that never collides with the binary
296-
/// digest prefix.
294+
/// all nodes' digests for one realm form a single scan range). Local-only
295+
/// markers use text prefixes that never collide with the binary digest prefix.
297296
pub const WATCH_INTEREST_NODE_PREFIX: &[u8] = b"n/";
298297
pub const WATCH_INTEREST_DIRTY_PREFIX: &[u8] = b"dirty/";
298+
const WATCH_INTEREST_PENDING_PREFIX: &[u8] = b"pending/";
299299

300300
/// One coalesced interest entry: the union of every subscription a node holds
301301
/// for a realm that shares this path prefix.
@@ -390,6 +390,13 @@ pub fn watch_interest_dirty_key(realm_id: RealmId) -> Vec<u8> {
390390
key
391391
}
392392

393+
pub fn watch_interest_pending_key(realm_id: RealmId) -> Vec<u8> {
394+
let mut key = Vec::with_capacity(WATCH_INTEREST_PENDING_PREFIX.len() + 32);
395+
key.extend_from_slice(WATCH_INTEREST_PENDING_PREFIX);
396+
key.extend_from_slice(realm_id.as_bytes());
397+
key
398+
}
399+
393400
/// Recovers the realm id from a `dirty/<realm>` marker key.
394401
pub fn watch_interest_dirty_realm_id(key: &[u8]) -> Option<RealmId> {
395402
let tail = key.strip_prefix(WATCH_INTEREST_DIRTY_PREFIX)?;

operations/src/notifications/watch/interest.rs

Lines changed: 181 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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
};
1718
use aruna_core::task::{TaskEffect, TaskEvent, TaskKey};
1819
use 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.
272326
async 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

Comments
 (0)