Skip to content

Commit cb77a0e

Browse files
committed
Encode MemTable as durable metadata in the FFI example logical codec
1 parent 516d20d commit cb77a0e

6 files changed

Lines changed: 203 additions & 81 deletions

File tree

‎docs/source/extension-guide/checklist.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,9 @@ publish. Each links to the page that explains it.
9191
derived from it is in use. This is the rule most likely to arrive as a
9292
bug report against your library. → {ref}`extension_sessions`
9393
- [ ] **Your production codec serializes durable metadata**, not a
94-
process-local token. The examples in this repository use tokens to make
95-
ownership observable; that is a demonstration, not a pattern.
94+
process-local token. The example logical codec in this repository does
95+
this; the example physical codec uses a token to make ownership
96+
observable, which is a demonstration, not a pattern.
9697
→ {ref}`extension_codec_durable_metadata`
9798
- [ ] **You have integration tests across a real FFI boundary.** The two
9899
example crates in this repository are the pattern: build the cdylib,

‎docs/source/extension-guide/codecs.md‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,15 +58,20 @@ Your payload has to be enough to rebuild the object somewhere your process is
5858
not. Write the metadata a fresh instance can be constructed from — a path, a
5959
connection string, a schema, the options the object was created with.
6060

61-
The example codecs in this repository do not do this, and it is worth knowing
62-
before copying them. They keep a process-local `HashMap` of live providers and
63-
encode an integer token into it: encoding inserts, decoding removes. That makes
64-
Rust type identity observable across three separately loaded libraries in one
65-
test, which is what the examples exist to show. It also means a decode consumes
66-
its token, so the same bytes cannot be decoded twice, one encoded plan cannot
67-
fan out to several readers, and a plan that never reaches a decoder keeps its
68-
provider alive for the life of the process. A real codec has none of those
69-
properties because it does not park the object anywhere.
61+
The logical codec in `examples/datafusion-ffi-example` is the pattern to copy.
62+
It encodes a `MemTable` as its schema and batches, one Arrow IPC stream per
63+
partition, and decodes by building a new `MemTable` from those streams. Nothing
64+
is kept between encode and decode, so the same bytes decode any number of times
65+
and an encoded plan can fan out to several readers.
66+
67+
The physical codec in the same example does not do this, and it is worth
68+
knowing before copying it. It keeps a process-local `HashMap` of live execution
69+
plans and encodes an integer token into it: encoding inserts, decoding removes.
70+
That makes Rust type identity observable across three separately loaded
71+
libraries in one test, which is what it exists to show. It also means a decode
72+
consumes its token, so the same bytes cannot be decoded twice, and a plan that
73+
never reaches a decoder stays alive for the life of the process. A real codec
74+
has none of those properties because it does not park the object anywhere.
7075

7176
(extension_codec_ids)=
7277

‎examples/datafusion-ffi-example/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ Separate shared libraries guarantee distinct DataFusion library markers. This ca
3131

3232
## Codec behavior
3333

34-
`MyLogicalExtensionCodec` serializes this example's in-memory table providers, and `MyPhysicalExtensionCodec` serializes provider-owned memory scans and opaque FFI wrappers around them. Both use documented, process-local, one-shot token registries. The registries make ownership and callback routing visible without pretending to be a portable format. They assume trusted in-process payloads and consume each token during decoding. A production provider should instead encode durable metadata from which its provider and plans can be reconstructed.
34+
`MyLogicalExtensionCodec` serializes this example's in-memory table providers as durable metadata: a `MemTable` is written as its schema and batches, one Arrow IPC stream per partition, and decoding builds a new `MemTable` from those streams. Nothing is retained between encode and decode, so one encoded plan can be decoded any number of times, in any process. This is the pattern a production provider should follow.
35+
36+
`MyPhysicalExtensionCodec` serializes provider-owned memory scans and opaque FFI wrappers around them through a documented, process-local, one-shot token registry. The registry makes ownership and callback routing visible without pretending to be a portable format. It assumes trusted in-process payloads and consumes each token during decoding.
3537

3638
Both codec getters take the `SessionContext` they are being installed on and pull the `TaskContextProvider` off it, so decode callbacks resolve session configuration and registered functions against the session that is running the query. Passing `require_udf_on_decode` to either constructor makes every decode call resolve a named scalar function out of that context, which is how the tests check where the registry came from.
3739

