Changelog¶
All notable changes to rayforce are documented here. This project adheres to
Semantic Versioning.
Unreleased¶
Added¶
QConnection::connect_optschooses the keepalive. It takesConnectOpts: credentials, a timeout and aKeepalive, which isAuto(the core's 60 s default),Off, orBudget(Duration), roughly when a silent peer counts as dead.connectandconnect_withkeep their signatures and getAuto. A zero timeout or budget is refused rather than passed on, because the C side reads 0 as no timeout or as the default keepalive, and so is either pasti32::MAXmilliseconds.rayforce-sysbindsq_connect_optsand theQ_KEEPALIVE_*constants.
1.4.0¶
Changed¶
-
The vendored core is v3.0.1 (
0aa2d6a), from v2.11.5 (5d5f47c). One squashed release commit of about 230 changes across 475 files.include/rayforce.hgrows: aRAY_TIMESPANtype (16) withray_timespan, and nanosecond timespan literals in Rayfall;ray_error_msgandray_error_clearmade public, which this crate already bound as internal functions;ray_table_colandray_table_col_idx;ray_is_vector_type; and theRAY_ERR_VERSIONandRAY_ERR_FPENVerror codes. NoScalarorVectorvariant maps type 16 yet. The nineteenINTERNAL_FNSsignatures are unchanged, and the private headers bindgen reads grow additively, for compiled closures, heap bounds and the pool. Engine highlights: q-style F32 literals and(times [i n] body...); generation-2 compressed columns read by range, per-column FSST-compressed symbol files, and files rayforce wrote read back without re-validation; bounded memory on small machines, wide dense grouping and two-pass top-k grouping, the default pool sized from the CPUs the process may run on; integer atom subtraction promotes both operands;alteramends a dotted target in place. The suite passes unchanged on both the release and the DFD-armed debug core. -
rayforce-qis 2.6.0 (69b4210), from 2.5.0 (c7582d2), pinned by its tag again. Two changes reach this crate through the C clientQConnectionis built on. Every connection now has kernel TCP keepalive at the core's 60 s default, so a publisher that vanishes without closing (a VPN tunnel reaped, a NAT flow dropped) fails the connection andSubscription::is_aliveturns false within about a minute, where it used to wait forever; the other side is that an idle connection whose network is down for over a minute is closed where it used to resume, and this crate does not yet expose rayforce-q'sQ_KEEPALIVE_OFF. And a connection this process opened takes frames up to 2 GiB, where every frame stopped at 256 MiB, so a publisher's catch-up no longer loses its largest frames; a pushed frame a subscription cannot decode now closes it, with the reason on stderr, instead of being dropped with the connection left open. A port served withserve_qkeeps the 256 MiB limit. 2.6.0 needs core v2.11.0 or later, forray_sock_set_keepalive. -
bindgen is 0.73, from 0.70. 0.73 parses C11
_Atomicin both of its spellings, so the_Atomicto__volatiledefines 1.3.1 added torayforce-sys's build script for bindgen 0.70 are gone. The MSRV stays 1.74; bindgen 0.73 needs 1.71.
1.3.2¶
Added¶
q::decode_response_colsdecodes a complete Q response message keeping only the named columns of a table, in the order named, as q'scols#tdoes. The others are skipped by their serialized length: no string is built for a cell of a string column and no symbol of a symbol column is interned, so a caller that lands a few columns of a wide table no longer pays for all of them. rayforce-q measured a 100 000-row, 142-column window kept to 23 columns at ~43 ms, against ~182 ms for the whole message. Skipping reads only lengths, so a timespan column, whichq::decode_responserefuses, can be dropped. A name the table lacks is anErrwhose message names it (q: column `x` not in table), and so are a name given twice and one holding a NUL; every name is checked before any column is decoded. For a keyed table the key table comes back whole and the names pick among the value columns. A message that is not a table, or an empty name list, decodes asq::decode_responsedecodes it, and a compressed message is still decompressed whole.rayforce-sysdeclares the C function,q_decode_cols.
Changed¶
rayforce-qis 2.5.0 (c7582d2), from 2.4.0 (df8ced9). The vendored core stays at v2.11.5 (5d5f47c). 2.5.0 addsq_decode_colstoq.h. The rest ofq.c's change is the fix below, andq_decodeand the dict and keyed-table construction moved into helpers both decoders share. rayforce-q has pushed no 2.5.0 tag, so it is pinned by the merge of its pull request, whose tree is the "Release 2.5.0" commit's.rayforce-sys's build script refuses aRAYFORCE_Q_SRCcheckout whoseq.hdoes not declareq_decode_cols, naming 2.5.0, where a caller ofq::decode_response_colswould have failed to link.
Fixed¶
-
A table decoded from the Q wire is no longer released twice when a column cannot be added to it.
ray_table_add_colconsumes the table on every path, failure included, and rayforce-q's table builder released it again. A table whose column is an atom reaches this from the wire on every decode path, where the error object reusing the freed block masked it; onray_table_add_col's out-of-memory paths the block was freed twice. -
q::decode_responsepasses its error buffer asc_char, where it spelled iti8, which does not type-check wherec_charis unsigned, as on aarch64 Linux. -
CI and the release workflow point bindgen at libclang itself. They took the first file under
/usr/libmatchinglibclang*.so*, which also matches LLVM'slibclang_rtruntime. On the runners of 2026-10-20 that came first, so bindgen found no libclang and the build script panicked. They now take the libclang of the installedclang's LLVM, or the newest other one, and load it to check it exportsclang_createIndexfirst. The README's Linux example had the same search and is fixed the same way.
1.3.1¶
Changed¶
-
The vendored core is v2.11.5 (
5d5f47c) andrayforce-qis 2.4.0 (df8ced9), from v2.11.0 and 2.3.0.include/rayforce.his unchanged, and thebindings.rsbindgen generates changes only in the opaqueray_runtime_s, 48 to 560 bytes for the limiting cgroup's directory; at that size bindgen no longer derivesCopy/Clonefor it, and the crate only ever holds it by pointer. The nineteenINTERNAL_FNSsignatures are unchanged. Of the private headers bindgen reads,src/lang/eval.h,src/lang/env.h,src/core/runtime.handsrc/ops/ops.hgrow for compiled closures and for the row provenance of helpers called from a query, andsrc/lang/internal.hgainsray_null_class; none is inINTERNAL_FNS. The new sourcessrc/ops/select_merge.c,src/ops/symcmp.c,src/table/symgrp.candsrc/table/symimp.care picked up by the Makefile'ssrc/*/*.cwildcard and the packaged globs, and link against nothing new; the Makefile changes only its fuzz targets. rayforce-q has pushed no 2.4.0 tag, so it is pinned by its "Release 2.4.0" commit.q.cis unchanged; 2.4.0 adds(.q.handle), the connection whose pushed frame a handler is running for, whichq_server.cnow records around each dispatch. The builtin is registered by rayforce-q'sembed/rayforce_q.c, which this crate does not compile, andq_conn_currentis not declared inrayforce-sys, so it does not reach this crate yet. -
rayforce-syserases both spellings of_Atomicfor bindgen's parse. v2.11.5'ssrc/lang/env.hdeclaresextern _Atomic uint64_t g_global_epoch, an atomic spelled as a qualifier at file scope, which bindgen 0.70 cannot type and aborts on ("Couldn't resolve constant type"), and which the function-like-D_Atomic(T)=Tdefine could not reach._Atomicis now defined as__volatileand__volatileas a function-like macro, so_Atomic(T)reads asTand_Atomic Tas a volatileT.rayforce-sys1.3.0 cannot generate its bindings against a v2.11.5 core given throughRAYFORCE_SRC. Only bindgen's parse is affected: no allowlisted item holds an atomic, and the core is compiled by its own Makefile. -
The v2.11.5 engine deltas that reach this crate outside
eval.Table::insert_rowappends an empty string to a string column, which raisedvec_append: str vectors use ray_str_vec_append.Table::upsert_columnson a table with mapped columns, such as one fromTable::load_splayed, checks each value column's length against the key's and fails withlength, where a shorter column was read past its end.Table::save_splayedkeeps one copy of a replaced table on disk instead of about three: only the selected generation is kept, and the first publish removes the root column files of the layout it retires.Table::load_splayedwhose generation is removed while it opens retries for as long as the table keeps changing (at least eight times, for up to 60 seconds) and stops on an interrupt, where it failed with an I/O error after eight attempts;Table::load_partedreuses unchanged partitions across mounts.Value::deserializerefuses a lambda whose closure is not a dict of symbol keys to a list of values withtype. Every block the core allocates outside its pools, the string arenas and hash tables of the symbol table and column domains included, now counts toward the spill watermark, and the default watermark sits a quarter below the RAM the process may use (at least 3 GiB below, at most half of it) rather than at all of it. Spill files are opened close-on-exec, so a.sys.execchild no longer holds them. -
What changes through
evaland the query builders. Closures compile to bytecode, their captures bound as locals, andreturnends a lambda on its first, interpreted call as it does once compiled. A lambda called from a query reads the query's source columns, and aby:key the planner cannot lower is evaluated once over the table and grouped on as a computed key.incompares a string element of a collection as one value (it answered per character), works over string columns inside a query (it raisednyi) and answers an empty boolean vector for an empty left operand, so(where (in keys held))no longer raises whenkeysis empty. Nulls of different types are different values inin,find,distinctand join keys, while numeric nulls still meet across widths, as1meets1i;not-inexcludes null symbols. A dict row given toupsertmay leave out non-key columns, and a row that leaves out a list column gets the null object, where reading the cell aborted. Ordered symbol columns compare by their text; a sort or take over a grouping with no outputs works on a computed key; a multi-keyselecttakes a distinct count beside an expression aggregate; nested selects are composed before planning. Parquet imports go column by column when memory is short, hash indexes store 32-bit row ids and slots below 2^32 rows, scans read ahead on cold storage, andsys-mappedcounts file mappings in whole pages, where it drifted below zero.
1.3.0¶
Added¶
- Q subscriptions. A
QConnectioncan now be handed to the event loop withattach, turning it into aSubscriptionthat receives frames the peer pushes unsolicited — a tickerplant or a dict-form publisher. The plain client could not do this: it is blocking request/response, so a pushed frame would be read as the answer to the next call.
New Poll (the runtime's event loop), Subscription
(send / execute / is_alive), and env::bind_vary / env::bind_unary
for binding a Rust handler under the name a publisher calls. A handler is a
type implementing env::VaryFn / env::UnaryFn; the generated trampoline
borrows the arguments and catches panics, so the whole surface is safe.
Poll and Subscription are !Send like every other engine-backed handle,
so Runtime::scope refuses to let them out.
-
Q listener.
Poll::serve_q(port)registers therayforce-qlistener on the runtime's event loop, returning aQListener. Peers are served while the loop runs: a sync string is evaluated and answered, an async(upd; payload)is dispatched to whateverupdnames in the environment. A runtime that definesupdin Rayfall and serves a port is whatrayforce -qis, embedded. -
Guarded IPC.
Poll::serve_ipc(port)serves the native IPC from a runtime, built withbudgetandclockand started withstart, which returns anIpcServer. A request that runs past the budget, or whose client closes its connection, is cancelled through the core's interrupt; a client's own cancel works as before.IpcServer::drainhands back each request's end: the whole text, longer than the 256 characters the core's query log keeps, how long it was evaluated, and whether it was answered, failed or cancelled, and by whom. Nothing on the Q wire goes through the guard, so a writer there is never cancelled. One per process, because the core's hooks are globals. -
q::encode— the mirror ofq::decode_response: turn aValueinto a complete Q wire message for a transport you own. Together they let you write a Q publisher, not just a client. -
Value::attrs— the attribute byte. Rarely needed, but it is the only way to tell a keyed table (a 2-element list carryingRAY_ATTR_DICT) from a plain list, which no type code distinguishes.
Changed¶
-
rayforce-qis 2.3.0 (f4378a4). The vendored core stays at v2.11.0 (dc9b12a). 2.3.0 is the hardening series: bounded nesting and overflow-checked sizes on both codec walks, server responses queued through the poll's writable events so a slow reader no longer parks the loop, large frames received in 64 KiB chunks, the blocking client shutting its socket down after a framing failure so a stale response cannot answer the next request, and header-only or malformed frames closing the connection. Two behaviour changes reach this crate: Q timespans now decode to an error instead of a wrong timestamp, and a keyed table round-trips as a Q dict of two tables. -
rayforce-syscompilesrayforce-q'sq_server.calongsideq.c. rayforce-q 2.1.0 is a floor rather than a preference — theq_conn_*API does not exist in 2.0.0. The server-side changes 1.2.0 lists as not reaching this crate — the deadline on poll-attached sync sends,.q.*registered as restricted builtins — now do.
1.2.0¶
Added¶
-
rayforce-sysexports the four functions core v2.11.0 adds toinclude/rayforce.h:ray_git_commit(the commit the library was built from —CORE_COMMIT, for the vendored build),ray_sys_ram_limit(the smaller of physical RAM and the process's cgroup memory limit),ray_sym_bytes(the global symbol table's string storage) andray_ipc_send_timeout(ray_ipc_sendwith a round-trip deadline, which closes the connection on expiry). Of these, the safe crate wraps onlyray_ipc_send_timeout, below. -
TcpClient::send_timeoutandTcpClient::execute_timeoutbound a request with aDurationthat covers the whole round trip, sending the request included. On expiry the core cancels the query on the server, as Ctrl-C does, closes the connection and answers anioerror. The client then stays closed: a reply carries no request id, so the connection cannot be reused. The timeout is rounded up to whole milliseconds, andDuration::ZEROis refused with abindingerror, because the core reads 0 as no deadline. -
GUID vectors can be built and read in bulk.
[u8; 16]is now aVecElemmapped toRAY_GUID, soValue::vecbuilds a GUID vector with one copy,as_slice::<[u8; 16]>()borrows it without copying, andpush/setare type-checked against it.Value::guid_vecandValue::guid_slicename the two bulk paths; an all-zero cell is the GUID null.
Changed¶
-
rayforce-sysbindsray_ipc_tx_infofrom the core's privatesrc/core/ipc.h, which is now one of the headers bindgen reads.TcpClientuses it to tell whether a failed send closed its connection. That header completesstruct ray_poll, soray_pollis now generated as an opaque 320-byte blob instead of an incomplete type, the same treatmentray_runtime_sgets. Only pointers to it cross the API. With it comeray_ipc_tx_info_t, a plain six-field struct, and nothing else of the poll's or its selectors' private layout. -
The vendored core is v2.11.0 and
rayforce-qis 2.2.0 (139e4fd) (from v2.9.1 and1eabaf4). Apart from the four additions above, the binding layer holds still. The opaqueray_runtime_sgrows from 40 to 48 bytes by aram_limitfield, which nothing here can see: the crate only ever holds the runtime by pointer. The thirteenINTERNAL_FNSsignatures are unchanged. Of the private headers bindgen reads,src/lang/internal.hdeclares the.parquet.*builtins and passesray_hsend_fnan argument array,src/ops/ops.hadds a shared-node result memo toray_graph_t, andsrc/core/platform.haddsray_os_cgroup_mem_limit— none of them inINTERNAL_FNS, andray_graph_tis not in the public surface. The new sourcessrc/core/crc32.c,src/io/parquet.candsrc/store/stream.care picked up by the Makefile'ssrc/*/*.cwildcard and the packagedvendor/rayforce/src/**globs, and link against nothing new. The Makefile now also writes abuild/.git-hashstamp each time it runs: underOUT_DIRfor the vendored build, inside the checkout for aRAYFORCE_SRCone.
rayforce-q 2.2.0 is a release commit on top of cdbdecb that touches only
its VERSION and changelog, so the q client built here is the one the two
entries below describe. Upstream has pushed no 2.2.0 tag, so the pin is a
commit, as before. Its server-side changes — a deadline on poll-attached sync
sends, .q.* registered as restricted builtins — do not reach this crate,
which builds only q.c.
-
The v2.10.0 and v2.11.0 engine deltas that reach this crate outside
eval.TcpClientconnections run TCP keepalive after the handshake, so a server that vanishes without closing (a suspended host, a NAT dropping the flow) is detected in about 60 s, and a pendingTcpClient::executeorsendfails with anioerror instead of waiting forever.Value::serializerefuses a value that holds a lazy query-graph handle at any depth.Table::save_splayedpublishes its replacement atomically, and a failed first write removes the files it produced even when the directory was there before it. In a container, the heap starts spilling to disk at the cgroup's memory limit rather than at the host's physical RAM. -
What changes through
evaland the query builders. The language gains native Parquet reads, scans and imports (.parquet.read,.parquet.scan,.parquet.meta,.parquet.splayed,.parquet.partedand more) and an optional deadline on.ipc.send. A zero of any numeric or temporal type is now falsy; onlyi64andf64zeros were, so anifover ani32zero took the then-branch. An integeravgdivides the exact 128-bit sum, where the grouped engines divided a wrapped 64-bit one. Comparing an integer column with a computed float scalar, such as(avg x), compared every row with 0. Aselectof a bare aggregate beside scalar expressions over aggregates answers one row instead of one per input row; grouping by a computed key works beside distinct aggregates; SYM and temporal columns keep their type through the eval fallback and a compiledxbar;ifhandles vector conditions, mixed branch types and one-element conditions; andshowprints every column and row of a table. Storing a null into a narrow SYM column no longer clears the cells after it or writes past the column's end. A journal base takes one writer, and.sys.gcand.log.*reject arguments. The rest is performance: radix grouping with per-worker work stealing, fused top-k that prunes chunks by their zone extrema, parallel splayed CSV loads and hash index builds, and a memo that runs a shared query node once. -
q minute, second and month values decode by unit.
QConnection::executeandq::decode_responsere-tagged them without converting: a minute or second count became that many milliseconds ofTIME, and a month count that many days ofDATE, so01:30read back as00:00:00.090and2024.03mas2000.10.17. They now come back as01:30:00.000and2024.03.01— the month's first day, as q's`date$gives it. A value the target cannot hold, such as the0Winfinities or a datetime past theTIMESTAMPrange, decodes to the typed null instead of wrapping into a plausible wrong value. -
The q decoder rejects more malformed responses, and reports them differently. A table frame without the
0 99marker used to decode as a table and is now refused; a login handshake answering a capability above 3 fails ashandshake/auth failed; a compressed body declaring more than 256 MiB fails asdecompression failed. A failure inside the body used to surface asQ: q: trailing bytes after objectwhatever its cause.q_decodenow hands back the decoder's own error object, which arrives as an engineRayError— butrayforce-qpasses its reason as the error code, which the core cuts to seven bytes, so it readsq: bufforq: malfwith an empty message. That wording is an upstream defect; do not match on the text of these errors.
Fixed¶
-
A
TcpClientwhose connection the core has closed no longer reaches the next connection opened. A handle is a slot in the runtime's poll, and the next connection takes the lowest free one. When a server went away mid-request, the core closed the connection and freed its slot, but the client kept the handle. Its nextsendthen went to whichever connection had taken the slot since, and dropping it closed that connection. A failed send now asks the core whether the handle is still live, and if not the client forgets it: later calls fail without touching the network, and drop closes nothing. An error the server answers with leaves the connection open. The core's keepalive in v2.11.0 closes dead connections more often, and everysend_timeoutexpiry closes one, which is why this mattered now. -
count()of afilter()in aselectaggregate counts the rows the predicate keeps.col("size").filter(col("price").gt(150.0)).count()failed withtype: filter: vec and mask must be lists, on v2.11.0 as on v2.9.1; the query path now lowers it to a sum ofif pred 1 0. -
Value::lenis 0 for an atom, andis_emptyis true. It read the object's length field, which an atom shares with its value, soValue::i64(42).len()answered 42. Dicts answer their key count. -
Fn::newaccepts whitespace inside the opening form, such as( fn [x] (* x x)), which the engine evaluates as a lambda but the source check refused with abindingerror.
1.1.2¶
Changed¶
-
The vendored core is v2.9.1 and
rayforce-qis still1eabaf4(from v2.9.0). Nothing in the binding layer moves:include/rayforce.his unchanged, and thebindings.rsbindgen generates is byte-identical to v2.9.0's. Of the private headers bindgen reads,src/lang/eval.hgains thewhile/timesforms,src/lang/internal.hgainsray_fold_while_fn, andsrc/ops/ops.hgrowsray_graph_tby the select-alias andif-arm fields and addsray_active_query_literal— none of them inINTERNAL_FNS, whose thirteen signatures are unchanged, andray_graph_tis not in the public surface. No source file is added or removed, sostage_core, the packaged include globs and the link libraries are untouched; the Makefile's new Windows (MSYS2) branch is dormant on the platforms this crate builds on. -
The v2.9.1 engine deltas that reach this crate.
Value::deserializerejects a buffer whose payload runs past the object it encodes with adomainerror (N trailing payload bytes) instead of answering the object:ray_dechecked the header length but not that the object consumed all of it. Everything else arrives througheval. The language gainswhile,timesandfold-while. In aselect, a projection sees the projections before it, so a computed column is usable by the next one in the same query. Window, sort and limit queries release their input table on every path, and a grouped top-N keeps at most N groups when keys tie.likeandinover text columns run their typed column kernels; a join skips per-cell null tests on key columns proven null-free; the worker pool wakes only the workers a dispatch can keep busy and hands each worker its freed blocks back at the end of every dispatch. The.sys.*builtins validate their integer arguments and report arity errors for extra arguments, and the server binary rejects a flag given as another flag's value, and unknown options. The i64 formatting fix (a 32-bitlongon the way out) and the Windows port — a WSAPoll event loop, Winsock errno mapping, the platform layer — change nothing on Linux or macOS.
Fixed¶
-
Table::head(n)andTable::tail(n)use the magnitude ofn, as the table transform page says and astakedoes with the sign. Both handednstraight to the core's signedtake, sohead(-2)answered the last two rows andtail(-2)the first two;i64::MINis abindingerror now instead of an overflow. -
A zero-copy slice of an empty vector is
&[]without reading the data pointer.slice::from_raw_partsrequires a non-null, aligned pointer even for a zero length, so the empty case answers before the core's data pointer is consulted. -
Value::getrefuses a value that is neither a vector nor a list with abindingerror, andValue::is_null_atanswersfalsefor a non-vector or an out-of-range index, so neither reachesray_list_get/ray_vec_is_nullwith an atom, a table or a dict. -
q::decode_responsevalidates the frame header before the body: a big-endian frame, or a message type other than RESPONSE (2), is refused with an explicit error, as the C client already does insideq_send.QConnection::executegoes through that C path and was never affected.
1.1.1¶
Changed¶
-
The vendored core is v2.9.0 and
rayforce-qis still1eabaf4(from v2.8.0). Nothing in the binding layer moves:include/rayforce.hchanges by a comment only, and thebindings.rsbindgen generates is byte-identical to v2.8.0's. Of the private headers bindgen reads, onlysrc/lang/internal.hchanges —ray_system_fntakes the argument-array form andray_unsized_read_budgetis new — and neither is inINTERNAL_FNS. No source file is added or removed, sostage_core, the packaged include globs and the link libraries are untouched. -
The table accessors are total over
ray_t. Handed a value that is not aRAY_TABLE,ray_table_ncols/ray_table_nrowsanswer 0,ray_table_col_nameanswers -1,ray_table_schemaandray_table_get_col*answer null, and the two setters do nothing, where v2.8.0 decoded the argument's payload as table slots and could segfault.ray_table_add_colanswers atypeerror and releases itstblargument — the consume-on-error contract a bad column already had.Tableonly ever passes a table, andTable::newalready relied on that release, so this reaches rawrayforce-syscallers only. -
The v2.8.1 and v2.9.0 engine deltas that reach this crate, all through
eval..sys.execanswers the exit code a shell would report —3forexit 3, 128 + signum for a signal — rather than the rawwaitpidstatus (768), and a second argument'outanswers{code, out}with stdout captured;.sys.infogainspidandhostname.guidis seeded from the OS per thread: it ran offrand()'s default seed before, so every process drew the same GUID sequence, and two processes writing GUID row ids into one table collided.readandread-bytesread to EOF, so/procand/sysfiles and FIFOs come back with their content instead of an empty string; an unsized stream is bounded by the heap's headroom and fails withiopast it. A groupedselectnested two levels under awhereon a count is no longer filtered by that outer threshold — count-of-count underwhere (> n N)came back empty when the inner counts fell belowN. Over a splayed or parted table whose symbol column is backed by a symfile,like,strlen, anifbranch and a computed group key read the vocabulary off the mapping instead of interning all of it into the global symbol table. Grouping scales with cores, and atakeon a grouped result selects its top N natively. The core's fixes for IPC lifecycle hooks on threads with no VM do not reachTcpClient, which is confined to the runtime's thread.
1.1.0¶
Added¶
-
CI runs the suite against a debug-flavour engine. Set
RAYFORCE_CORE_DEBUG=1andrayforce-sysbuildslibrayforce.awith-DDEBUG, which compiles in the core's invariant checks and its stale retain/release detector; arm it at runtime withRAY_DFD=1. This is the only tool that sees a use-after-free inside the engine'smmap-backed pool allocator — AddressSanitizer and Valgrind trackmalloc, which the engine never calls, and Miri cannot execute the C library at all. Thetestjob now runs both flavours; the debug leg reproduces theValue-outliving-Runtimecrash below on the commit before its fix. Both legs then assert the archive they built:ray_dfd_check_livemust be present on the debug leg and absent on the release one. Without that pair, a break in theRAYFORCE_CORE_DEBUGplumbing would turn the debug leg into a second release run that stays green. -
The IPC tests run in CI.
tests/ipc.rsdrivesTcpClientagainst a spawned server and was returning early for want of one — which reports as a pass, so the gap was invisible. CI now builds the server binary, andRAYFORCE_REQUIRE_SERVER=1turns a missing one into a failure rather than a skip.tests/q_real.rsstill opts out viaRAYFORCE_Q_ADDR: it needs a realqserver, which cannot be provisioned on a runner. -
rayforce-sysbinds the core's new public entry points. v2.7.0 addedray_delete— an in-place row removal on a named table that keeps its key map — andray_shallow_bytes/ray_retained_bytes, the native footprint of a value in two well-defined senses, meant for charging a foreign runtime's garbage collector for a wrapper that keeps a Rayforce value alive. All three are declared ininclude/rayforce.h, so bindgen picks them up with no hand-written declaration; the safe crate does not wrap them yet.
Changed¶
-
The vendored core is v2.8.0 and
rayforce-qis1eabaf4(from v2.5.8 and 2.0.0). The core now recognises in-band nulls at construction, which changes what a vector built from a raw buffer reports:Value::vec(&[1i64, i64::MIN, 3])answersis_null_at(1)andget(1)returns the null singleton, where before the sentinel was ordinary data untilset_nullmarked it — the engine scans the payload once and raisesHAS_NULLS, so such values no longer aggregate as data. The empty symbol and the empty string are now their types' nulls:is_null_atreports them, butgetreturns the empty atom rather than the null singleton, soto_vec::<String>()keeps working andto_vec::<Option<String>>()yieldsNonefor them.set_null(idx, false)is a no-op in the core; overwrite the element withsetinstead. The docs no longer describe a "null bitmap": nulls are sentinels behind aHAS_NULLSfast-path hint. -
A failed
TcpClient::connectsays why. Every negative return from the core collapsed intoconnect to {host}:{port} failed, which reads the same whether nothing was listening, the password was wrong, or the peer speaks a wire version this build would misparse every atom of. The core distinguishes six causes — v2.6.1 added two of them — so the message now ends inconnection refused,authentication failed,wire version mismatch,timed out, or the OS error text, the same shapeQConnection::connect_withhas always used for its own three codes. Two of those read less plainly than they look:timed outalso covers a server that is alive but busy inside a long evaluation, because the core foldsEAGAIN/EWOULDBLOCKin withETIMEDOUT, and a host that fails to resolve surfaces asNo route to host, which is theerrnothe core stamps on that failure.rayforce-sysgainedRAY_IPC_ERR_*constants for the codes, mirroring theQ_ERR_*ones — the public header declares no contract for them, so they are maintained by hand againstconnect_fail_code()in the core, and an unrecognised code still falls through toconnection refused. One cause stays out of reach:server requires authenticationneeds a null password, and an empty&strarrives as a valid pointer to an empty string. -
QConnectionno longer takes the process down when a q peer disappears. Therayforce-qpin moves off the 2.1.1 tag to1eabaf4— six fixes toq.c, the one file of that repo this crate compiles, and no tag carries them yet. Writing to a closed peer used to raiseSIGPIPE, whose default disposition kills the process: a library has no business doing that to its host, andq_send_allnow passesMSG_NOSIGNAL(SO_NOSIGPIPEon the BSDs). A reply is accepted only when the frame says it is one, instead of any message type being decoded as the answer to the request in flight. A q identity reply (::, what an assignment answers) decodes to the null object rather than failing the exchange with "unsupported wire type". A nativeRAY_DICTresult now encodes, where the serializer had no branch for it and gave up. And a malformed reply whose decode left trailing bytes freed an error object throughray_releaserather thanray_error_free.q.his untouched, so nothing in this crate's FFI declarations moves. -
count (distinct …)counts a null as a value insideby:groups. The v2.6.1 core retires the per-group kernel's null-skipping arm: a groupedcount distinctover a null-bearing column now answers one more than it did, matching what the ungrouped form has always returned. The old convention was not even self-consistent — the serial, partitioned and per-group-buffer kernels disagreed, so the answer moved with the row count, the group count and the core count. Nothing in this crate's surface changes; the numbers coming back fromSelect::by(…)do. -
The rest of the v2.6.1 engine deltas that reach this crate.
.csv.readalso accepts Rayfall's dotted temporal spellings (2024.01.02,2024.01.02D01:02:03) alongside the ISO forms the CSV writer emits, so a file written bydumpround-trips.ifwith a null branch no longer writes an ordinary huge number where a null belongs — a null atom stays null across widths, and anF64past theint64range narrows to the integer null rather than an undefined cast. A periodic timer that overruns its period re-arms at the next deadline instead of replaying every fire it missed, and a failing callback printstimer <id>: error: <code>: <message>. The engine binary now exits 1 when-pcannot bind, rather than running the script and exiting 0 with no listener — relevant totests/ipc.rs, which spawns one.update where:andupsertwrite in place on a named flat table; the builders here pass a table value rather than a quoted name, so they keep taking the copy path and are unaffected. -
The v2.6.2 engine deltas that reach this crate. A
Value::sliceof a vector holding nulls inherits its parent'sHAS_NULLShint; before, every gate that reads the bit took the window for null-free, so an aggregate over it folded the sentinel in as a value.is_null_aton a slice already asked the parent and is unchanged.Table::save_splayedderives the on-disk bit from the payload rather than trusting the in-memory header, so a column holding a sentinel reloads with its nulls.nil?(Operation::NilQ) is element-wise outside queries as it always was inside them: over a vector or a list it answers aB8vector, where it used to answerfalse. The parser rejects a symbol, keyword, name or number glued to a quote, a colon or another name character —['a:1]is a parse error rather than the two symbolsaand1, and so is0Na— so Rayfall text handed toevalmust separate its tokens.Table::load_partedno longer reads a calendar-impossible directory such as2024.02.31as a date partition, which it used to normalise silently into2024.03.02; a root holding one falls back to symbol partitions..log.writeis refused inside an auto-journaled IPC evaluation. The rest lives in the engine binary rather than this crate's surface: the per-connection transmit backlog is configurable (.ipc.txlimit, 256 MiB by default — the old fixed cap),.mc.subno longer requires a filter argument, and a script or piped session stays alive until its pending timers are spent. -
The v2.7.0 and v2.8.0 engine deltas that reach this crate. A grouped
selectthat matches no row keeps every aggregate column and every literal projection column, so an empty result has the same shape as a populated one instead of losing columns. Every sort path keeps equal keys in source order, andpivotemits its index rows and pivot columns in first-seen order. Groupedvar/stddevare computed numerically stably, andpearson_corranswers null when either side has no variance rather than a division artefact.count (distinct …)accepts a symbol-vectorby:. Integer atoms order asi64— the integer arms of range comparison no longer round-trip throughf64.Table::load_partedorders integer partitions by value and rejects a directory name pasti64as corrupt. The table key map thatupsertbuilds is now kept on the table and maintained across in-place insert, non-key update and single-row upsert, so repeated keyed upserts stop rebuilding it; copying a table drops it, anddelete— new in v2.7.0, in place on a named table likeupdate where:— keeps it.find,inand dictatconsult a hash index when one is attached, admitSTRneedles, and carry the index acrossconcat. On the wire,.ipc.opentakes an options dict with acompressthreshold, loopback and UNIX-domain links are never compressed,.ipc.on.closefires for outbound connections too, and.ipc.handlereports the direction;TcpClientitself is unchanged, sinceray_ipc_connectkeeps its signature and the threshold variant is private to the core. The core's legacy IPC server API (ray_ipc_server_t,ray_ipc_poll) is gone; it was private and this crate never bound it. The rest lives in the engine binary:RAYFORCE_HOMEas the fallback root for relative paths, datalog integer arithmetic checked to0Nlon overflow and division by zero, journal archives preserved when rolls share a timestamp, and a multicast framing failure that no longer drops subscribers. -
A core-flavour switch rebuilds a
RAYFORCE_SRCcheckout from scratch. Release and debug objects share every filename andmaketracks headers but not flags, so a flavour flip would otherwise archive a mixed library. The build script now drops every object under the core'ssrc/and thelibrayforce.abeside them on the first build after the flags change, and records them in an untracked.stampfile — in your own checkout as well as underOUT_DIR, which previously had the only such check. Nothing tracked by git is touched. -
Breaking:
Runtime::scopereplacesRuntime::new.Runtime::newis private; the only way to a runtime isRuntime::scope(|rt| { … }), which creates it, hands the closure a&Runtimeyou cannot drop or move out of, and tears it down when the closure returns — on the error path and on unwind alike. A nested scope errors rather than starting a second runtime. Migration is mechanical: deletelet _rt = Runtime::new()?;, wrap the body, end it withOk(()). -
Nothing engine-backed leaves a scope.
Runtime::scoperequiresSendof its return type and of the closure, andValue,Table,Fn,TcpClientandQConnectionare all!Send— so returning one, or assigning one into a variable declared outside, is a compile error readingrequired by a bound in Runtime::scope. The cost is that an unrelated!Sendcapture (anRc, aRefCellborrow) is refused too, with a diagnostic about threads when no thread is involved; construct such values inside the closure, or move them in. -
Breaking:
is_live()is nowon_runtime_thread(), and answers a per-thread question rather than a per-process one. A live runtime is required for everything except reading and dropping handles you already hold:eval,set_global,get_global, the value constructors and the connection constructors all answer to this one predicate, which is true only inside a scope and only on the thread that entered it. Afalseresult does not mean a runtime can be created — one may be live on another thread, andRuntime::scopesays so.
Fixed¶
-
Engine calls from another thread are refused instead of segfaulting. The liveness flag was a process-wide
AtomicBool, but everything it guards is thread-local: the core's VM (__VM) and heap (ray_tl_heap) both are. So inside a scope, any other thread saw a live runtime and every guard passed —std::thread::spawn(|| rayforce::eval("(+ 1 1)"))crashed inray_eval_str, which dereferences__VMwith no null check, from safe code with nounsafeanywhere. Constructors were quieter but not better: off-threadValue::sym("hello")succeeded, allocating into a per-thread heap that noray_runtime_destroywould ever unmap. The guard is now a thread-local, so those calls panic naming the thread; creating a runtime stays process-wide, because the core's__RUNTIMEis an unguarded global that a secondray_runtime_createwould overwrite in silence. -
A
Valuecan no longer outlive itsRuntime. Dropping the runtime unmaps the engine heap, so a handle still alive afterwards released into memory that is no longer mapped. No check at the point of use could have helped:ray_t.rccounts references to an object, whileray_runtime_destroymunmaps every pool without consulting it, and by the time a stale handle is used the thing to check is the pointer — which is what became invalid.Runtime::scoperemoves the shape instead: the closure's locals are dropped before the runtime is, and itsSendbounds stop a value leaving.Valuestays one pointer wide, with no bookkeeping on clone or drop. -
The connection types are confined to their scope too.
TcpClientandQConnectionhad no liveness tracking of any kind, so a client outliving itsRuntimecalledray_ipc_close/q_closeagainst an unmapped heap. Both are now!Send/!Syncwithcompile_failmarkers pinning it, which is what the scope's bounds read, and bothDrops run before the runtime's. -
Building a value requires a live
Runtime.Value::i64(1)with no runtime was safe Rust calling straight into the engine with no check at all. It did not crash, which is why it went unnoticed:ray_alloclazily maps a heap when none exists, so the value landed in an orphan one. The sharp case was symbols, which are runtime-scoped —Value::sym("hello")returned an empty symbol, dropping the string with no error anywhere. -
The runtime tears down its event loop.
TcpClient::connectinstalls a poll on first use andray_runtime_destroydoes not touch it, so it leaked.Runtime'sDropnow takes it down first, while the heap it releases selector state into is still there. -
QConnectionis!Send/!Sync, like every other handle in the crate. It was a bare file descriptor, so it inferred both, whileexecuteinterns symbols and builds engine objects that belong to the runtime's thread. -
Building with
--no-default-features(nochrono) is now warning-free.
1.0.1¶
Added¶
- Decode Q wire messages from an external transport. New
q::decode_responseturns a complete Q IPC message (8-byte wire header + body, compressed or not) into aValue. This lets socket I/O live in a separate transport thread that owns a plainTcpStreamand just moves bytes, while deserialization into engine objects stays on the thread that owns theRuntime. Q server-side errors surface asErr.
1.0.0¶
Initial release of the Rust bindings for RayforceDB v2.
Added¶
- Value model. A single reference-counted
Valuehandle (Clone= retain,Drop= release) covering all atom types — bool,u8,i16/i32/i64,f32/f64, symbol, string, date, time, timestamp, and GUID — plus typed nulls. - Containers. Vectors with zero-copy
as_slice::<T>()reads, lists, and dictionaries. - Tables.
Table::newfrom typed columns, column/row accessors,head/tail/take, and inner/left/asof joins. - Query DSL. A fluent builder over
selectandupdatewithcol(..)expressions, arithmetic operator overloads, comparison and aggregation methods, filtering, grouping (by), and ordering. - CSV & splayed I/O.
read_csv/write_csv, plussave_splayed,load_splayed, andload_partedfor on-disk columnar data. - Serialization.
Value::serialize/Value::deserializeround-trips using RayforceDB's native wire format. - Conversions.
ToValue/FromValuefor native Rust types and an optionalchronofeature (default) for temporal interop. - IPC client.
TcpClientto connect to a running RayforceDB server,executequeries, andsend/send_asyncvalues.
Notes¶
- A single live
Runtimeper process;Value,Table, andTcpClientare!Send/!Sync. - An embedded IPC server, window joins, pivots, and feature-gated dataframe/SQL plugins are planned for future releases.