DB::DuckDB — README
DB::DuckDB
A DuckDB driver for Raku's DB interface,
implemented as a direct NativeCall binding over
the DuckDB C API — no external process, no hand-maintained FFI layer. The C in the
distribution is three small self-contained files under resources/, none of which includes
duckdb.h or links libduckdb: a ~45-line pointer bridge (the two remaining by-value
duckdb_result accessors cannot be called safely from NativeCall on SysV platforms; built on
first use, skipped entirely on Windows), a VARCHAR bulk decoder, and an Arrow release
bridge. The two shims are optional — without a compiler the driver uses slower pure Raku
paths.
use DB::DuckDB;
my $db = DB::DuckDB.new; # in-memory
# my $db = DB::DuckDB.new(path => 'data.duckdb'); # file backed
say $db.query('select 42').value; # 42
$db.execute('create table foo (id integer, name varchar)');
$db.execute('insert into foo values (?, ?)', 1, 'a');
for $db.query('select * from foo').hashes -> %row {
say %row<id>, %row<name>;
}Status
Early but working. Implemented and covered by tests:
driver:
new,connect,query,execute,close,library-versionconnection:
prepare,prepare-nocache,query,execute,begin,commit,rollback,ping,finishstatement: positional
?and named$nameplaceholders (DuckDB syntax;:nameis not supported), binding and rebinding,executeresult:
value,row,array,hash,arrays,hashes,column,column-view,column-views,column-arrays,:parallel,:degree,:stream,chunks,is-streaming,names,rows,rows-changed,freewrites:
appender,insert-rows,execute-many, and theDB::DuckDB::Appenderhandle (append-row/append-rows/flush/close/rows/width), all routed through DuckDB's appenderparquet:
read-parquet(a file, a glob or a list;:union-by-name,:filename,:hive-partitioning,:columns,:stream),write-parquet(a table or a query, with compression / row-group-size / partitioning),parquet-metadata,parquet-schema,parquet-row-groupsDB::DuckDB::Arrow:arrow-chunk,with-arrow-chunks— Arrow C data interface views, no Arrow extension and no NanoArrow
Results are read chunk by chunk out of the vectors and decoded in Raku (DB::DuckDB::Decoder),
because 2.0 stopped rendering several types through duckdb_value_varchar. VARCHAR columns
are unpacked in bulk by a small C shim (see Design notes) and fall back to the per-value
decoder when the shim cannot be built.
Every chunk the driver fetches is destroyed again once its vector has been consumed or copied,
including the chunks column-views holds for lazy decoding (released in free). Chunks that
were never destroyed cost ~235 KB each, which is what made a 200k-row query leak ~23 MB per
round; the whole suite now runs flat across hundreds of rounds.
Not implemented (yet): callbacks/UDFs, extension loading, and anything that needs a by-value struct return (see Design notes).
Requirements
Raku 6.d / Rakudo (tested on Rakudo 2026.09, Windows 11 and WSL Ubuntu)
DB, plusAlien::Libduckdb, which puts a libduckdb where this module can find it: a prebuilt archive in seconds, a cmake source build as fallback (5-20 minutes).zefresolves both automatically.Optional: a C compiler for the
VARCHARand Arrow shims. It is not required - without one the driver just logs a note and uses the slower pure Raku decoders (and the pure Raku Arrow release callback, which caches its callable per release pointer since 0.8.1). SetDB_DUCKDB_CCif the compiler is not calledcc/gcc/clangand not in one of the usual Windows toolchain directories.
The VARCHAR shim
resources/duckdb-varchar-shim.c is a self-contained C file: it does not
include duckdb.h and does not link libduckdb, it only knows the byte layout of
duckdb_string_t. It is compiled on first use (one gcc -O2 -shared) into
~/.local/share/DB-DuckDB and reloaded from there afterwards; a cached library
is reused only when the per-file stamp next to it records the ABI this driver
expects, so a newer source or a bumped ABI means a rebuild.
| variable | effect |
|---|---|
DB_DUCKDB_VARCHAR_SHIM | path of a prebuilt shim library; skips the build |
DB_DUCKDB_SHIM_CACHE | where the built library is cached |
DB_DUCKDB_CC / CC | compiler to use |
DB_DUCKDB_NO_VARCHAR_SHIM=1 | never use the shim (pure Raku decoder) |
DB_DUCKDB_NO_CACHE_SWEEP=1 | leave the shim cache exactly as it is |
DB_DUCKDB_SHIM_TRACE | path of a log file; the shim appends a line per step (survives a hard crash, unlike stderr) |
The two paths are cross-checked in t/04-varchar-shim.rakutest: both digests
of the same table have to match byte for byte.
The cache directory is swept once per process, after a shim has been located:
.bak files from older releases, stamps whose library is gone, per-process
builds older than an hour, and a shared-name library with no stamp that
predates a stamped one. Everything in that directory is rebuildable from
resources/, so the sweep is safe; it still refuses to empty a cache that has
no usable library left, and never touches the library the process resolved.
DB::DuckDB::ShimCache holds the logic, t/15-shim-cache.rakutest pins it.
Why the Alien dependency is pinned
The DuckDB C API is not ABI-stable across majors: duckdb_result is passed by
value, the DUCKDB_TYPE enum was renumbered, and the by-value accessors moved.
This binding is written and tested against DuckDB 2.x, so META6.json
asks for exactly Alien::Libduckdb:ver<1.1.0> - the release that ships
libduckdb 2.0.0 - instead of whatever is newest. Bumping that pin is a
deliberate act: read duckdb.h, check the result struct layout, run t/, then
bump.
As a second line of defence, the driver calls duckdb_library_version() right
before it opens its first database handle and refuses to continue when the
loaded library is not 2.x:
DB::DuckDB: libduckdb v3.0.0 is not ABI-compatible with this binding, which
targets DuckDB 2.x; library in use: C:\rakudo\bin\duckdb.dll. ...That catches what the pin cannot: a DB_DUCKDB_LIB override, a stale
duckdb.dll sitting next to raku, or a system libduckdb.
How the library is located
Alien::Libduckdb::shared-lib()- the private prefix thatzef installset up (this is the normal path)%DB_DUCKDB_LIBenvironment variable, if it points at an existing fileresources/libraries/duckdbinside the distribution, if installed with onenext to the running
rakuexecutable - this coversC:\rakudo\bin\duckdb.dll/usr/local/lib,/usr/lib,/opt/homebrew/libbare
duckdb, letting the OS loader search
$env:DB_DUCKDB_LIB = 'C:\rakudo\bin\duckdb.dll'Install
zef install .Or run straight from a checkout:
raku -Ilib -e 'use DB::DuckDB; say DB::DuckDB.new.query("select version()").value'Bulk writes
Multi-row INSERT statements pay DuckDB's parser for every row. The appender API
does not, and the driver exposes it at three levels:
my $db = DB::DuckDB.new;
# 1. the low-level handle
my $ap = $db.appender('measurements');
$ap.append-row(1, 'x', 1.5);
$ap.append-row([2, 'y', 2.5]); # a single Positional is unpacked
$ap.free; # flush + close; raises on a failed cast
# 2. whole batches (transactional by default)
$db.insert-rows('measurements', ((3, 'z', 3.5), (4, Any, 4.5)));
# 3. reuse an existing INSERT statement
$db.execute-many('insert into measurements values (?, ?, ?)',
((5, 'w', 5.5), (6, 'v', 6.5)));insert-rows and execute-many wrap the load in a transaction, so a batch that
fails halfway leaves nothing behind. When the caller already has a transaction
open (an explicit begin), the batch joins it instead of opening its own and
nothing is committed early:
$db.execute('begin');
$db.execute('insert into measurements values (7, NULL, 7.5)');
$db.insert-rows('measurements', ((8, 'u', 8.5),)); # joins the transaction
$db.execute('rollback'); # both rows are goneexecute-many only routes to the appender when the statement is a plain
INSERT INTO t [(cols)] VALUES (?...). A batch that adds ON CONFLICT is
staged instead: from 32 rows up, the values go into a TEMP table through the
appender and are replayed with one INSERT ... SELECT that keeps the clause, so
a 20k-row upsert costs 0.25 s rather than 55.6 s. DO NOTHING always takes that
path; DO UPDATE takes it only when the clause names its conflict target and
the batch has no duplicate key -- the bulk form updates a key once where the
prepared path updates it per row, so anything else stays sequential. Everything
left (RETURNING, expressions, sub-selects, ON CONSTRAINT, narrow batches)
still runs as one prepared statement per row inside the same transaction, and a
failure in a staged batch rolls back with no staging table left behind.
Session options
threads, memory_limit and preserve_insertion_order are pushed to every
connection the driver opens, and can be changed on a live session:
my $db = DB::DuckDB.new(:threads(8), :memory-limit('2GB'),
:preserve-insertion-order(False));
$db.set-session-option('threads', 4); # applied immediatelypreserve_insertion_order => False is only sent when it is turned off: True
is DuckDB's own default, so the driver stays out of the engine's way. Set any
other option with set-session-option.
Zero-boxing column views
column() always builds a plain Array and boxes every value into a Raku
scalar. When you only want to scan or aggregate a column, column-view keeps
DuckDB's own vector buffer and reads through it in place:
my $r = $conn.query('SELECT id, amount, note FROM sales');
my $ids = $r.column-view(0);
say $ids.type; # BIGINT
say $ids.elems; # 5000
say $ids.int64(0); # no Mu allocation per value
say $ids.sum; # walks the raw buffer, skips NULLs
say $ids.min; # same
say $ids.count; # non-NULL values
say $ids.to-list; # List, built with a typed loop (fastest full read)
say $dates.at(0, :typed); # Date, for DATE / TIMESTAMP columns
say $ids.slice(0, 100); # a narrower view, no copyint64 / num64 / is-null / sum / min / max / count / mean /
slice work on the numeric types without boxing. at($i) returns a proper
Raku value for every type, but for VARCHAR, dates, wide DECIMAL and nested
types it goes through the same decoder the boxed path uses, so to-list is the
better choice there. $r.column-views returns all columns at once.
Temporal columns can skip the text form: at($i, :typed) and to-list(:typed)
answer DATE as a Date and every TIMESTAMP_* as a UTC DateTime (fractional
seconds kept as a Rat), built from the integer payload rather than formatted
and re-parsed. TIME and TIMESTAMPTZ keep their text, because there is no
lossless Raku value for a bare clock or an explicitly zoned stamp. A sliced or
materialised view rebuilds the value from DuckDB's own text, so (BC) and
infinity come back as the text they went in as instead of a wrong date.
The view borrows the result's memory: it is only valid while $r is alive, and
reading it after $result.finish raises.
Zero-copy text columns
Text is the one type the view above still boxes, because at($i) on a
VARCHAR has to return a Str. .text gives the column back as bytes
instead: one payload buffer, plus a byte offset and a length per row, filled by
the shim in a single copy per chunk.
my $t = $conn.query('SELECT note FROM sales').column-view(0).text;
say $t.type; # VARCHAR
say $t.elems; # 1_000_000
say $t.byte-size; # 41_896_900 -- one buffer, one allocation
$t.bytes; # the whole payload, back to back (Buf, borrowed)
$t.offset(7); # byte offset of row 7 inside .bytes
$t.length(7); # byte length (UTF-8 bytes, not characters)
$t.bytes-at(7); # Buf slice -- no decode, no Str
$t.at(7); # Str, one allocation, only when you ask for it
$t.is-null(7); # signals NULL; an empty string is length 0 but not NULL
$t.to-list; # List of Str/Any, for compatibility with column()
$t.slice(100, 500); # rows 100..599 as another TextColumn -- no copyslice is the cheap window: the new column borrows the same payload buffer and
the same three tables and only remembers where it starts, so it costs one small
object however big the parent is -- a thousand 100k-row slices of a million-row
column take 23 ms in total. .bytes on the slice still reports the whole parent
payload (trimming it would be the copy the view avoids); .byte-size reports
the window's own extent. Slices of slices add their start rows, an out-of-range
window raises, and a NULL inside the window still reads back as Any.
Use it for anything that does not need a string object per row: grouping,
hashing, length filters, writing the column back out, feeding a parser. On one
million 40-byte strings the export costs 67 ms against 1.9 s for to-list
(3.9 s before it learned to decode whole ASCII windows), and the rows read back
byte for byte identical across chunk seams (t/16-text-column.rakutest).
The tables are borrowed: offset-buffer / length-buffer / null-buffer
expose the raw CArrays so a hot loop can index them without a method call per
row. Treat them as read-only, and keep the result alive while the column is in
use -- a streamed result recycles its chunks, so .text on a streaming view is
a detached copy.
BLOB columns work the same way; the payload is the bytes as stored. Other
types are refused: numeric columns already have int64 / num64 / sum, and
temporal and DECIMAL values are rendered from their integer payload. When the
C shim is unavailable, .text refuses with an explanation rather than
silently falling back to the slow path -- check
DB::DuckDB::TextColumn::text-column-available() first if you want to branch.
Parallel decoding
column-arrays decodes the columns of a materialised result on several threads
by default, once the result is big enough to pay for them:
my $r = $conn.query('SELECT * FROM wide_table');
my @cols = $r.column-arrays; # parallel when it pays
my @four = $r.column-arrays(:degree(4)); # capped at 4 workers
my @one = $r.column-arrays(:!parallel); # force the serial pathA worker is only used if it gets at least 20k rows, so a 25k-row result stays on
one thread and a million-row one spreads over the pool; :degree caps the count
on top of that. DB_DUCKDB_NO_COLUMN_PARALLEL=1 switches the default off for a
whole process.
The work is cut into (column, chunk-span) tasks and each worker writes into
that column's preallocated array, so no memory is shared between workers and no
value is copied twice. A result with more columns than workers spreads the
columns over the workers; a result with fewer columns splits each column's
chunks into contiguous spans and stitches the spans back in chunk order, so row
order is preserved and the output is identical to the serial path (checked
value by value in t/08-parallel.rakutest).
DB_DUCKDB_DEGREE in the environment sets the default worker count.
Measured on this machine, 1M rows, wall clock for the whole decode (median of four runs per shape; this is a 24-core box and the spread between runs is wide because GC dominates). The numbers predate the weight-based split below; the one- and two-column rows are exactly the lopsided cases that motivated it, so treat them as a lower bound:
| shape | serial | :parallel | speedup |
|---|---|---|---|
1 x VARCHAR | 4.7 s | 1.9 s | 2.4x |
| 5 numeric columns | 8.5 s | 3.4 s | 2.2x |
| 4 numeric columns | 5.2 s | 2.6 s | 2.0x |
| 7 mixed columns | 22.5 s | 10.5 s | 2.1x |
| 3 mixed columns | 9.2 s | 6.5 s | 1.3x |
| 2 mixed columns | 5.5 s | 4.8 s | 1.1x |
Parallelism pays where the per-value cost is high (VARCHAR unpacking,
DECIMAL formatting) or where there are many columns to spread. Results with
only one or two columns are bound by the boxing step and by Raku's GC -- both
shared between threads -- so the gain there is small. Below 20 000 rows the
driver stays serial, because setting the workers up costs more than the decode.
Splitting by column weight
Cutting every column into the same number of spans spreads the work evenly
only if the columns cost the same. They do not: a VARCHAR value costs about
four BIGINTs to decode, a nested value about three, DECIMAL/HUGEINT about
two. With two such columns and four workers, an even split of spans leaves the
cheap column's worker idle while the expensive one still runs, and a lopsided
result can end up slower than the serial path.
So the span count per column is ceil(nt * w / sum-of-weights), capped at both
nt and the chunk count, with weights VARCHAR 4, LIST/STRUCT/MAP/
ARRAY/UNION 3, DECIMAL/HUGEINT 2, everything else 1. Plain numeric
columns are left whole unless they are the only column, because stitching a
numeric column back costs a copy that cancels the gain.
Measured on 200k rows of BIGINT + VARCHAR(40), 20 interleaved rounds in one
process, paired median against the serial path:
| workers | even split | by weight |
|---|---|---|
:degree(2) | 1.22-1.24x | 1.20-1.38x |
:degree(4) | 0.92-0.93x | 1.29-1.32x |
The point is the second row: adding workers no longer costs speed. Absolute ratios move by up to 20% with machine load.
Two cases cannot be split and fall back to the serial path automatically: a streaming result (its chunks belong to the stream, not to a chunk list) and a result that was already partially read.
Streaming results
By default a result is materialised -- DuckDB runs the query to completion
before the first row is available. :stream keeps the query pulling row
batches on demand:
my $r = $conn.query('SELECT * FROM huge', :stream);
$r.is-streaming; # True
for $r.chunks -> $chunk {
# $chunk.size -- rows in this vector (<= 2048, the last one shorter)
# $chunk.column-count
my $view = $chunk.column-view(0);
... $view.sum ...
my @row = $chunk.row(0); # boxed values, for the odd row
my @rows = $chunk.to-rows; # boxed, for small chunks
}A chunk is valid only until the next one is pulled. DuckDB recycles the
vector, so the driver expires the previous chunk (and every view it handed out)
before fetching the next one; any read through a stale chunk or view raises
instead of returning another row's data. That is the trade-off for not copying:
consume inside the loop body and let the chunk go. $chunk.is-valid reports
whether the driver has moved on; .size and .column-count stay readable
either way, because they are plain numbers rather than pointers.
Measured on this machine, 1M rows x 3 columns (bigint, double, varchar):
first usable chunk after 12 ms, versus 5.8 s to materialise the whole
result through column-arrays; summing the bigint column while streaming
takes 3.9 s and agrees exactly with SELECT sum(a).
Prefetching
chunks used to take turns with the engine: fetch, decode, fetch. The engine
computes while you decode nothing, and sits idle while you decode. A worker
thread now pulls the next chunk while the caller works on the current one.
The queue is two slots deep, not more: a chunk is ~235 KB and decoding it costs several times what fetching it costs, so a deeper queue would only raise time to first chunk and memory. Two slots are enforced with a token channel -- the worker takes a token before fetching, the caller returns one when it drops the previous chunk -- so at most two chunks are alive.
Nothing changes at the API: free stops the worker, joins it and destroys
anything still queued before the result goes away, so a stream abandoned after
one chunk is safe. Measured 300k rows, 7-9 interleaved rounds: 1.06-1.13x
on a decode-bound full scan, 1.03-1.18x on an engine-bound streaming
aggregation, time to first chunk unchanged, RSS plateau +17 MB.
DB_DUCKDB_NO_PREFETCH=1 restores the one-at-a-time path.
Arrow views
DB::DuckDB::Arrow exposes a chunk of a materialised result through the Arrow C
data interface, so the buffers can be handed to another component instead of
being boxed into Raku values here:
use DB::DuckDB::Arrow;
my $r = $conn.query('SELECT id, tags FROM items');
my $chunk = arrow-chunk($r, 0); # 0-based chunk index, 2048 rows each
my $col = $chunk.column(1); # LIST<VARCHAR>
say $chunk.length; # rows in this chunk
say $chunk.columns;
say $col.null-count;
say $col.children[0].strs; # the child vector of the list
$chunk.free; # idempotent
with-arrow-chunks($r, -> $c, $i { ... }); # frees every chunk for youwith-arrow-chunks hands each chunk and its index to the block and frees the
chunk on the way out, so nothing leaks if the block dies. Chunks are numbered
the same way as arrow-chunk.
Column gives typed access without boxing: .ints, .int32s, .nums,
.bools, .strs (each returns a List), .is-null($i), .children for
recursing into LIST/STRUCT, plus the raw .buffer($j) pointer and
.length for talking to something that wants the buffers themselves. Chunk
also exposes .addr for the ArrowArray* struct.
There is no LOAD arrow and no NanoArrow dependency: the Arrow C data interface
is two ten-field structs, so ArrowArray / ArrowSchema are declared directly
as repr('CStruct').
This is not a faster read path. strs has to follow the offset buffer and copy
each value out, which on 200k rows is 6.3 s against 1.7 s for the bulk
join-varchar decoder used by column() -- about 3.8x slower. Use it for
nested types, for passing data to other libraries, or when you want the Arrow
buffers as they are.
Releasing an Arrow array means calling a callback pointer stored inside the
struct. Doing that from Raku naively leaked on Windows (~174 MB to ~711 MB over
30k releases, because each nativecast to a callable builds a stub the VM
never reclaims). Two things fix it: resources/duckdb-arrow-shim.c moves the
indirect call to C, and -- for machines without a compiler -- the in-Raku path
now caches one callable per release pointer instead of building one per chunk.
DuckDB uses a single release function for every chunk it produces, so that
cache holds exactly one entry; the no-shim path measured +69 MB over ~1500
releases before and +2 MB after (GC noise). It is built on demand next to the
VARCHAR shim, under a per-process name that is renamed into place only when the
shared name is free, so several processes can build at once (same hardening the
VARCHAR shim received in 0.7.1).
DB_DUCKDB_ARROW_SHIM points at a prebuilt library, DB_DUCKDB_NO_ARROW_SHIM=1
forces the in-Raku path, DB_DUCKDB_ARROW_DEBUG=1 logs which release path is
taken. When no compiler is available the driver checks that a shim library
actually exists before trusting it -- an earlier version only required the
binding module, which compiles fine without the library and then failed on the
first release call.
A chunk fetched from the result is owned by the caller: arrow-chunk destroys
it right after the conversion has copied the data into Arrow-owned buffers, so
the Arrow view does not keep the DuckDB result alive.
Parquet
DuckDB 2.0's C API has no Parquet entry point -- there is nothing named
parquet in duckdb.h. The format is reachable only through SQL, so the
driver wraps read_parquet(...) and COPY ... TO ... (FORMAT PARQUET) instead
of pretending a binding exists. Rows read from a file travel through the same
chunk/vector path a table scan uses, which is why the VARCHAR, temporal,
DECIMAL and ENUM C channels apply unchanged.
# read: a file, a glob, or a list of files
for $db.read-parquet('sales.parquet').hashes -> %row { ... }
$db.read-parquet('data/*.parquet', :union-by-name, :filename).column-views[0].sum;
$db.read-parquet(<a.parquet b.parquet>, :union-by-name);
# write: a table, or a query
$db.write-parquet('out.parquet', 'events'); # returns rows written
$db.write-parquet('out.parquet', 'select * from events', :sql);
$db.write-parquet('out.z.parquet', 'events', :compression<zstd>, :row-group-size(250_000));
$db.write-parquet('out/part', 'events', :partition-by<day region>);
# footer only
$db.parquet-metadata('out.parquet')[0]<num_rows num_row_groups format_version>;
$db.parquet-schema('out.parquet');
$db.parquet-row-groups('out.parquet');Read paths bind the file names as parameters, one placeholder per path, so a
file whose name contains a quote needs no escaping. Writes have to interpolate
the target and the table name, so both go through the same literal and
identifier quoting used internally, and compression is validated against the
codec list before the statement is built.
Options: :union-by-name, :filename, :hive-partitioning, :columns,
:stream when reading; :sql, :schema, :compression, :row-group-size,
:partition-by, :overwrite-or-ignore when writing. COPY replaces an
existing file by default; :overwrite-or-ignore matters for partitioned
directories that already hold files. :row-group-size is only honoured at
2048-row vector boundaries, so the real groups come out as multiples of that.
Measured on 1M rows x 4 columns (best of 3, single process, zstd):
| operation | time | notes |
|---|---|---|
write-parquet (COPY) | 0.033 s | 2.05 MiB, 0.03 µs/row |
execute-many, 20k rows | 0.226 s | 11.3 µs/row, ~400x slower per row |
read BIGINT, 1M values | 0.58 s | table scan 0.58 s |
read VARCHAR, 1M values | 1.15 s | table scan 1.20 s |
| read all four columns | 5.08 µs/row | table scan 5.05 µs/row |
| first streamed chunk | 19.8 ms | table scan 37.7 ms |
parquet-metadata (footer) | 0.6 ms | does not read data pages |
The reader is not a separate decode path, and does not need to be: reading a Parquet file costs what reading the equivalent table costs, within noise.
Environment switches
| variable | effect |
|---|---|
DB_DUCKDB_DEGREE | default worker count for column-arrays(:parallel) |
DB_DUCKDB_NO_PREFETCH=1 | one chunk at a time in chunks, no prefetch worker |
DB_DUCKDB_NO_COLUMN_VIEW=1 | column-views falls back to plain column-arrays (decodes eagerly, holds nothing) |
DB_DUCKDB_ARROW_DEBUG=1 | log which Arrow release path is taken |
DB_DUCKDB_ARROW_SHIM | path of a prebuilt Arrow release shim |
DB_DUCKDB_NO_ARROW_SHIM=1 | never build or use the Arrow shim; release through the cached in-Raku callback |
DB_DUCKDB_LIB | path of the libduckdb to load |
DB_DUCKDB_NO_VARCHAR_JOIN=1 | per-value VARCHAR decoding, no join buffer |
DB_DUCKDB_NO_VARCHAR_SHIM=1 | no C shim at all, pure Raku decoder |
The shim build variables (DB_DUCKDB_VARCHAR_SHIM, DB_DUCKDB_SHIM_CACHE,
DB_DUCKDB_CC, DB_DUCKDB_SHIM_TRACE) are listed under The VARCHAR shim.
Type mapping (DuckDB → Raku)
| DuckDB type | Raku value |
|---|---|
BOOLEAN | Bool |
TINYINT / SMALLINT / INTEGER / BIGINT | Int |
UTINYINT / USMALLINT / UINTEGER / UBIGINT | Int |
FLOAT / DOUBLE | Num |
VARCHAR | Str |
NULL (any type) | Any (undefined) |
DATE, TIME, TIMESTAMP, TIMESTAMP_S/_MS/_NS, TIME_NS | Str in DuckDB's ISO format, rendered from the integer payload (byte identical to CAST(x AS VARCHAR)) |
TIME_TZ, TIMESTAMP_TZ, TIMESTAMP_TZ_NS | Str in DuckDB's own ISO rendering (session time zone honoured) |
DECIMAL | Str, exact and lossless, rendered from the unscaled integer |
ENUM | Str, looked up in the type's dictionary (one Str per label) |
HUGEINT / UHUGEINT | Str, exact and lossless |
BLOB, UUID, INTERVAL, LIST, STRUCT, MAP, UNION, BIT, BIGNUM, ARRAY | Str in DuckDB's textual rendering |
SQLNULL type | Any |
Raku → DuckDB (bind): Bool → BOOLEAN, Int → BIGINT, Num / Rat / FatRat → DOUBLE,
Str → VARCHAR, Blob → BLOB, undefined → NULL, anything else → its .Str.
Dates and decimals are deliberately not auto-coerced into Raku Date/DateTime/Rat:
DuckDB's own textual rendering is exact for every type, while the duckdb_value_* family that
would return real date/decimal structures is deprecated and partly by-value. Exactness beats
convenience here — convert at the call site when you need it.
Design notes
One database instance per driver.
connectcreates connections that all share a singleduckdb_database. Opening:memory:twice would silently create two independent databases, and opening a file path twice trips DuckDB's instance/file locking, so the driver opens once and guards it with$!opened.duckdb_resultis mapped as a fixed-layout struct (48 bytes on LP64: threeidx_tplus three pointers) and its size is asserted at load time, so an ABI change fails loudly instead of corrupting memory.Results are read through the chunk/vector API.
duckdb_result_chunk_count/duckdb_fetch_chunkhand back aduckdb_data_chunkpointer, and each vector's data buffer is read in place; the structs living inside those buffers are dissected field by field throughuint32/uint64views, since NativeCall faults onCStructarray element access on Windows. No struct is ever returned or taken by value, with one deliberate exception: the two accessors that still declare a by-valueduckdb_resultare called throughDB::DuckDB::Bridge, which passes the same 48 bytes as a pointer. NativeCall's by-value marshalling happens to work under MS x64 but is miscompiled under SysV, where it surfaced asunique_ptrNULL crashes inside DuckDB.Values are rendered exactly like DuckDB's own
CAST(x AS VARCHAR), not coerced into RakuDate/DateTime/Rat.TIMESTAMP_TZis the one piece of session state involved: the vector holds UTC microseconds, so the instant is shifted by the session zone's offset (synced fromstrftime(..., '%z')on construction and refreshed afterSET TimeZone) and only then labelled with that offset.Prepared statements are cached per connection by the
DBrole; statements are destroyed when their connection is dropped.closealso tears down connections the caller forgot tofinish, because DuckDB will not release the file lock until every connection and prepared statement is gone.VARCHARis unpacked in bulk by a C shim. Decoding a string column oneduckdb_string_tat a time andBuf.push-ing each payload was the slowest thing in result reading (and pure-Raku batching was slower still, because every payload still had to be copied byte by byte).duckdb-varchar-shim.csnapshots the vector'sduckdb_string_tarray once, then copies every payload into one flat buffer while writing an offsets table, a lengths table and a NULL bitmap; Raku decodes that buffer once and slices values out withsubstr. Measured 2.7-3.4x oncolumn()and 1.5-1.8x onarrays()/hashes()at 100k-1M rows. The shim never trusts the pointers it is handed: thestring_tarray is copied into memory it owns before being walked, out-of-line payloads are read through a probing copy, a payload longer than 16 MiB (the signature of a selection buffer mistaken for a string vector) and any unreadable pointer make it returnDKD_NOT_FLAT, which is the signal to fall back to the per-value decoder for that chunk.Temporal columns are formatted by the driver, not by DuckDB. A
DATEis an int32 day count and theTIMESTAMP*/TIME*types are int64 counters, so the text can be computed directly from the payload instead of asking DuckDB toCASTeach value.dkd_temporal_joinin the same shim renders a whole chunk into one0x01-separated buffer (aNULLbecomes0x02) that Raku splits once, and a pure Raku renderer produces exactly the same text when no compiler is around. Measured on 1M rows against DuckDB's own cast:DATE8.38 → 1.38,TIMESTAMP23.35 → 1.22,TIME4.58 → 1.27 µs/value, and a three columnarrays()read 35.88 → 4.78 µs/row (the cast itself costs 1.30 µs/value, so this is the floor). The text matches theCASTto the byte, including the(BC)suffix on pre-epoch dates,24:00:00, and fractional seconds with trailing zeros removed.TIME_TZ/TIMESTAMP_TZ/TIMESTAMP_TZ_NSstay on DuckDB's own rendering because their text carries the session zone. SetDB_DUCKDB_NO_TEMPORAL_SHIM=1to force the Raku renderer.DECIMAL is rendered from its unscaled integer, in C. A
DECIMALis an integer plus a scale, and DuckDB prints exactlyscaledigits, trailing zeros included. Doing that in Raku turned out to cost more per value than asking DuckDB to format it (1.71 µs just for the formatting), sodkd_decimal_joindoes the digit loop instead, for the 64 bit and the 128 bit widths alike (the latter behind__SIZEOF_INT128__; without it the Raku path stays). On 1M rows:DECIMAL(9,2)2.45 → 1.23,DECIMAL(18,4)3.52 → 1.24,DECIMAL(38,10)9.42 → 1.48 µs/value, against 1.24–1.42 forCAST(x AS VARCHAR). One quirk is reproduced on purpose: the all-fraction widths print without an integer digit (DECIMAL(9,9)→.5), whereDECIMAL(10,9)→0.5. SetDB_DUCKDB_NO_DECIMAL_SHIM=1to force the Raku renderer.ENUM is a dictionary lookup, not a render. The keys are read as the narrowest unsigned integer that fits the dictionary (
duckdb_enum_internal_typesays which) and the text comes fromduckdb_enum_dictionary_value, read once per chunk. 2.71 → 0.61 µs/value on 1M rows, which is faster than the VARCHAR channel because rows sharing a label share oneStrand nothing is allocated per value.Works on Windows without an import library: the DLL is loaded at runtime, so the MSVC
.libthat ships with the C API is not needed.
Tests
raku -Ilib t/01-basic.rakutest # types, placeholders, transactions, errors
raku -Ilib t/02-file.rakutest # file database, multiple connections, reopen
raku -Ilib t/03-types.rakutest # every 2.0 type vs. DuckDB's own text rendering,
# NULLs, chunked results, session time zone, uuid
raku -Ilib t/04-varchar-shim.rakutest # the VARCHAR shim, including its fallbacks
raku -Ilib t/05-appender.rakutest # appender, insert-rows, type mapping
raku -Ilib t/06-column-view.rakutest # column-views vs column-arrays, all types
raku -Ilib t/07-streaming.rakutest # chunks, 2048-row vectors, chunk lifetime,
# prefetch vs one-at-a-time
raku -Ilib t/08-parallel.rakutest # parallel column decode vs the serial path,
# including lopsided results at 2/3/4/8 workers
raku -Ilib t/09-lazy-view.rakutest # lazily decoded views and their cleanup
raku -Ilib t/10-varchar-join.rakutest # the join-buffer VARCHAR decoder and its fallbacks
raku -Ilib t/11-arrow.rakutest # Arrow C data interface views, nested types, release
raku -Ilib t/12-temporal.rakutest # date/time/timestamp rendering vs DuckDB's own cast,
# pre-epoch and extreme values, NULLs, lazy views
raku -Ilib t/13-decimal-enum.rakutest # decimal widths 4..38 incl. the 128 bit slot and the
# all-fraction quirk; enum dictionaries and NULLs
raku -Ilib t/14-parquet.rakutest # parquet round trip, path shapes (quotes, unicode,
# glob, list), options, footer metadata, partitioning
raku -Ilib t/15-shim-cache.rakutest # cache sweep: what is removed, what is kept,
# and that it never empties a usable cacheNo prove harness is required -- each file is a standalone Test script.
License
Artistic-2.0 (内容由AI生成,仅供参考) (内容由AI生成,仅供参考) (内容由AI生成,仅供参考)