Skip to content

v1.0 stage3: transactional record writing - #1265

Open
jgabry wants to merge 14 commits into
v1.0from
v1.0-stage3-record-writing
Open

v1.0 stage3: transactional record writing#1265
jgabry wants to merge 14 commits into
v1.0from
v1.0-stage3-record-writing

Conversation

@jgabry

@jgabry jgabry commented Sep 10, 2026

Copy link
Copy Markdown
Member

Submission Checklist

  • Run unit tests
  • Declare copyright holder and agree to license (see below)

Summary

This PR and the summary below were written with the assistance of AI coding tools. I have reviewed all of the code.

Stage 3 of the v1.0 compilation-state work (#1258): every executable compile() installs now has a build record beside it, and the two are installed as one transaction. Nothing reads the record yet; the rebuild decision that consumes it is Stage 4. No dev-version bump, since no public contract changes.

What lands:

  • compile() assembles a format 1 record from what it already holds and hands it to install_executable(), which installs the executable and the record together: both previous files are moved aside, the new executable is renamed in, the record is written beside it, and the pair is verified by reading the record back against the installed executable's hash. Any failure after the backups are made restores both.
  • The options cmdstanr adds to stanc_options (warn-pedantic, use-opencl, allow-undefined, the model name) accumulate in their own list and are merged with the caller's only where the arguments are built, so stanc_options_supplied and stanc_options_injected are both values the code holds and neither is reconstructed. stanc_name is recorded raw, my-model_model for my-model.stan.
  • The record's dependencies are the compiled Stan file, the files stanc --info reports it included, in that order, the user header when there is one and make/local when it exists, each by content hash with the path it was built from. stanc --info now runs through one function that $variables() and the record share.
  • reported_features comes from <exe> info on the built binary, present only when known; tbb_dir from one make query run with the build's own variables, so a TBB_LIB supplied on the call is seen and a relative one is recorded absolute.
  • Two detectors, a make/local that includes another makefile and a user header that includes other headers, fill known_untracked_dependencies, and a build that has any prints one note naming force_recompile = TRUE. Nothing on a no-op.
  • Two NEWS entries and a vignette subsection on the record's lifecycle: whatever ignores the executable ignores the record, with the .gitignore, .Rbuildignore and copied-executable cases.

Every rule has a sentence in dev-notes/compilation-state-contract.md (§1, §4, §6) and a test that fails if the rule is removed: tests/testthat/test-build-record-compile.R for what a build records, test-utils.R for the transaction and its rollback, test-build-record.R for the interleaved-writes case the hash catches.

Part of #1238, #1257 and #1258. Closes nothing on its own.

Copyright and Licensing

Please list the copyright holder for the work you are submitting
(this will be you or your assignee, such as a university or company):
Jonah Gabry

By submitting this pull request, the copyright holder is agreeing to
license the submitted work under the following licenses:

The four places compile() adds a stanc option (warn-pedantic, use-opencl,
allow-undefined and the model name) wrote into the same list as the options
the caller supplied, so by the time anything could record the build the two
were indistinguishable. They now accumulate in their own list and the two are
merged only where they become arguments. No behaviour changes: stanc receives
the same arguments in the same order.

Part of #1258.
After a successful build, compile() assembles a format 1 build record from
what it already holds and writes it beside the executable: the options the
caller supplied and the ones cmdstanr added, kept apart; the name stanc was
passed; the effective include paths; the hashes and paths of the Stan file,
the files stanc reports it included, the user header and make/local; the
features the executable reports, present only when known; the CmdStan
installation; the TBB directory this build resolved, asked of make with the
build's own variables; and whether make/local or the user header includes
files nothing here can track. Nothing reads the record yet.

Three helpers do the parts compile() cannot do in a line: reading the
features the executable reports, asking make for the TBB directory, and
running the two untracked-dependency detectors. stanc --info now runs
through one function that both the variables method and the record use.
An empty cpp_options list is a named list so it writes as a JSON object.

Part of #1258.
install_executable() now takes the record and installs both files together:
the old executable and the old record are moved aside, the new executable is
renamed in, the record is written beside it, and the pair is verified by
reading the record back against the installed executable's hash. Any failure
after the backups are made puts both previous files back, so a destination
never holds a new executable with old provenance or a record describing a
binary that is not there. compile() wrote the record after install in the
previous commit; it now hands the record to install_executable() instead.

verify_build_record() is the check the transaction ends with, on its own so
the interleaved-writes case can be tested by writing the four files by hand.
Two builds racing to one destination can leave executable B beside record A
with every individual write atomic; the hash is what catches it.

Part of #1258.
A build whose make/local includes another makefile, or whose user header
includes other headers, now prints one note after the record is written,
naming force_recompile = TRUE as the way to pick up changes to those files.
Nothing is said on a no-op, a dry run or a failed build.

NEWS gains entries for the record file and the note, and the internals
vignette a subsection on the record's lifecycle: ignore it wherever the
executable is ignored, in .gitignore and .Rbuildignore, and move or copy it
with the executable.

Part of #1258.
install_executable() staged the executable, moved the old pair aside,
renamed the new executable in and only then serialised the record, and
each of its five failure points had its own recovery branch. Some of
those branches checked whether the recovery worked and some did not, so
a second filesystem failure during rollback could leave the old
executable stranded at its backup path while the error said it had not
been modified. A serialisation failure could also leave a partial record
in the directory with nothing naming it.

The function now stages both files beside the destination first, so
nothing there changes until both exist. The moves that follow are a
list, run in order and undone in reverse if a later one fails or the
installed pair does not read back, with every result checked in one
place. The error either says the executable and record are as they were
or names the files it could not put back. write_build_record() removes
its staging file on exit whether or not it reached the rename.

The transaction tests are renumbered for the record's own staging
rename, four snapshots carry the new messages, and a test pins that a
failed serialisation leaves nothing behind.

Part of #1258.
Under WSL stanc is handed /mnt/<drive> paths and reports the files it
included in that spelling, which Windows R cannot open, so hashing them
failed every multi-file build after make had succeeded. The reported
paths now go back through wsl_safe_path(revert = TRUE) before they are
hashed. The stanc --info call also reads the temporary copy make
compiled rather than the model's own source, so the source hash and the
include list describe the same file.

reported_features_from_exe() promised that nothing <exe> info printed
could fail the build, but a line with nothing before the equals sign
passed its filter as a nameless flag and the schema then rejected the
record outside the helper. Missing version lines were written as "..".
The filter now keeps entries with a name and a stan_version of three
dotted integers only.

Part of #1258.
test-build-record-compile.R built its mocked models on the shared
resources/stan/bernoulli fixture, and the mock writes a text file where
the executable goes. Every test file that runs after it alphabetically
then ran that text file as the model, which is why CI failed in the
sampling tests on three platforms with "./bernoulli: 1: mock: not found"
while single-file local runs passed. Each test now copies the program
into its own directory first.

Part of #1258.
The included-files test now includes two files in non-sorted order with
one of them twice and asserts the whole sequence, and pins the
include_paths default in the same build. A build with stan_opencl and a
user header pins the use-opencl and allow-undefined injection sites. The
note test checks a dry run and a failed build say nothing.

Part of #1258.
The roxygen and the design's tbb_dir row said the helper exists because
get_cmdstan_flags() runs flag-free, which has not been true since Stage
1 gave it make_args. It exists to read both TBB variables in one make
call and resolve a relative TBB_LIB. Both now also say the value is
recorded as the shell prints it, so repeated spaces and glob characters
in a TBB_LIB are not preserved, the same as every make query cmdstanr
runs. Contract regenerated.

Part of #1258.
When the rollback in install_executable() could not move a newly installed
file out of the way but the old file's return then renamed over it, the
error still named the restored file as left behind. A successful undo now
drops its destination from the stuck list, so the message says the pair is
as it was. A fresh install whose new record will not move still names it.

Part of #1258.
…e share

wsl_safe_path(revert = TRUE) only knew /mnt/<drive>, so an include reached
through //wsl$/<distro>/... came back as a bare Linux path that Windows R
could not hash, and a successful build failed while assembling its record.
A path starting with a single slash and not under /mnt/ now gets the share
prefix; host paths already carry a drive or a share and pass through. The
three revert calls in csv.R had the same gap for paths CmdStan prints.

The vector test gains a native path and a share path, and a new test compiles
a model with its include written under the share, run only under WSL.

Part of #1258.
@codecov-commenter

codecov-commenter commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.27660% with 7 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (v1.0@faa40ab). Learn more about missing BASE report.

Files with missing lines Patch % Lines
R/utils.R 92.50% 6 Missing ⚠️
R/model.R 98.07% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             v1.0    #1265   +/-   ##
=======================================
  Coverage        ?   92.87%           
=======================================
  Files           ?       16           
  Lines           ?     6916           
  Branches        ?        0           
=======================================
  Hits            ?     6423           
  Misses          ?      493           
  Partials        ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Every Windows CI job failed the tbb_dir test at 1718854. CmdStan's
print-% rule echoes a variable through RTools sh, which spells the
installation's TBB directory as /c/... and drops the backslashes of a
supplied Windows path. Printing the value with $(info) from a fragment
makefile avoids the shell, but that is a second make-query mechanism plus
per-platform path conversion for a corner the Windows launch has never
handled.

The record now names the directory the call's TBB_LIB gives, resolved
against the installation when relative, and the installation's own
lib/tbb when the call names none. A TBB_LIB or TBB_BIN set in
make/local, ~/.config/stan/make.local or the environment still moves the
TBB the binary links against and no longer reaches the record. The
design's field row, the environment bullet in the untracked list and
the rejected alternatives say so, and the contract is regenerated.

Part of #1258.
@jgabry
jgabry marked this pull request as ready for review September 13, 2026 00:20
The helper read the raw cpp_options list, so a repeated TBB_LIB recorded
the first value where make acts on the last, a vector value errored after
a successful make, and FALSE recorded a directory called FALSE where make
sees an empty assignment. It now goes through parsed_cpp_options(), the
same conversion the record's cpp_options_supplied field uses.

A TBB_BIN named on the call is honoured too, in the order the makefile
uses: TBB_LIB, or TBB_BIN when TBB_LIB is empty, both resolved against
the installation when relative. The design row, the roxygen and the
tests say so.

Part of #1258.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants