diff --git a/mdbx/batch.go b/mdbx/batch.go new file mode 100644 index 0000000..87337f8 --- /dev/null +++ b/mdbx/batch.go @@ -0,0 +1,173 @@ +package mdbx + +/* +#include +#include "mdbxgo.h" +*/ +import "C" +import "unsafe" + +// GetBatchBuffer is a reusable buffer of MDBX_val (key, value) pairs for +// Cursor.GetBatch. It lives in C memory so cgo does not scan it for Go +// pointers on every call. Reusable across cursors and transactions; not safe +// for concurrent use. Close releases the C allocation. +type GetBatchBuffer struct { + ptr *C.MDBX_val + size int + n int // pairs filled by the most recent GetBatch +} + +// maxBatchPairs bounds GetBatchBuffer sizes to 1Mi pairs (a 32MiB buffer on +// 64-bit). It keeps the calloc byte count (2 * numPairs * sizeof(MDBX_val)) +// at or below 32MiB even with 32-bit size_t/int, so neither multiplication +// can overflow on any platform. +const maxBatchPairs = 1024 * 1024 + +// NewGetBatchBuffer allocates a buffer holding numPairs key/value pairs +// (64-512 is a reasonable range). It panics if numPairs is outside +// [1, maxBatchPairs] or the allocation fails. +func NewGetBatchBuffer(numPairs int) *GetBatchBuffer { + if numPairs < 1 || numPairs > maxBatchPairs { + panic("mdbx: NewGetBatchBuffer: number of pairs out of range") + } + p := C.calloc(C.size_t(2*numPairs), C.size_t(unsafe.Sizeof(C.MDBX_val{}))) + if p == nil { + panic("mdbx: NewGetBatchBuffer: OOM") + } + return &GetBatchBuffer{ptr: (*C.MDBX_val)(p), size: numPairs} +} + +// Close releases the C allocation. No-op if already closed. Slices handed out +// by Key/Val point into that allocation's contents and must not be used after. +func (b *GetBatchBuffer) Close() { + if b.ptr != nil { + C.free(unsafe.Pointer(b.ptr)) + b.ptr = nil + b.size = 0 + b.n = 0 // there is no last fill to report anymore + } +} + +// Cap returns the buffer capacity in key/value pairs. +func (b *GetBatchBuffer) Cap() int { return b.size } + +func (b *GetBatchBuffer) at(i int) *C.MDBX_val { + if b.ptr == nil { + panic("mdbx: GetBatchBuffer: use after Close") + } + // Bound by the last fill count, not the capacity: entries past b.n hold + // stale pointers from earlier fills (possibly of an already-ended txn). + if i < 0 || i >= 2*b.n { + panic("mdbx: GetBatchBuffer: index out of range of the last GetBatch fill") + } + return (*C.MDBX_val)(unsafe.Add(unsafe.Pointer(b.ptr), uintptr(i)*unsafe.Sizeof(C.MDBX_val{}))) +} + +// Key returns the i-th key of the most recent GetBatch (i < its pair count). +// Zero-copy view: read-only, invalid once the txn ends, the buffer is +// refilled or Closed, or (in a write txn) a later Put/Del moves the page. +// +// For dup-only ops (FirstDup, LastDup, NextDup, PrevDup) mdbx_cursor_get +// promises the value only, so treat the key as unspecified — the caller +// already knows it from the Get that positioned the cursor. Cursor.Get +// reports nil for it. +func (b *GetBatchBuffer) Key(i int) []byte { return castToBytes(b.at(2 * i)) } + +// Val returns the i-th value of the most recent GetBatch. See Key. +func (b *GetBatchBuffer) Val(i int) []byte { return castToBytes(b.at(2*i + 1)) } + +// batchFirstOpOK and batchNextOpOK report whether an op can be driven without +// an input key/value, mirroring the start_op/turn_op sets libmdbx accepts for +// mdbx_cursor_scan (mdbx.h). Allow-lists, so an unknown or out-of-range op +// value is rejected too. A positioning op is a start op only: GetCurrent as +// opNext would restream the same record until the buffer filled, First would +// rewind on every step. +func batchFirstOpOK(op uint) bool { + switch op { + case First, FirstDup, Last, LastDup, GetCurrent, GetMultiple: + return true + default: + // Continuing a scan hands the previous call's opNext back as opFirst. + return batchNextOpOK(op) + } +} + +func batchNextOpOK(op uint) bool { + switch op { + case Next, NextDup, NextNoDup, NextMultiple, Prev, PrevDup, PrevNoDup, PrevMultiple: + return true + default: + return false + } +} + +// GetBatch fetches up to buf.Cap() key/value pairs in one cgo call: the first +// record with opFirst, the rest with opNext. It amortizes cgo overhead over +// large scans. +// +// There is no way to pass a search key or value, so opFirst and opNext are +// restricted to the ops mdbx_cursor_get answers without one — the same +// start_op/turn_op sets libmdbx documents for mdbx_cursor_scan: +// +// opFirst: First, FirstDup, Last, LastDup, GetCurrent, GetMultiple — or any +// opNext value, which is how a scan continues past the first batch +// opNext: Next, NextDup, NextNoDup, NextMultiple, Prev, PrevDup, PrevNoDup, PrevMultiple +// +// Anything else (Set, SetKey, SetRange, GetBoth, GetBothRange, the bound and +// KeyTo/PairTo seeks) is rejected with EINVAL rather than searched for with an +// empty key, which reads as a plausible wrong answer: Set matches nothing and +// looks like a clean EOF, SetRange silently rewinds to the first key. For a +// ranged scan, position the cursor with Get and batch with (GetCurrent, Next). +// +// The *_MULTIPLE ops work but have page granularity: each stored pair is +// (key, packed page of fixed-size values), so n counts pages, not records. +// +// n is the number of pairs stored (read via buf.Key/Val). The first n pairs +// are valid even when err != nil (the error came from the step after them). +// eof is true when iteration was exhausted before the buffer filled; +// otherwise continue with c.GetBatch(buf, opNext, opNext). +// +// buf := mdbx.NewGetBatchBuffer(256) +// defer buf.Close() +// for opFirst := uint(mdbx.First); ; opFirst = mdbx.Next { +// n, eof, err := cur.GetBatch(buf, opFirst, mdbx.Next) +// for i := 0; i < n; i++ { +// handle(buf.Key(i), buf.Val(i)) +// } +// if err != nil { +// return err +// } +// if eof { +// break +// } +// } +func (c *Cursor) GetBatch(buf *GetBatchBuffer, opFirst, opNext uint) (n int, eof bool, err error) { + if buf != nil { + // An errored call must not leave the buffer reporting entries from a + // previous fill (possibly views of an already-ended transaction). + buf.n = 0 + } + if c._c == nil || buf == nil || buf.ptr == nil || buf.size <= 0 { + return 0, false, operrno("mdbx_cursor_get", C.MDBX_EINVAL) + } + if !batchFirstOpOK(opFirst) || !batchNextOpOK(opNext) { + return 0, false, operrno("mdbx_cursor_get", C.MDBX_EINVAL) + } + r := C.mdbxgo_cursor_get_batch( + c._c, buf.ptr, C.size_t(buf.size), + C.MDBX_cursor_op(opFirst), C.MDBX_cursor_op(opNext), + ) + n = int(r.val) + buf.n = n + switch r.err { + case success, C.MDBX_RESULT_TRUE: + // RESULT_TRUE (e.g. a lower/upper-bound reposition) is success with a + // valid last pair, same as SUCCESS: buffer filled, not at EOF. + return n, false, nil + case C.MDBX_NOTFOUND: + return n, true, nil + default: + // A mid-batch error may still have produced the first n valid pairs. + return n, false, operrno("mdbx_cursor_get", r.err) + } +} diff --git a/mdbx/batch_test.go b/mdbx/batch_test.go new file mode 100644 index 0000000..3990049 --- /dev/null +++ b/mdbx/batch_test.go @@ -0,0 +1,528 @@ +package mdbx + +import ( + "bytes" + "fmt" + "testing" +) + +func fillBatchDB(tb testing.TB, env *Env, name string, numItems int) DBI { + tb.Helper() + var db DBI + err := env.Update(func(txn *Txn) (err error) { + db, err = txn.OpenDBISimple(name, Create) + if err != nil { + return err + } + for i := range numItems { + k := fmt.Appendf(nil, "key-%08d", i) + v := fmt.Appendf(nil, "val-%08d", i) + if err := txn.Put(db, k, v, Append); err != nil { + return err + } + } + return nil + }) + if err != nil { + tb.Fatal(err) + } + return db +} + +func TestCursor_GetBatch(t *testing.T) { + env, _ := setup(t) + const numItems = 1000 + db := fillBatchDB(t, env, "testbatch", numItems) + + buf := NewGetBatchBuffer(64) + defer buf.Close() + + err := env.View(func(txn *Txn) error { + cur, err := txn.OpenCursor(db) + if err != nil { + return err + } + defer cur.Close() + + seen := 0 + for opFirst := uint(First); ; opFirst = Next { + n, eof, err := cur.GetBatch(buf, opFirst, Next) + if err != nil { + return err + } + for i := range n { + wantK := fmt.Sprintf("key-%08d", seen) + wantV := fmt.Sprintf("val-%08d", seen) + if !bytes.Equal(buf.Key(i), []byte(wantK)) { + t.Fatalf("pair %d: key = %q, want %q", seen, buf.Key(i), wantK) + } + if !bytes.Equal(buf.Val(i), []byte(wantV)) { + t.Fatalf("pair %d: val = %q, want %q", seen, buf.Val(i), wantV) + } + seen++ + } + if eof { + break + } + } + if seen != numItems { + t.Errorf("scanned %d items, want %d", seen, numItems) + } + return nil + }) + if err != nil { + t.Error(err) + } +} + +func TestCursor_GetBatch_EmptyDB(t *testing.T) { + env, _ := setup(t) + db := fillBatchDB(t, env, "testbatchempty", 0) + + buf := NewGetBatchBuffer(8) + defer buf.Close() + + err := env.View(func(txn *Txn) error { + cur, err := txn.OpenCursor(db) + if err != nil { + return err + } + defer cur.Close() + + n, eof, err := cur.GetBatch(buf, First, Next) + if err != nil { + return err + } + if n != 0 || !eof { + t.Errorf("GetBatch on empty table: n=%d eof=%v, want 0/true", n, eof) + } + return nil + }) + if err != nil { + t.Error(err) + } +} + +func TestCursor_GetBatch_NoAllocs(t *testing.T) { + env, _ := setup(t) + db := fillBatchDB(t, env, "testbatchnoalloc", 100) + + buf := NewGetBatchBuffer(32) + defer buf.Close() + + err := env.View(func(txn *Txn) error { + cur, err := txn.OpenCursor(db) + if err != nil { + return err + } + defer cur.Close() + + assertNoAllocs(t, "Cursor.GetBatch", func() { _, _, _ = cur.GetBatch(buf, First, Next) }) + return nil + }) + if err != nil { + t.Error(err) + } +} + +// BenchmarkCursorScan compares a full forward scan performed with one cgo +// call per record (Get/Next) against batched retrieval (GetBatch). +func BenchmarkCursorScan(b *testing.B) { + env, _ := setup(b) + const numItems = 100_000 + db := fillBatchDB(b, env, "benchscan", numItems) + + txn, err := env.BeginTxn(nil, Readonly) + if err != nil { + b.Fatal(err) + } + defer txn.Abort() + cur, err := txn.OpenCursor(db) + if err != nil { + b.Fatal(err) + } + defer cur.Close() + + b.Run("Get_Next", func(b *testing.B) { + b.ResetTimer() + var total int + for range b.N { + count := 0 + for _, _, err := cur.Get(nil, nil, First); err == nil; _, _, err = cur.Get(nil, nil, Next) { + count++ + } + total = count + } + if total != numItems { + b.Fatalf("scanned %d, want %d", total, numItems) + } + b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/numItems, "ns/record") + }) + + for _, batch := range []int{64, 256, 1024} { + b.Run(fmt.Sprintf("GetBatch_%d", batch), func(b *testing.B) { + buf := NewGetBatchBuffer(batch) + defer buf.Close() + b.ResetTimer() + var total int + for range b.N { + count := 0 + for opFirst := uint(First); ; opFirst = Next { + n, eof, err := cur.GetBatch(buf, opFirst, Next) + if err != nil { + b.Fatal(err) + } + count += n + if eof { + break + } + } + total = count + } + if total != numItems { + b.Fatalf("scanned %d, want %d", total, numItems) + } + b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/numItems, "ns/record") + }) + } +} + +// Exact-fill boundary: the batch that fills completely on the table's last +// record reports eof=false; the follow-up call must return (0, true, nil). +func TestCursor_GetBatch_ExactFill(t *testing.T) { + env, _ := setup(t) + db := fillBatchDB(t, env, "testbatchexact", 128) + + buf := NewGetBatchBuffer(64) + defer buf.Close() + + err := env.View(func(txn *Txn) error { + cur, err := txn.OpenCursor(db) + if err != nil { + return err + } + defer cur.Close() + + for i, want := range []struct { + n int + eof bool + }{{64, false}, {64, false}, {0, true}} { + op := uint(Next) + if i == 0 { + op = First + } + n, eof, err := cur.GetBatch(buf, op, Next) + if err != nil { + return err + } + if n != want.n || eof != want.eof { + t.Errorf("batch %d: n=%d eof=%v, want n=%d eof=%v", i, n, eof, want.n, want.eof) + } + } + return nil + }) + if err != nil { + t.Error(err) + } +} + +// A failing step mid-batch must leave the pairs fetched before it valid. +func TestCursor_GetBatch_PartialOnError(t *testing.T) { + env, _ := setup(t) + db := fillBatchDB(t, env, "testbatchpartial", 10) + + buf := NewGetBatchBuffer(8) + defer buf.Close() + + err := env.View(func(txn *Txn) error { + cur, err := txn.OpenCursor(db) + if err != nil { + return err + } + defer cur.Close() + + // First succeeds; NextMultiple on a non-DupFixed table fails with + // MDBX_INCOMPATIBLE on the second step. + n, eof, err := cur.GetBatch(buf, First, NextMultiple) + if err == nil { + t.Fatal("GetBatch(First, NextMultiple): expected error, got nil") + } + if eof { + t.Error("eof must be false on error") + } + if n != 1 { + t.Fatalf("n = %d, want 1 pair fetched before the failing step", n) + } + if got := string(buf.Key(0)); got != "key-00000000" { + t.Errorf("Key(0) = %q, want key-00000000", got) + } + return nil + }) + if err != nil { + t.Error(err) + } +} + +// Ops that need an input key/value must be rejected instead of run against an +// empty one: Set would match nothing and report a clean EOF, SetRange would +// silently rewind the scan to the first key. +func TestCursor_GetBatch_RejectsInputOps(t *testing.T) { + env, _ := setup(t) + db := fillBatchDB(t, env, "testbatchops", 10) + + buf := NewGetBatchBuffer(8) + defer buf.Close() + + err := env.View(func(txn *Txn) error { + cur, err := txn.OpenCursor(db) + if err != nil { + return err + } + defer cur.Close() + + for _, tc := range []struct { + name string + first, next uint + }{ + {"Set as opFirst", Set, Next}, + {"SetKey as opFirst", SetKey, Next}, + {"SetRange as opFirst", SetRange, Next}, + {"GetBoth as opFirst", GetBoth, Next}, + {"GetBothRange as opFirst", GetBothRange, Next}, + {"SetLowerBound as opFirst", SetLowerBound, Next}, + {"SetUpperBound as opFirst", SetUpperBound, Next}, + {"KeyGreaterThan as opFirst", KeyGreaterThan, Next}, + {"PairLesserThan as opFirst", PairLesserThan, Next}, + {"Set as opNext", First, Set}, + {"SetRange as opNext", First, SetRange}, + {"GetCurrent as opNext", First, GetCurrent}, + {"GetMultiple as opNext", First, GetMultiple}, + {"out-of-range op", ^uint(0), Next}, + } { + n, eof, err := cur.GetBatch(buf, tc.first, tc.next) + assertRejected(t, tc.name, n, eof, err) + } + + // A rejected call must not leave the previous fill readable. + if _, _, err := cur.GetBatch(buf, First, Next); err != nil { + return err + } + if _, _, err := cur.GetBatch(buf, Set, Next); err == nil { + t.Fatal("GetBatch(Set, Next): expected EINVAL, got nil") + } + assertPanics(t, "Key(0) after a rejected GetBatch", func() { _ = buf.Key(0) }) + return nil + }) + if err != nil { + t.Error(err) + } +} + +// Reverse scans use the same machinery via (Last, Prev). +func TestCursor_GetBatch_Reverse(t *testing.T) { + env, _ := setup(t) + const numItems = 100 + db := fillBatchDB(t, env, "testbatchreverse", numItems) + + buf := NewGetBatchBuffer(16) + defer buf.Close() + + err := env.View(func(txn *Txn) error { + cur, err := txn.OpenCursor(db) + if err != nil { + return err + } + defer cur.Close() + + seen := 0 + for opFirst := uint(Last); ; opFirst = Prev { + n, eof, err := cur.GetBatch(buf, opFirst, Prev) + if err != nil { + return err + } + for i := range n { + want := fmt.Sprintf("key-%08d", numItems-1-seen) + if got := string(buf.Key(i)); got != want { + t.Fatalf("pair %d: key = %q, want %q", seen, got, want) + } + seen++ + } + if eof { + break + } + } + if seen != numItems { + t.Errorf("scanned %d items, want %d", seen, numItems) + } + return nil + }) + if err != nil { + t.Error(err) + } +} + +// DupSort: (FirstDup, NextDup) batches the values of the positioned key. +func TestCursor_GetBatch_DupSort(t *testing.T) { + env, _ := setup(t) + const numDups = 20 + var db DBI + err := env.Update(func(txn *Txn) (err error) { + db, err = txn.OpenDBISimple("testbatchdup", Create|DupSort) + if err != nil { + return err + } + for i := range 3 { + for j := range numDups { + k := fmt.Sprintf("key-%d", i) + v := fmt.Sprintf("val-%d-%02d", i, j) + if err := txn.Put(db, []byte(k), []byte(v), 0); err != nil { + return err + } + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + buf := NewGetBatchBuffer(8) + defer buf.Close() + + err = env.View(func(txn *Txn) error { + cur, err := txn.OpenCursor(db) + if err != nil { + return err + } + defer cur.Close() + + // Position on the second key, then batch only its values. Key(i) is + // unspecified for dup-only ops, so only the values are checked. + if _, _, err := cur.Get([]byte("key-1"), nil, Set); err != nil { + return err + } + seen := 0 + for opFirst := uint(FirstDup); ; opFirst = NextDup { + n, eof, err := cur.GetBatch(buf, opFirst, NextDup) + if err != nil { + return err + } + for i := range n { + want := fmt.Sprintf("val-1-%02d", seen) + if got := string(buf.Val(i)); got != want { + t.Fatalf("dup %d: val = %q, want %q", seen, got, want) + } + seen++ + } + if eof { + break + } + } + if seen != numDups { + t.Errorf("scanned %d dups, want %d", seen, numDups) + } + return nil + }) + if err != nil { + t.Error(err) + } +} + +func TestGetBatchBuffer_Closed(t *testing.T) { + env, _ := setup(t) + db := fillBatchDB(t, env, "testbatchclosed", 10) + + buf := NewGetBatchBuffer(8) + + err := env.View(func(txn *Txn) error { + cur, err := txn.OpenCursor(db) + if err != nil { + return err + } + defer cur.Close() + + n, _, err := cur.GetBatch(buf, First, Next) + if err != nil { + return err + } + if n == 0 { + t.Fatal("GetBatch filled nothing") + } + + buf.Close() + buf.Close() // idempotent + + if got := buf.Cap(); got != 0 { + t.Errorf("Cap() after Close = %d, want 0", got) + } + // Close must drop the last-fill count as well: Key/Val would otherwise + // hand out views of freed memory. + assertPanics(t, "Key(0) after Close", func() { _ = buf.Key(0) }) + assertPanics(t, "Val(0) after Close", func() { _ = buf.Val(0) }) + + n, eof, err := cur.GetBatch(buf, First, Next) + assertRejected(t, "GetBatch on a closed buffer", n, eof, err) + + n, eof, err = cur.GetBatch(nil, First, Next) + assertRejected(t, "GetBatch(nil)", n, eof, err) + return nil + }) + if err != nil { + t.Error(err) + } +} + +// Reading past the last fill must panic rather than expose stale entries. +func TestGetBatchBuffer_IndexOutOfFill(t *testing.T) { + env, _ := setup(t) + db := fillBatchDB(t, env, "testbatchindex", 3) + + buf := NewGetBatchBuffer(8) + defer buf.Close() + + err := env.View(func(txn *Txn) error { + cur, err := txn.OpenCursor(db) + if err != nil { + return err + } + defer cur.Close() + + n, _, err := cur.GetBatch(buf, First, Next) + if err != nil { + return err + } + if n != 3 { + t.Fatalf("n = %d, want 3", n) + } + assertPanics(t, "Key(n)", func() { _ = buf.Key(n) }) + assertPanics(t, "Val(n)", func() { _ = buf.Val(n) }) + assertPanics(t, "Key(-1)", func() { _ = buf.Key(-1) }) + return nil + }) + if err != nil { + t.Error(err) + } +} + +// assertRejected pins that GetBatch refused the call without moving the +// cursor. The exact errno is platform-dependent (EINVAL on POSIX, +// ERROR_INVALID_PARAMETER on Windows), so the code itself is not asserted. +func assertRejected(t *testing.T, name string, n int, eof bool, err error) { + t.Helper() + if err == nil { + t.Errorf("%s: expected an error, got nil", name) + } + if n != 0 || eof { + t.Errorf("%s: n=%d eof=%v, want 0/false", name, n, eof) + } +} + +func assertPanics(t *testing.T, name string, fn func()) { + t.Helper() + defer func() { + if e := recover(); e == nil { + t.Errorf("%s: expected panic, got none", name) + } + }() + fn() +} diff --git a/mdbx/mdbxgo.c b/mdbx/mdbxgo.c index f75fbc8..d9eb806 100644 --- a/mdbx/mdbxgo.c +++ b/mdbx/mdbxgo.c @@ -107,6 +107,25 @@ mdbxgo_val_result mdbxgo_cursor_get_val(MDBX_cursor *cur, char *kdata, size_t kn return r; } +mdbxgo_size_result mdbxgo_cursor_get_batch(MDBX_cursor *cur, MDBX_val *pairs, size_t max_pairs, + MDBX_cursor_op op_first, MDBX_cursor_op op_next) { + mdbxgo_size_result r = {0}; + MDBX_cursor_op op = op_first; + while (r.val < max_pairs) { + MDBX_val key = {0}, val = {0}; + r.err = mdbx_cursor_get(cur, &key, &val, op); + /* MDBX_RESULT_TRUE (e.g. a lower/upper-bound reposition) is success + * with a valid pair, not a stop condition: store it and continue. */ + if (r.err != MDBX_SUCCESS && r.err != MDBX_RESULT_TRUE) + break; + pairs[2 * r.val] = key; + pairs[2 * r.val + 1] = val; + r.val++; + op = op_next; + } + return r; +} + /* Compare two items lexically */ // static int __hot cmp_lexical(const MDBX_val *a, const MDBX_val *b) { // if (a->iov_len == b->iov_len) diff --git a/mdbx/mdbxgo.h b/mdbx/mdbxgo.h index d6455a2..c241e02 100644 --- a/mdbx/mdbxgo.h +++ b/mdbx/mdbxgo.h @@ -74,6 +74,20 @@ mdbxgo_val_result mdbxgo_cursor_get_empty(MDBX_cursor *cur, MDBX_cursor_o mdbxgo_val_result mdbxgo_cursor_get_val(MDBX_cursor *cur, char *kdata, size_t kn, char *vdata, size_t vn, MDBX_cursor_op op); mdbxgo_val_result mdbxgo_cursor_put_reserve(MDBX_cursor *cur, char *kdata, size_t kn, size_t vn, MDBX_put_flags_t flags); +/* mdbxgo_cursor_get_batch fills pairs[0..2*max_pairs) with (key, value) + * result pairs: the first mdbx_cursor_get step uses op_first, the rest + * op_next. Both are passed an empty key/value, so the caller must restrict + * them to ops that need no input — the start_op/turn_op sets of + * mdbx_cursor_scan; Cursor.GetBatch enforces that on the Go side. + * r.val holds the filled pair count. r.err is MDBX_SUCCESS (or + * MDBX_RESULT_TRUE, e.g. a bound reposition) when the buffer filled, + * MDBX_NOTFOUND when iteration was exhausted first, or the failing code of a + * mid-batch error (in which case r.val still counts the pairs stored before + * it). Amortizes cgo call overhead: one Go->C call retrieves max_pairs + * records. + * */ +mdbxgo_size_result mdbxgo_cursor_get_batch(MDBX_cursor *cur, MDBX_val *pairs, size_t max_pairs, MDBX_cursor_op op_first, MDBX_cursor_op op_next); + typedef struct { int err; uint64_t pages_allocated;