Skip to content

Commit bbbf0db

Browse files
committed
fix(breg): preserve registry writes during external refresh
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
1 parent 36d2301 commit bbbf0db

9 files changed

Lines changed: 646 additions & 116 deletions

File tree

‎crates/registry-breg/src/external_views.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,9 @@ pub(crate) fn mark_link_fields(
190190
};
191191
let mut fields = vec![link.from_field.as_str().to_owned()];
192192
for boundary in &profile.row_boundaries {
193-
if !fields.contains(&boundary.field) {
193+
if boundary.field.as_str() != entity.canonical_id.id.as_str()
194+
&& !fields.contains(&boundary.field)
195+
{
194196
fields.push(boundary.field.clone());
195197
}
196198
}

‎crates/registry-breg/src/mutation.rs‎

Lines changed: 124 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ use crate::model::{
8585
use crate::outbox::{
8686
event_data_schemas, insert_configured_events, EnvelopeBinding, OutboxError, OutboxMutation,
8787
};
88+
use crate::postgres::external_refresh_queue::{capture_local_records, LocalRecordCapture};
8889
use crate::postgres::{
8990
begin_action_transaction, begin_record_transaction, begin_timer_action_transaction,
9091
ActionClaimContext, ClaimContext, ExpectedRegistryIdentity, ImmediateActionLinkBinding,
@@ -2687,16 +2688,10 @@ pub(crate) struct CurrentRow {
26872688
/// tombstones pass an empty field set, which deliberately matches every plan
26882689
/// for the Record type. The call is skipped entirely for packages without an
26892690
/// indexed projection, including packages created before the queue existed.
2690-
pub(super) struct ExternalRefreshCapture {
2691-
entity_id: String,
2692-
record_id: Uuid,
2693-
changed_fields: BTreeSet<String>,
2694-
}
2695-
26962691
pub(super) fn external_refresh_capture(
26972692
entity_id: &str,
26982693
current: &CurrentRow,
2699-
) -> ExternalRefreshCapture {
2694+
) -> LocalRecordCapture {
27002695
let changed_fields = match (&current.before_data, current.record_lifecycle.as_str()) {
27012696
(Some(before), "active") => before
27022697
.keys()
@@ -2706,7 +2701,7 @@ pub(super) fn external_refresh_capture(
27062701
.collect::<BTreeSet<_>>(),
27072702
_ => BTreeSet::new(),
27082703
};
2709-
ExternalRefreshCapture {
2704+
LocalRecordCapture {
27102705
entity_id: entity_id.to_owned(),
27112706
record_id: current.record_uuid,
27122707
changed_fields,
@@ -2715,22 +2710,15 @@ pub(super) fn external_refresh_capture(
27152710

27162711
pub(super) async fn capture_external_refresh_rows(
27172712
transaction: &Transaction<'_>,
2718-
captures: &[ExternalRefreshCapture],
2713+
captures: &[LocalRecordCapture],
27192714
enabled: bool,
27202715
) -> Result<(), MutationError> {
27212716
if !enabled {
27222717
return Ok(());
27232718
}
2724-
for capture in captures {
2725-
crate::postgres::external_refresh_queue::capture_local_record(
2726-
transaction,
2727-
&capture.entity_id,
2728-
capture.record_id,
2729-
&capture.changed_fields,
2730-
)
2719+
capture_local_records(transaction, captures)
27312720
.await
27322721
.map_err(|_| MutationError::Unavailable)?;
2733-
}
27342722
Ok(())
27352723
}
27362724

@@ -4703,6 +4691,125 @@ impl From<HistoryCommitError> for MutationError {
47034691
mod tests {
47044692
use super::*;
47054693

4694+
#[tokio::test]
4695+
async fn external_refresh_capture_locks_plans_in_global_order() {
4696+
use crate::postgres::{
4697+
begin_registry_transaction,
4698+
external_projection::postgres_tests::Activated,
4699+
external_refresh_queue::{register_plans, PlanRegistration},
4700+
external_view_query::tests::registry,
4701+
};
4702+
4703+
let registry = registry();
4704+
let fixture = Activated::new(&registry).await;
4705+
let scope = fixture
4706+
.bind(&registry, "projection", "binding", "grant", 60)
4707+
.await
4708+
.unwrap();
4709+
let lock = RegistryLockKey::derive(registry.registry_id()).unwrap();
4710+
let timeout = Duration::from_secs(10);
4711+
let mut blocker = fixture.pool.get().await.unwrap();
4712+
let setup = begin_registry_transaction(&mut blocker, lock, timeout, &fixture.expected)
4713+
.await
4714+
.unwrap();
4715+
let plans = [("a-plan", "late-record"), ("z-plan", "early-record")].map(
4716+
|(plan_id, root_entity_id)| PlanRegistration {
4717+
plan_id: plan_id.to_owned(),
4718+
package_digest: fixture.expected.package_digest.clone(),
4719+
activation_id: fixture.expected.activation_uuid().unwrap(),
4720+
authority_digest: "authority".to_owned(),
4721+
root_entity_id: root_entity_id.to_owned(),
4722+
watched_fields: BTreeSet::from(["subject-key".to_owned()]),
4723+
projection_epochs: vec![scope.epoch],
4724+
interval_seconds: 900,
4725+
page_size: 50,
4726+
max_pending: 100,
4727+
},
4728+
);
4729+
register_plans(&setup, &plans).await.unwrap();
4730+
setup.commit().await.unwrap();
4731+
4732+
// Hold the first lock that retention takes. It must still be possible
4733+
// to lock the last plan while the mutation waits on the first one.
4734+
let blocker = begin_registry_transaction(&mut blocker, lock, timeout, &fixture.expected)
4735+
.await
4736+
.unwrap();
4737+
let blocker_pid: i32 = blocker
4738+
.transaction()
4739+
.query_one(
4740+
"SELECT pg_backend_pid() FROM registry_internal.registry_external_refresh_plans
4741+
WHERE plan_id='a-plan' FOR UPDATE",
4742+
&[],
4743+
)
4744+
.await
4745+
.unwrap()
4746+
.get(0);
4747+
let mut writer = fixture.pool.get().await.unwrap();
4748+
let writer = begin_registry_transaction(&mut writer, lock, timeout, &fixture.expected)
4749+
.await
4750+
.unwrap();
4751+
let writer_pid: i32 = writer
4752+
.transaction()
4753+
.query_one("SELECT pg_backend_pid()", &[])
4754+
.await
4755+
.unwrap()
4756+
.get(0);
4757+
let captures = ["early-record", "late-record"].map(|entity_id| LocalRecordCapture {
4758+
entity_id: entity_id.to_owned(),
4759+
record_id: Uuid::new_v4(),
4760+
changed_fields: BTreeSet::new(),
4761+
});
4762+
let probe = async {
4763+
let waiting = tokio::time::timeout(Duration::from_secs(5), async {
4764+
loop {
4765+
let row = fixture
4766+
.database
4767+
.admin
4768+
.query_one(
4769+
"SELECT $1 = ANY(pg_blocking_pids($2))",
4770+
&[&blocker_pid, &writer_pid],
4771+
)
4772+
.await
4773+
.unwrap();
4774+
if row.get::<_, bool>(0) {
4775+
break;
4776+
}
4777+
tokio::time::sleep(Duration::from_millis(10)).await;
4778+
}
4779+
})
4780+
.await;
4781+
let last_lock = blocker
4782+
.transaction()
4783+
.query_one(
4784+
"SELECT 1 FROM registry_internal.registry_external_refresh_plans
4785+
WHERE plan_id='z-plan' FOR UPDATE NOWAIT",
4786+
&[],
4787+
)
4788+
.await;
4789+
blocker.rollback().await.unwrap();
4790+
(waiting, last_lock)
4791+
};
4792+
let (captured, (waiting, last_lock)) = tokio::join!(
4793+
capture_external_refresh_rows(writer.transaction(), &captures, true),
4794+
probe,
4795+
);
4796+
captured.unwrap();
4797+
writer.commit().await.unwrap();
4798+
let work_count: i64 = fixture
4799+
.migration
4800+
.query_one(
4801+
"SELECT count(*) FROM registry_internal.registry_external_refresh_work",
4802+
&[],
4803+
)
4804+
.await
4805+
.unwrap()
4806+
.get(0);
4807+
fixture.cleanup().await;
4808+
waiting.expect("the mutation waits for the first plan lock");
4809+
last_lock.expect("the mutation must not lock a later plan before the first one");
4810+
assert_eq!(work_count, 2);
4811+
}
4812+
47064813
/// Every record write converts its values here. A value a caller never
47074814
/// submitted (a proposal prepared before a view linked the field, applied
47084815
/// as stored) reaches the write without the earlier located checks, so this

0 commit comments

Comments
 (0)