Skip to content

Commit 951e41d

Browse files
[C++] Add borrowing ingest_proto_records overload
Signed-off-by: Zlata Stefanovic <zlata.stefanovic@databricks.com>
1 parent 07ce9d5 commit 951e41d

11 files changed

Lines changed: 416 additions & 39 deletions

File tree

cpp/CLAUDE.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,13 @@ Public API is everything under `include/zerobus/`:
177177
- Every FFI crossing serializes data; prefer the batch APIs
178178
(`ingest_proto_records`, `ingest_json_records`) over per-record calls in hot
179179
paths.
180+
- Neither batch API copies payloads: `detail/proto_batch.hpp` builds the FFI's
181+
pointer/length arrays from pointers into the caller's buffers, and
182+
`tests/proto_batch_test.cpp` asserts that by pointer identity (a copying
183+
regression would still behave correctly). `ingest_proto_records` also takes
184+
`const ProtoRecordView*` + count, for records held outside a
185+
`vector<vector<uint8_t>>`. JSON has no such overload: its FFI needs
186+
NUL-terminated `const char*`, so `std::string` is already the zero-copy shape.
180187
- Ingestion is asynchronous: `ingest_*` queues and returns. Never wait per
181188
record (`wait_for_offset`/`flush` in the loop); flush once at the end or flush
182189
periodically. Examples and doc comments must follow this pattern.

cpp/NEXT_CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
### New Features and Improvements
66

7+
- Added a borrowing overload of `Stream::ingest_proto_records()`, taking
8+
`const ProtoRecordView*` and a count. Callers whose encoded records already
9+
live elsewhere (an arena, a ring buffer, their own record type) no longer have
10+
to copy every payload into a `std::vector<std::vector<std::uint8_t>>` just to
11+
hand the batch over. `zerobus::ProtoRecordView` is a non-owning `{data, size}`
12+
pair whose bytes must stay valid until the call returns. Existing calls are
13+
unaffected.
14+
715
### Bug Fixes
816

917
- Fixed a use-after-free in which a custom `HeadersProvider` could be destroyed
@@ -25,3 +33,7 @@
2533
### Deprecations
2634

2735
### API Changes
36+
37+
- New: `zerobus::ProtoRecordView` (in `zerobus/record.hpp`) and
38+
`Stream::ingest_proto_records(const ProtoRecordView*, std::size_t)`. Additive
39+
only — no existing signature changed.

cpp/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,27 @@ stream.ingest_proto_records(batch);
229229
stream.flush();
230230
```
231231

232+
If your encoded records already live somewhere other than a
233+
`std::vector<std::vector<std::uint8_t>>` — one contiguous arena, a ring buffer,
234+
your own record type — describe them with `ProtoRecordView` instead of copying
235+
each payload into that container first:
236+
237+
```cpp
238+
// `arena` holds the encoded records back to back; `spans` records where each
239+
// one starts and how long it is.
240+
std::vector<zerobus::ProtoRecordView> views;
241+
for (const auto& span : spans) {
242+
views.push_back({arena.data() + span.offset, span.size});
243+
}
244+
stream.ingest_proto_records(views.data(), views.size());
245+
stream.flush();
246+
```
247+
248+
A `ProtoRecordView` borrows: the bytes it points at must stay valid until the
249+
ingest call returns (the core copies them before it does). Build the views only
250+
once the buffer they point into has stopped growing — a `push_back` that
251+
reallocates invalidates every pointer taken from it earlier.
252+
232253
### Arrow Flight ingestion (Beta)
233254

234255
Stream Arrow record batches instead of proto/JSON records. Create the stream
@@ -328,6 +349,7 @@ canonical version):
328349
| `zerobus::StreamOptions` / `zerobus::ArrowStreamOptions` | Stream configuration |
329350
| `zerobus::ZerobusException` | Thrown on any failure; `is_retryable()` |
330351
| `zerobus::UnackedRecord` | An unacknowledged record recovered from a failed stream |
352+
| `zerobus::ProtoRecordView` | Non-owning `{data, size}` view of a proto record, for batch ingestion without copies |
331353
332354
Key `Stream` methods: `ingest_proto_record`, `ingest_json_record`,
333355
`ingest_proto_records`, `ingest_json_records`, `wait_for_offset`, `flush`,

cpp/examples/proto/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ ingestion into Databricks Delta tables using the Zerobus C++ SDK.
1313
- [Batch Example](#batch-example)
1414
- [Running the Example](#running-the-example-1)
1515
- [Code Highlights](#code-highlights-1)
16+
- [Batching Records You Already Hold](#batching-records-you-already-hold)
1617
- [Adapting for Your Custom Table](#adapting-for-your-custom-table)
1718

1819
## Overview
@@ -165,6 +166,7 @@ zerobus::Stream stream =
165166
```
166167
Batch of 3 records queued; batch offset ID: 0
167168
Batch acknowledged at offset ID: 0
169+
Arena batch of 2 records queued; batch offset ID: 1
168170
Stream closed successfully.
169171
```
170172

@@ -195,6 +197,35 @@ if (batch_offset >= 0) {
195197
In a hot path you would queue **many** batches and `flush()` once, rather than
196198
waiting after each batch.
197199

200+
### Batching Records You Already Hold
201+
202+
`encode_json()` hands back one `std::vector<std::uint8_t>` per record, so the
203+
batch above is a natural vector-of-vectors. If your encoded records live
204+
somewhere else — the example packs a second batch into one contiguous arena —
205+
use `ProtoRecordView` instead of copying each payload into that container just
206+
to pass it:
207+
208+
```cpp
209+
std::vector<zerobus::ProtoRecordView> views;
210+
for (const Span& span : spans) { // spans index into `arena`
211+
views.push_back({arena.data() + span.offset, span.size});
212+
}
213+
214+
const std::int64_t offset =
215+
stream.ingest_proto_records(views.data(), views.size());
216+
```
217+
218+
A view borrows, so two rules apply:
219+
- **The bytes must outlive the call.** The core copies them before
220+
`ingest_proto_records()` returns, and nothing holds the pointer afterwards.
221+
- **Take the pointers last.** Build the views only once the buffer they point
222+
into has stopped growing; an `insert`/`push_back` that reallocates invalidates
223+
every pointer taken from it earlier.
224+
225+
`{nullptr, 0}` is a valid empty record. A null pointer with a non-zero size is
226+
rejected with a `ZerobusException` naming the record's index, rather than
227+
dereferenced inside the core.
228+
198229
## Adapting for Your Custom Table
199230

200231
Because the schema is fetched from Unity Catalog at runtime, adapting to your own

cpp/examples/proto/batch.cpp

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
// unit. The call returns a single logical offset assigned to the whole batch;
1212
// waiting on that one offset confirms the entire batch.
1313
//
14+
// Two ways to hand a batch over are shown: a vector of encoded records, and
15+
// ProtoRecordViews borrowing records that already live in a caller-owned arena.
16+
//
1417
// Configuration — every connection setting, plus the Unity Catalog table
1518
// metadata JSON, is read from the environment. Export these before running (see
1619
// ../README.md for what each one is and the full copy-pasteable block,
@@ -26,6 +29,7 @@
2629
// TIMESTAMP)
2730

2831
#include <chrono>
32+
#include <cstddef>
2933
#include <cstdint>
3034
#include <cstdlib>
3135
#include <iostream>
@@ -122,7 +126,46 @@ int main() {
122126
std::cout << "Batch acknowledged at offset ID: " << batch_offset << "\n";
123127
}
124128

125-
// 6. flush() drains anything still pending, then close at a controlled
129+
// 6. A second batch, for records the SDK does not own.
130+
//
131+
// encode_json() returns a vector per record, so the batch above was
132+
// already a natural vector-of-vectors. When your records live elsewhere
133+
// — here, packed into one arena — describe them with ProtoRecordView
134+
// instead of copying each payload into that container to pass it.
135+
const std::vector<std::string> more_orders = {
136+
make_order_json(4, "Dan Brown", "Laptop Stand", 1, 34.50, "pending",
137+
now),
138+
make_order_json(5, "Erin Page", "HD Webcam", 2, 59.99, "pending", now),
139+
};
140+
141+
// Where each encoded record starts in the arena, and how long it is.
142+
struct Span {
143+
std::size_t offset;
144+
std::size_t size;
145+
};
146+
std::vector<std::uint8_t> arena;
147+
std::vector<Span> spans;
148+
for (const std::string& order : more_orders) {
149+
const std::vector<std::uint8_t> encoded = schema.encode_json(order);
150+
spans.push_back({arena.size(), encoded.size()});
151+
arena.insert(arena.end(), encoded.begin(), encoded.end());
152+
}
153+
154+
// Take the pointers only now the arena has stopped growing: a reallocating
155+
// insert invalidates any taken earlier.
156+
std::vector<zerobus::ProtoRecordView> views;
157+
views.reserve(spans.size());
158+
for (const Span& span : spans) {
159+
views.push_back({arena.data() + span.offset, span.size});
160+
}
161+
162+
// arena must outlive this call — the views only borrow it.
163+
const std::int64_t arena_offset =
164+
stream.ingest_proto_records(views.data(), views.size());
165+
std::cout << "Arena batch of " << views.size()
166+
<< " records queued; batch offset ID: " << arena_offset << "\n";
167+
168+
// 7. flush() drains anything still pending, then close at a controlled
126169
// point.
127170
stream.flush();
128171
stream.close();

cpp/include/zerobus/record.hpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
#ifndef ZEROBUS_RECORD_HPP
22
#define ZEROBUS_RECORD_HPP
33

4+
#include <cstddef>
45
#include <cstdint>
56
#include <string>
67
#include <utility>
78
#include <vector>
89

910
namespace zerobus {
1011

12+
/// A non-owning view of one protobuf-encoded record, for the borrowing
13+
/// `Stream::ingest_proto_records()` overload.
14+
///
15+
/// The bytes must stay valid until that call returns; the core copies them
16+
/// before it does. `{nullptr, 0}` is a valid empty record.
17+
struct ProtoRecordView {
18+
const std::uint8_t* data = nullptr;
19+
std::size_t size = 0;
20+
};
21+
1122
/// A record recovered from a stream that was closed or failed before all
1223
/// records were acknowledged. Returned by `Stream::get_unacked_records()`.
1324
///

cpp/include/zerobus/stream.hpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ class Stream {
7878
std::int64_t ingest_proto_records(
7979
const std::vector<std::vector<std::uint8_t>>& records);
8080

81+
/// @overload
82+
/// Ingest a batch of borrowed protobuf records, skipping the copy into a
83+
/// vector of vectors when the encoded records already live elsewhere (an
84+
/// arena, a ring buffer, your own record type).
85+
///
86+
/// @param records Pointer to @p num_records views, each borrowing bytes that
87+
/// must stay valid until this call returns.
88+
/// @param num_records Number of views in @p records.
89+
/// @return The single logical offset assigned to the whole batch, or -1 if
90+
/// @p num_records is 0 (a no-op).
91+
/// @throws ZerobusException if @p records is null with a non-zero
92+
/// @p num_records, if a view has a null pointer with a non-zero size,
93+
/// or if the stream is closed or ingestion fails.
94+
std::int64_t ingest_proto_records(const ProtoRecordView* records,
95+
std::size_t num_records);
96+
8197
/// Ingest a batch of JSON records, blocking until they are queued.
8298
///
8399
/// @param records The records, each a UTF-8 JSON string.

cpp/src/detail/proto_batch.hpp

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#ifndef ZEROBUS_DETAIL_PROTO_BATCH_HPP
2+
#define ZEROBUS_DETAIL_PROTO_BATCH_HPP
3+
4+
// Builds the parallel pointer/length arrays zerobus_stream_ingest_proto_records
5+
// expects, without copying payloads.
6+
//
7+
// Kept out of stream.cpp so tests can reach it: a Stream needs a live server,
8+
// but the invariants here — aliasing the caller's bytes, never handing the FFI
9+
// a null payload — are testable alone (tests/proto_batch_test.cpp). Free of
10+
// zerobus.h; the JSON equivalent stays in stream.cpp, where checked_c_str is.
11+
12+
#include <cstddef>
13+
#include <cstdint>
14+
#include <string>
15+
#include <vector>
16+
17+
#include "zerobus/error.hpp"
18+
#include "zerobus/record.hpp"
19+
20+
namespace zerobus {
21+
namespace detail {
22+
23+
// An empty payload still crosses the FFI as a non-null pointer with length 0,
24+
// rather than nullptr or a dangling data() result.
25+
inline constexpr std::uint8_t kEmptyPayloadSentinel = 0;
26+
27+
inline const std::uint8_t* ptr_or_sentinel(
28+
const std::vector<std::uint8_t>& bytes) {
29+
return bytes.empty() ? &kEmptyPayloadSentinel : bytes.data();
30+
}
31+
32+
// Same sentinel for the raw form, so {nullptr, 0} is a valid empty record.
33+
inline const std::uint8_t* ptr_or_sentinel(const std::uint8_t* data,
34+
std::size_t len) {
35+
return len == 0 ? &kEmptyPayloadSentinel : data;
36+
}
37+
38+
// The pointers alias the caller's record bytes, so a ProtoBatchView must not
39+
// outlive the records it was built from.
40+
struct ProtoBatchView {
41+
std::vector<const std::uint8_t*> ptrs;
42+
std::vector<std::uintptr_t> lens;
43+
};
44+
45+
inline ProtoBatchView make_proto_batch(
46+
const std::vector<std::vector<std::uint8_t>>& records) {
47+
ProtoBatchView v;
48+
v.ptrs.reserve(records.size());
49+
v.lens.reserve(records.size());
50+
for (const auto& r : records) {
51+
v.ptrs.push_back(ptr_or_sentinel(r));
52+
v.lens.push_back(r.size());
53+
}
54+
return v;
55+
}
56+
57+
// Borrowing form. Its own loop rather than materialising the views into a
58+
// vector<vector<uint8_t>> and delegating above: those copies are the cost it
59+
// exists to avoid.
60+
inline ProtoBatchView make_proto_batch(const ProtoRecordView* records,
61+
std::size_t num_records) {
62+
if (records == nullptr && num_records != 0) {
63+
throw ZerobusException(
64+
"ingest_proto_records called with a null record array and a non-zero "
65+
"record count",
66+
false);
67+
}
68+
ProtoBatchView v;
69+
v.ptrs.reserve(num_records);
70+
v.lens.reserve(num_records);
71+
for (std::size_t i = 0; i < num_records; ++i) {
72+
// The core would dereference a sized null payload. Name the index so the
73+
// offending record is identifiable in a large batch.
74+
if (records[i].data == nullptr && records[i].size != 0) {
75+
throw ZerobusException(
76+
"proto record at index " + std::to_string(i) +
77+
" has a null data pointer with a non-zero size",
78+
false);
79+
}
80+
v.ptrs.push_back(ptr_or_sentinel(records[i].data, records[i].size));
81+
v.lens.push_back(records[i].size);
82+
}
83+
return v;
84+
}
85+
86+
} // namespace detail
87+
} // namespace zerobus
88+
89+
#endif // ZEROBUS_DETAIL_PROTO_BATCH_HPP

0 commit comments

Comments
 (0)