‎examples/datafusion-ffi-example/python/tests/_test_logical_extension_codec.py‎

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,13 @@ def test_installing_a_codec_cannot_hijack_an_earlier_codecs_objects():
203203
ctx = ctx.with_logical_extension_codec(later, codec_id="TOKENBBB")
204204
after = ctx.sql('SELECT "A" FROM numbers').logical_plan().to_bytes(ctx)
205205

206-
# Provider tokens are minted per encode, so the payloads differ in the
207-
# token id. What must not change is which codec claimed the provider.
206+
# The provider is encoded as its schema and batches, so re-encoding the
207+
# same table yields the same bytes. Which codec claimed it must not
208+
# change either.
208209
assert b"TOKENAAA" in after
209210
assert b"TOKENBBB" not in after
210211
assert later.table_provider_encode_calls() == 0
211-
assert len(before) == len(after)
212+
assert before == after
212213

213214

214215
def test_decode_dispatches_to_the_codec_that_encoded():
@@ -233,6 +234,39 @@ def test_decode_dispatches_to_the_codec_that_encoded():
233234
assert second.table_provider_decode_calls() == 0
234235

235236

237+
def test_one_encoded_plan_decodes_more_than_once():
238+
"""A table provider payload is durable metadata -- the table's schema
239+
and batches -- not a handle into the encoding process, so the same
240+
bytes can be decoded again and again, on the session that wrote them
241+
or on one that never saw the original provider.
242+
243+
Every decode rebuilds an equivalent table: the rows come back, and
244+
they are the rows the provider was created with.
245+
"""
246+
blob, owner = _encode_provider_plan("TOKENAAA")
247+
expected = [[0, 1, 2, 3]]
248+
249+
def rows(ctx: SessionContext) -> list[list[int]]:
250+
restored = LogicalPlan.from_bytes(ctx, blob)
251+
batches = ctx.create_dataframe_from_logical_plan(restored).collect()
252+
return [batch.column(0).to_pylist() for batch in batches]
253+
254+
same_session = SessionContext().with_logical_extension_codec(
255+
owner, codec_id="TOKENAAA"
256+
)
257+
assert rows(same_session) == expected
258+
assert rows(same_session) == expected
259+
260+
# A fresh session with a fresh codec instance has no access to anything
261+
# the encoding side might have kept; the bytes alone must suffice.
262+
elsewhere = SessionContext().with_logical_extension_codec(
263+
MyLogicalExtensionCodec(provider_prefix="TOKENAAA"), codec_id="TOKENAAA"
264+
)
265+
assert rows(elsewhere) == expected
266+
267+
assert owner.table_provider_decode_calls() == 2
268+
269+
236270
def test_decode_survives_a_different_install_order():
237271
"""Dispatch keys off codec identity, not chain position, so the
238272
decoding session may install the same codecs in any order.

‎examples/datafusion-ffi-example/src/logical_extension_codec.rs‎

Lines changed: 138 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18-
use std::collections::HashMap;
1918
use std::fmt;
20-
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
21-
use std::sync::{Arc, Mutex, OnceLock};
19+
use std::io::Cursor;
20+
use std::sync::Arc;
21+
use std::sync::atomic::{AtomicUsize, Ordering};
2222

2323
use arrow::datatypes::SchemaRef;
24+
use arrow::ipc::reader::StreamReader;
25+
use arrow::ipc::writer::StreamWriter;
26+
use arrow::record_batch::RecordBatch;
2427
use datafusion::catalog::MemTable;
2528
use datafusion::common::{DataFusionError, Result, TableReference};
2629
use datafusion::datasource::TableProvider;
@@ -34,35 +37,126 @@ use pyo3::types::PyCapsule;
3437

3538
use crate::required_udf::{TaskContextProbe, resolve_required_udf};
3639

37-
const TABLE_PROVIDER_TOKEN: &[u8] = b"DFPYEXTP";
38-
static NEXT_TABLE_PROVIDER_ID: AtomicU64 = AtomicU64::new(1);
39-
static TABLE_PROVIDERS: OnceLock<Mutex<HashMap<u64, Arc<dyn TableProvider>>>> = OnceLock::new();
40+
/// Default byte prefix stamped on every table provider this codec encodes.
41+
const TABLE_PROVIDER_PREFIX: &[u8] = b"DFPYEXTP";
4042

41-
/// Hands a provider to another library in this process by token.
43+
/// Format tag that follows the prefix. Bump it if the layout below changes.
44+
const MEM_TABLE_FORMAT: &[u8] = b"MEMTBL1";
45+
46+
/// Write a [`MemTable`] as durable metadata: its schema and every batch of
47+
/// every partition, so that a decoder anywhere can rebuild an equivalent
48+
/// table from the bytes alone.
4249
///
43-
/// Encoding inserts, decoding removes. Two consequences worth knowing before
44-
/// copying this:
50+
/// Layout, after the caller's provider prefix:
4551
///
46-
/// - **Decode consumes the token.** Decoding the same encoded bytes twice
47-
/// fails the second time with `Unknown ... table provider token`. That is
48-
/// fine here because every plan is encoded immediately before the single
49-
/// decode that consumes it, but it rules out anything that replays a stored
50-
/// plan, retries a decode, or fans one encoded plan out to several readers.
51-
/// - **An encode that is never decoded leaks.** Nothing expires entries, so a
52-
/// plan that fails to reach its decoder keeps its provider alive for the
53-
/// life of the process.
52+
/// ```text
53+
/// b"MEMTBL1" | u32 LE n_partitions | { u32 LE ipc_len | ipc stream }*
54+
/// ```
5455
///
55-
/// Both are acceptable for an example whose job is to show that Rust type
56-
/// identity survives a trip through two other libraries. Neither is acceptable
57-
/// in a real codec, which should encode metadata sufficient to rebuild the
58-
/// provider rather than parking the object here.
59-
fn table_providers() -> &'static Mutex<HashMap<u64, Arc<dyn TableProvider>>> {
60-
TABLE_PROVIDERS.get_or_init(|| Mutex::new(HashMap::new()))
56+
/// Each partition is one Arrow IPC stream. The stream carries the schema, so
57+
/// the decoder never has to trust a schema handed to it out of band.
58+
fn encode_mem_table(table: &MemTable, buf: &mut Vec<u8>) -> Result<()> {
59+
let schema = table.schema();
60+
buf.extend_from_slice(MEM_TABLE_FORMAT);
61+
buf.extend_from_slice(&length_prefix(table.batches.len())?);
62+
63+
for partition in &table.batches {
64+
// `MemTable` guards each partition with a tokio `RwLock`. This encode
65+
// runs on a tokio worker thread, where `blocking_read` panics, so
66+
// take the lock only if it is free. A partition that is mid-insert
67+
// is reported rather than waited for.
68+
let batches = partition.try_read().map_err(|_| {
69+
DataFusionError::Internal(
70+
"datafusion-ffi-example cannot encode a MemTable while a partition is locked"
71+
.to_string(),
72+
)
73+
})?;
74+
75+
let mut ipc = Vec::new();
76+
let mut writer = StreamWriter::try_new(&mut ipc, schema.as_ref())?;
77+
for batch in batches.iter() {
78+
writer.write(batch)?;
79+
}
80+
writer.finish()?;
81+
drop(writer);
82+
83+
buf.extend_from_slice(&length_prefix(ipc.len())?);
84+
buf.extend_from_slice(&ipc);
85+
}
86+
Ok(())
6187
}
6288

63-
fn token_id(buf: &[u8], prefix: &[u8]) -> Option<u64> {
64-
let id: [u8; 8] = buf.strip_prefix(prefix)?.try_into().ok()?;
65-
Some(u64::from_le_bytes(id))
89+
/// Rebuild a [`MemTable`] from bytes written by [`encode_mem_table`].
90+
///
91+
/// The table's schema is the one carried inside the IPC streams, not the
92+
/// `schema` argument DataFusion passes to `try_decode_table_provider`. A
93+
/// payload that does not describe itself consistently is rejected here
94+
/// instead of producing a table whose batches disagree with its schema.
95+
fn decode_mem_table(payload: &[u8]) -> Result<MemTable> {
96+
let mut rest = payload.strip_prefix(MEM_TABLE_FORMAT).ok_or_else(|| {
97+
DataFusionError::Internal(
98+
"datafusion-ffi-example table provider payload has an unknown format tag".to_string(),
99+
)
100+
})?;
101+
102+
let n_partitions = read_length_prefix(&mut rest)?;
103+
let mut schema: Option<SchemaRef> = None;
104+
let mut partitions: Vec<Vec<RecordBatch>> = Vec::with_capacity(n_partitions);
105+
106+
for _ in 0..n_partitions {
107+
let ipc_len = read_length_prefix(&mut rest)?;
108+
if rest.len() < ipc_len {
109+
return Err(DataFusionError::Internal(
110+
"datafusion-ffi-example table provider payload is truncated".to_string(),
111+
));
112+
}
113+
let (ipc, tail) = rest.split_at(ipc_len);
114+
rest = tail;
115+
116+
let reader = StreamReader::try_new(Cursor::new(ipc), None)?;
117+
let ipc_schema = reader.schema();
118+
match &schema {
119+
None => schema = Some(ipc_schema),
120+
Some(first) if *first != ipc_schema => {
121+
return Err(DataFusionError::Internal(
122+
"datafusion-ffi-example table provider partitions disagree on schema"
123+
.to_string(),
124+
));
125+
}
126+
Some(_) => {}
127+
}
128+
partitions.push(reader.collect::<std::result::Result<Vec<_>, _>>()?);
129+
}
130+
131+
if !rest.is_empty() {
132+
return Err(DataFusionError::Internal(
133+
"datafusion-ffi-example table provider payload has trailing bytes".to_string(),
134+
));
135+
}
136+
137+
let schema = schema.ok_or_else(|| {
138+
DataFusionError::Internal(
139+
"datafusion-ffi-example table provider payload has no partitions".to_string(),
140+
)
141+
})?;
142+
MemTable::try_new(schema, partitions)
143+
}
144+
145+
fn length_prefix(len: usize) -> Result<[u8; 4]> {
146+
u32::try_from(len)
147+
.map(u32::to_le_bytes)
148+
.map_err(|_| DataFusionError::Internal(format!("length {len} does not fit in u32")))
149+
}
150+
151+
fn read_length_prefix(rest: &mut &[u8]) -> Result<usize> {
152+
let (head, tail) = rest.split_at_checked(4).ok_or_else(|| {
153+
DataFusionError::Internal(
154+
"datafusion-ffi-example table provider payload is truncated".to_string(),
155+
)
156+
})?;
157+
*rest = tail;
158+
let bytes: [u8; 4] = head.try_into().expect("split_at_checked returned 4 bytes");
159+
Ok(u32::from_le_bytes(bytes) as usize)
66160
}
67161

68162
#[derive(Debug, Default)]
@@ -76,23 +170,20 @@ pub(crate) struct CallCounters {
76170

77171
/// Example codec for objects owned by this extension library.
78172
///
79-
/// The table-provider token registry is intentionally process-local. It is a compact
80-
/// example of preserving Rust type identity across three loaded libraries, not a
81-
/// network serialization format. Production libraries should encode reconstructible
82-
/// provider metadata rather than retaining objects in a global registry.
83-
///
84-
/// See [`table_providers`] for the token lifecycle, which is narrower than it
85-
/// looks: a decode consumes its token, so the same encoded plan cannot be
86-
/// decoded twice.
173+
/// Table providers are encoded as durable metadata, see [`encode_mem_table`].
174+
/// Nothing is retained between encode and decode, so the same bytes decode
175+
/// any number of times, in any process, and an encoded plan that never
176+
/// reaches a decoder costs nothing.
87177
struct CountingLogicalExtensionCodec {
88178
inner: DefaultLogicalExtensionCodec,
89179
counters: Arc<CallCounters>,
90180
/// Scalar function every table-provider decode must resolve from the
91181
/// `TaskContext` it is handed. See [`crate::required_udf`].
92182
required_udf: Option<String>,
93-
/// Byte prefix identifying providers this codec owns. Distinct tokens let a
94-
/// test install several instances and observe which one the chain picks.
95-
token: Arc<[u8]>,
183+
/// Byte prefix identifying providers this codec owns. Distinct prefixes
184+
/// let a test install several instances and observe which one the chain
185+
/// picks.
186+
provider_prefix: Arc<[u8]>,
96187
}
97188

98189
impl fmt::Debug for CountingLogicalExtensionCodec {
@@ -127,19 +218,11 @@ impl LogicalExtensionCodec for CountingLogicalExtensionCodec {
127218
ctx: &TaskContext,
128219
) -> Result<Arc<dyn TableProvider>> {
129220
resolve_required_udf(self.required_udf.as_deref(), ctx, &self.counters.task_ctx)?;
130-
if let Some(id) = token_id(buf, &self.token) {
221+
if let Some(payload) = buf.strip_prefix(self.provider_prefix.as_ref()) {
131222
self.counters
132223
.decode_table_provider
133224
.fetch_add(1, Ordering::SeqCst);
134-
return table_providers()
135-
.lock()
136-
.map_err(|err| DataFusionError::Internal(err.to_string()))?
137-
.remove(&id)
138-
.ok_or_else(|| {
139-
DataFusionError::Internal(format!(
140-
"Unknown datafusion-ffi-example table provider token {id}"
141-
))
142-
});
225+
return Ok(Arc::new(decode_mem_table(payload)?));
143226
}
144227
self.inner
145228
.try_decode_table_provider(buf, table_ref, schema, ctx)
@@ -151,18 +234,12 @@ impl LogicalExtensionCodec for CountingLogicalExtensionCodec {
151234
node: Arc<dyn TableProvider>,
152235
buf: &mut Vec<u8>,
153236
) -> Result<()> {
154-
if node.downcast_ref::<MemTable>().is_some() {
237+
if let Some(table) = node.downcast_ref::<MemTable>() {
155238
self.counters
156239
.encode_table_provider
157240
.fetch_add(1, Ordering::SeqCst);
158-
let id = NEXT_TABLE_PROVIDER_ID.fetch_add(1, Ordering::SeqCst);
159-
table_providers()
160-
.lock()
161-
.map_err(|err| DataFusionError::Internal(err.to_string()))?
162-
.insert(id, node);
163-
buf.extend_from_slice(&self.token);
164-
buf.extend_from_slice(&id.to_le_bytes());
165-
return Ok(());
241+
buf.extend_from_slice(&self.provider_prefix);
242+
return encode_mem_table(table, buf);
166243
}
167244
self.inner.try_encode_table_provider(table_ref, node, buf)
168245
}
@@ -188,7 +265,7 @@ impl LogicalExtensionCodec for CountingLogicalExtensionCodec {
188265
pub(crate) struct MyLogicalExtensionCodec {
189266
counters: Arc<CallCounters>,
190267
required_udf: Option<String>,
191-
token: Arc<[u8]>,
268+
provider_prefix: Arc<[u8]>,
192269
}
193270

194271
#[pymethods]
@@ -200,7 +277,7 @@ impl MyLogicalExtensionCodec {
200277
/// unset for the ordinary behaviour; set it to observe *which* session's
201278
/// registry the FFI decode callback actually receives.
202279
///
203-
/// `provider_prefix` overrides [`TABLE_PROVIDER_TOKEN`], the byte prefix
280+
/// `provider_prefix` overrides [`TABLE_PROVIDER_PREFIX`], the byte prefix
204281
/// stamped on encoded table providers. Two instances built with different
205282
/// prefixes each own a disjoint slice of the wire format, which is what
206283
/// lets a test install both and tell from the decoded bytes which one the
@@ -211,8 +288,8 @@ impl MyLogicalExtensionCodec {
211288
Self {
212289
counters: Arc::new(CallCounters::default()),
213290
required_udf: require_udf_on_decode,
214-
token: provider_prefix.map_or_else(
215-
|| Arc::from(TABLE_PROVIDER_TOKEN),
291+
provider_prefix: provider_prefix.map_or_else(
292+
|| Arc::from(TABLE_PROVIDER_PREFIX),
216293
|prefix| Arc::from(prefix.as_bytes()),
217294
),
218295
}
@@ -259,7 +336,7 @@ impl MyLogicalExtensionCodec {
259336
inner: DefaultLogicalExtensionCodec {},
260337
counters: Arc::clone(&self.counters),
261338
required_udf: self.required_udf.clone(),
262-
token: Arc::clone(&self.token),
339+
provider_prefix: Arc::clone(&self.provider_prefix),
263340
});
264341

265342
let runtime = get_tokio_runtime().handle().clone();

0 commit comments

Comments
 (0)