Changelog¶
All notable changes to rayforce are documented here. This project adheres to
Semantic Versioning.
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. -
The submodules are addressed over SSH.
.gitmodulesnow points atgit@github.com:RayforceDB/rayforce.gitandrayforce-q.git. An existing clone picks the change up withgit submodule sync --recursive; CI needs nothing, sinceactions/checkoutrewritesgit@github.com:to https with the job token. Without a GitHub SSH key, setgit config --global url."https://github.com/".insteadOf "git@github.com:"before initializing the submodules — and, for agit = "https://…"Cargo dependency,net.git-fetch-with-cli = truein~/.cargo/config.tomlso Cargo fetches through git and honours the rewrite. crates.io users are unaffected: the C sources ship inside the crate. -
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.