@@ -85,6 +85,7 @@ use crate::model::{
8585use crate :: outbox:: {
8686 event_data_schemas, insert_configured_events, EnvelopeBinding , OutboxError , OutboxMutation ,
8787} ;
88+ use crate :: postgres:: external_refresh_queue:: { capture_local_records, LocalRecordCapture } ;
8889use 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-
26962691pub ( 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
27162711pub ( 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 {
47034691mod 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