@@ -249,6 +249,163 @@ fn parse_call_args(content_type: headers::ContentType, body: Bytes) -> axum::res
249249 }
250250}
251251
252+ /// 2PC prepare endpoint: execute a reducer and return a prepare_id.
253+ ///
254+ /// `POST /v1/database/:name_or_identity/prepare/:reducer`
255+ ///
256+ /// On success, the response includes:
257+ /// - `X-Prepare-Id` header with the prepare_id
258+ /// - Body contains the reducer return value (if any)
259+ pub async fn prepare < S : ControlStateDelegate + NodeDelegate > (
260+ State ( worker_ctx) : State < S > ,
261+ Extension ( auth) : Extension < SpacetimeAuth > ,
262+ Path ( CallParams {
263+ name_or_identity,
264+ reducer,
265+ } ) : Path < CallParams > ,
266+ TypedHeader ( content_type) : TypedHeader < headers:: ContentType > ,
267+ headers : axum:: http:: HeaderMap ,
268+ body : Bytes ,
269+ ) -> axum:: response:: Result < impl IntoResponse > {
270+ let args = parse_call_args ( content_type, body) ?;
271+ let caller_identity = auth. claims . identity ;
272+
273+ // The coordinator sends its actual database identity in `X-Coordinator-Identity`.
274+ // Without this, `anon_auth_middleware` gives the HTTP caller an ephemeral random
275+ // identity, which gets stored in `st_2pc_state` and breaks recovery polling.
276+ let coordinator_identity = headers
277+ . get ( "X-Coordinator-Identity" )
278+ . and_then ( |v| v. to_str ( ) . ok ( ) )
279+ . and_then ( |s| spacetimedb_lib:: Identity :: from_hex ( s) . ok ( ) ) ;
280+
281+ let ( module, Database { owner_identity, .. } ) = find_module_and_database ( & worker_ctx, name_or_identity) . await ?;
282+
283+ // 2PC prepare is a server-to-server call; no client lifecycle management needed.
284+ // call_identity_connected/disconnected submit jobs to the module's executor, which
285+ // will be blocked holding the 2PC write lock after prepare_reducer returns — deadlock.
286+ let result = module
287+ . prepare_reducer ( caller_identity, None , & reducer, args. 0 , coordinator_identity)
288+ . await ;
289+
290+ match result {
291+ Ok ( ( prepare_id, rcr, return_value) ) => {
292+ let ( status, body) =
293+ reducer_outcome_response ( & module, & owner_identity, & reducer, rcr. outcome , return_value, args. 1 ) ?;
294+ let mut response = (
295+ status,
296+ TypedHeader ( SpacetimeEnergyUsed ( rcr. energy_used ) ) ,
297+ TypedHeader ( SpacetimeExecutionDurationMicros ( rcr. execution_duration ) ) ,
298+ body,
299+ )
300+ . into_response ( ) ;
301+ if !prepare_id. is_empty ( ) {
302+ response
303+ . headers_mut ( )
304+ . insert ( "X-Prepare-Id" , http:: HeaderValue :: from_str ( & prepare_id) . unwrap ( ) ) ;
305+ }
306+ Ok ( response)
307+ }
308+ Err ( e) => Err ( map_reducer_error ( e, & reducer) . into ( ) ) ,
309+ }
310+ }
311+
312+ #[ derive( Deserialize ) ]
313+ pub struct TwoPcParams {
314+ name_or_identity : NameOrIdentity ,
315+ prepare_id : String ,
316+ }
317+
318+ /// 2PC commit endpoint: finalize a prepared transaction.
319+ ///
320+ /// `POST /v1/database/:name_or_identity/2pc/commit/:prepare_id`
321+ pub async fn commit_2pc < S : ControlStateDelegate + NodeDelegate > (
322+ State ( worker_ctx) : State < S > ,
323+ Extension ( _auth) : Extension < SpacetimeAuth > ,
324+ Path ( TwoPcParams {
325+ name_or_identity,
326+ prepare_id,
327+ } ) : Path < TwoPcParams > ,
328+ ) -> axum:: response:: Result < impl IntoResponse > {
329+ let ( module, _database) = find_module_and_database ( & worker_ctx, name_or_identity) . await ?;
330+
331+ module. commit_prepared ( & prepare_id) . map_err ( |e| {
332+ log:: error!( "2PC commit failed: {e}" ) ;
333+ ( StatusCode :: NOT_FOUND , e) . into_response ( )
334+ } ) ?;
335+
336+ Ok ( StatusCode :: OK )
337+ }
338+
339+ /// 2PC abort endpoint: abort a prepared transaction.
340+ ///
341+ /// `POST /v1/database/:name_or_identity/2pc/abort/:prepare_id`
342+ pub async fn abort_2pc < S : ControlStateDelegate + NodeDelegate > (
343+ State ( worker_ctx) : State < S > ,
344+ Extension ( _auth) : Extension < SpacetimeAuth > ,
345+ Path ( TwoPcParams {
346+ name_or_identity,
347+ prepare_id,
348+ } ) : Path < TwoPcParams > ,
349+ ) -> axum:: response:: Result < impl IntoResponse > {
350+ let ( module, _database) = find_module_and_database ( & worker_ctx, name_or_identity) . await ?;
351+
352+ module. abort_prepared ( & prepare_id) . map_err ( |e| {
353+ log:: error!( "2PC abort failed: {e}" ) ;
354+ ( StatusCode :: NOT_FOUND , e) . into_response ( )
355+ } ) ?;
356+
357+ Ok ( StatusCode :: OK )
358+ }
359+
360+ /// 2PC coordinator status endpoint.
361+ ///
362+ /// Returns `"commit"` if the coordinator has durably decided COMMIT for `prepare_id`,
363+ /// or `"abort"` otherwise. Participant B polls this to recover from a timeout or crash.
364+ ///
365+ /// `GET /v1/database/:name_or_identity/2pc/status/:prepare_id`
366+ pub async fn status_2pc < S : ControlStateDelegate + NodeDelegate > (
367+ State ( worker_ctx) : State < S > ,
368+ Extension ( _auth) : Extension < SpacetimeAuth > ,
369+ Path ( TwoPcParams {
370+ name_or_identity,
371+ prepare_id,
372+ } ) : Path < TwoPcParams > ,
373+ ) -> axum:: response:: Result < impl IntoResponse > {
374+ let ( module, _database) = find_module_and_database ( & worker_ctx, name_or_identity) . await ?;
375+
376+ let decision = if module. has_2pc_coordinator_commit ( & prepare_id) {
377+ "commit"
378+ } else {
379+ "abort"
380+ } ;
381+
382+ Ok ( ( StatusCode :: OK , decision) )
383+ }
384+
385+ /// 2PC commit-ack endpoint.
386+ ///
387+ /// Called by participant B after it commits via the status-poll recovery path,
388+ /// so that the coordinator can delete its `st_2pc_coordinator_log` entry.
389+ ///
390+ /// `POST /v1/database/:name_or_identity/2pc/ack-commit/:prepare_id`
391+ pub async fn ack_commit_2pc < S : ControlStateDelegate + NodeDelegate > (
392+ State ( worker_ctx) : State < S > ,
393+ Extension ( _auth) : Extension < SpacetimeAuth > ,
394+ Path ( TwoPcParams {
395+ name_or_identity,
396+ prepare_id,
397+ } ) : Path < TwoPcParams > ,
398+ ) -> axum:: response:: Result < impl IntoResponse > {
399+ let ( module, _database) = find_module_and_database ( & worker_ctx, name_or_identity) . await ?;
400+
401+ module. ack_2pc_coordinator_commit ( & prepare_id) . map_err ( |e| {
402+ log:: error!( "2PC ack-commit failed: {e}" ) ;
403+ ( StatusCode :: INTERNAL_SERVER_ERROR , e. to_string ( ) ) . into_response ( )
404+ } ) ?;
405+
406+ Ok ( StatusCode :: OK )
407+ }
408+
252409/// Encode a reducer return value as an HTTP response.
253410///
254411/// If the outcome is an error, return a raw string with `application/text`. Ignore `want_bsatn` in this case.
@@ -1278,6 +1435,16 @@ pub struct DatabaseRoutes<S> {
12781435 pub db_reset : MethodRouter < S > ,
12791436 /// GET: /database/: name_or_identity/unstable/timestamp
12801437 pub timestamp_get : MethodRouter < S > ,
1438+ /// POST: /database/:name_or_identity/prepare/:reducer
1439+ pub prepare_post : MethodRouter < S > ,
1440+ /// POST: /database/:name_or_identity/2pc/commit/:prepare_id
1441+ pub commit_2pc_post : MethodRouter < S > ,
1442+ /// POST: /database/:name_or_identity/2pc/abort/:prepare_id
1443+ pub abort_2pc_post : MethodRouter < S > ,
1444+ /// GET: /database/:name_or_identity/2pc/status/:prepare_id
1445+ pub status_2pc_get : MethodRouter < S > ,
1446+ /// POST: /database/:name_or_identity/2pc/ack-commit/:prepare_id
1447+ pub ack_commit_2pc_post : MethodRouter < S > ,
12811448}
12821449
12831450impl < S > Default for DatabaseRoutes < S >
@@ -1303,6 +1470,11 @@ where
13031470 pre_publish : post ( pre_publish :: < S > ) ,
13041471 db_reset : put ( reset :: < S > ) ,
13051472 timestamp_get : get ( get_timestamp :: < S > ) ,
1473+ prepare_post : post ( prepare :: < S > ) ,
1474+ commit_2pc_post : post ( commit_2pc :: < S > ) ,
1475+ abort_2pc_post : post ( abort_2pc :: < S > ) ,
1476+ status_2pc_get : get ( status_2pc :: < S > ) ,
1477+ ack_commit_2pc_post : post ( ack_commit_2pc :: < S > ) ,
13061478 }
13071479 }
13081480}
@@ -1327,7 +1499,12 @@ where
13271499 . route ( "/sql" , self . sql_post )
13281500 . route ( "/unstable/timestamp" , self . timestamp_get )
13291501 . route ( "/pre_publish" , self . pre_publish )
1330- . route ( "/reset" , self . db_reset ) ;
1502+ . route ( "/reset" , self . db_reset )
1503+ . route ( "/prepare/:reducer" , self . prepare_post )
1504+ . route ( "/2pc/commit/:prepare_id" , self . commit_2pc_post )
1505+ . route ( "/2pc/abort/:prepare_id" , self . abort_2pc_post )
1506+ . route ( "/2pc/status/:prepare_id" , self . status_2pc_get )
1507+ . route ( "/2pc/ack-commit/:prepare_id" , self . ack_commit_2pc_post ) ;
13311508
13321509 axum:: Router :: new ( )
13331510 . route ( "/" , self . root_post )
0 commit comments