Skip to main content

cratestack_client_rust/rpc/
batch_call.rs

1// -----------------------------------------------------------------------------
2// `BatchableCall` + `BatchHandle` — the prepared-call / typed-key duo
3// that the typed batch surface is built around. Sits alongside
4// `rpc::batch::{BatchBuilder, BatchResults}`, which consume them.
5// -----------------------------------------------------------------------------
6
7use cratestack_core::CratestackError;
8
9use crate::codec::HttpClientCodec;
10use crate::rpc::batch::BatchBuilder;
11use crate::rpc::client::RpcClient;
12use crate::rpc::error::RpcClientError;
13
14/// A typed unary RPC call that has been *prepared* but not yet sent.
15///
16/// Produced by every macro-generated unary RPC method on the typed
17/// client (model CRUD + unary procedures). Two consumption modes:
18///
19/// - **Eager.** `.await` directly — `IntoFuture` desugars to the same
20///   HTTP request `RpcClient::call` would have made.
21/// - **Batched.** `.queue(&mut batch)` registers the call with a
22///   [`BatchBuilder`] for a single multiplexed `POST /rpc/batch`.
23///   Returns a typed [`BatchHandle`] for `.take(...)` on the results
24///   after `batch.send().await` resolves.
25///
26/// The input is eagerly converted to `serde_json::Value` at
27/// construction time so the same prepared call can flow into either
28/// consumption mode without re-borrowing the input. Conversion errors
29/// surface lazily — eagerly on `.await`, per-handle on the batch path.
30#[must_use = "BatchableCall does nothing until `.await`ed or `.queue(&mut batch)`d"]
31pub struct BatchableCall<C, O> {
32    rpc: RpcClient<C>,
33    op_id: String,
34    input_value: Result<serde_json::Value, CratestackError>,
35    /// `fn() -> O` instead of `O` so `BatchableCall` is `Send` + `Sync`
36    /// regardless of whether `O` is — the marker is variance-only.
37    _output: std::marker::PhantomData<fn() -> O>,
38}
39
40impl<C, O> BatchableCall<C, O>
41where
42    C: HttpClientCodec + Clone + Send + 'static,
43    O: serde::de::DeserializeOwned + Send + 'static,
44{
45    /// Construct a prepared call. Callers should generally use the
46    /// macro-generated typed methods rather than building this by hand.
47    pub fn new<I>(rpc: RpcClient<C>, op_id: impl Into<String>, input: &I) -> Self
48    where
49        I: serde::Serialize,
50    {
51        // cratestack#677: this used to strip `null` object entries here
52        // before handing the value to the codec — a workaround for
53        // `serde_json::Value::Null` mis-encoding as the CBOR empty-array
54        // marker (`0x80`) instead of CBOR null (`0xf6`). That root cause
55        // was fixed in `CborCodec::encode` (cratestack#657, via
56        // `serialize_unit_as_null`), which makes the strip both
57        // unnecessary and actively harmful: it recursed into nested
58        // objects, so an explicit `null` on a `model.<Model>.update`
59        // patch — meaning "clear this nullable column" — was
60        // indistinguishable from an untouched field by the time it
61        // reached the codec, silently dropping the clear. See
62        // `crates/cratestack-pg/tests/rpc_batch_explicit_null_clear.rs`
63        // for the regression test (fails if the strip is restored).
64        let input_value = serde_json::to_value(input)
65            .map_err(|error| CratestackError::Codec(format!("encode batch input: {error}")));
66        Self {
67            rpc,
68            op_id: op_id.into(),
69            input_value,
70            _output: std::marker::PhantomData,
71        }
72    }
73
74    /// Queue this call into a [`BatchBuilder`] for deferred
75    /// execution. The returned [`BatchHandle`] is the key to
76    /// retrieve the typed result via [`BatchResults::take`] after
77    /// [`BatchBuilder::send`] resolves.
78    ///
79    /// Input-encoding errors observed at construction time are
80    /// preserved per-handle, so a single bad input in a batch
81    /// produces a per-handle `take(...)?` error rather than
82    /// poisoning the whole batch.
83    pub fn queue(self, batch: &mut BatchBuilder<C>) -> BatchHandle<O> {
84        let id = match self.input_value {
85            Ok(value) => batch.push_frame(self.op_id, value),
86            Err(error) => batch.push_failed_frame(error),
87        };
88        BatchHandle {
89            id,
90            _output: std::marker::PhantomData,
91        }
92    }
93}
94
95impl<C, O> std::future::IntoFuture for BatchableCall<C, O>
96where
97    C: HttpClientCodec + Clone + Send + 'static,
98    O: serde::de::DeserializeOwned + Send + 'static,
99{
100    type Output = Result<O, RpcClientError>;
101    type IntoFuture = crate::task::BoxFuture<'static, Self::Output>;
102
103    fn into_future(self) -> Self::IntoFuture {
104        Box::pin(async move {
105            let value = self.input_value.map_err(RpcClientError::Codec)?;
106            self.rpc
107                .call::<serde_json::Value, O>(&self.op_id, &value)
108                .await
109        })
110    }
111}
112
113/// A typed key returned by [`BatchableCall::queue`]. Pair it with
114/// [`BatchResults::take`] to extract the typed output for that op
115/// from the batch response.
116///
117/// Carries `O` only as a phantom type — there's no runtime overhead.
118/// Cheap to clone; clones share identity (you can `take(handle)` only
119/// once, but the type tracks across passes).
120pub struct BatchHandle<O> {
121    pub(crate) id: u64,
122    pub(crate) _output: std::marker::PhantomData<fn() -> O>,
123}
124
125impl<O> Clone for BatchHandle<O> {
126    // Hand-written (not derived) so the impl doesn't pick up a spurious
127    // `O: Clone` bound — `O` is purely a phantom. `BatchHandle` is `Copy`,
128    // so the canonical clone is just a copy of `*self`.
129    fn clone(&self) -> Self {
130        *self
131    }
132}
133
134impl<O> Copy for BatchHandle<O> {}
135
136impl<O> std::fmt::Debug for BatchHandle<O> {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        f.debug_struct("BatchHandle").field("id", &self.id).finish()
139    }
140}
141
142#[cfg(test)]
143mod no_null_strip_tests {
144    use cratestack_codec_cbor::CborCodec;
145    use cratestack_core::CratestackCodec;
146    use serde::{Deserialize, Serialize};
147
148    #[derive(Serialize, Deserialize, PartialEq, Debug)]
149    struct Req {
150        required: String,
151        #[serde(default)]
152        optional: Option<String>,
153        #[serde(default)]
154        nested: Option<Inner>,
155    }
156
157    #[derive(Serialize, Deserialize, PartialEq, Debug)]
158    struct Inner {
159        #[serde(default)]
160        maybe: Option<String>,
161        kept: String,
162    }
163
164    /// cratestack#677: `BatchableCall::new` no longer strips `null` object
165    /// entries before handing the value to the codec (that workaround for
166    /// the `0x80`-vs-`0xf6` mis-encoding was obsoleted by cratestack#657's
167    /// `serialize_unit_as_null` fix, and was actively dropping explicit
168    /// nullable-column clears — see `crates/cratestack-pg/tests/
169    /// rpc_batch_explicit_null_clear.rs` for the production-path
170    /// regression test). This is the narrower codec-level guarantee the
171    /// removal now leans on: an explicit `null`, nested or not, must
172    /// still reach the wire as CBOR null and decode cleanly.
173    #[test]
174    fn unstripped_null_reaches_wire_as_cbor_null_and_decodes_cleanly() {
175        let value = serde_json::json!({
176            "required": "x",
177            "optional": null,
178            "nested": { "maybe": null, "kept": "k" },
179        });
180        let bytes = CborCodec.encode(&value).expect("encode");
181        assert!(
182            bytes.contains(&0xf6),
183            "an explicit null, nested or not, must reach the wire as RFC 8949 null (0xf6): {bytes:02x?}"
184        );
185        let decoded: Req = CborCodec
186            .decode(&bytes)
187            .expect("an unstripped null must decode, not error");
188        assert_eq!(decoded.required, "x");
189        assert_eq!(decoded.optional, None, "top-level null decodes to None");
190        assert_eq!(
191            decoded.nested,
192            Some(Inner {
193                maybe: None,
194                kept: "k".to_owned(),
195            }),
196            "nested null decodes to None, sibling field untouched"
197        );
198    }
199}