Skip to content

feat(r): add a function for lazy R callbacks - #975

Merged
paleolimbot merged 3 commits into
apache:mainfrom
eitsupi:r-function-array-stream
Oct 20, 2026
Merged

paleolimbot merged 3 commits into
apache:mainfrom
eitsupi:r-function-array-stream

Conversation

@eitsupi

@eitsupi eitsupi commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Add function_array_stream(fun, schema, validate = TRUE) to create an Arrow array stream that requests batches lazily from an R function. Construction and schema access do not call the function; returning NULL ends the stream. The explicit schema is copied at construction, and batches are converted and optionally validated against it. validate must be a single non-missing logical value.

The stream retains callback state across export and garbage collection and supports array_stream_set_finalizer(). The callback is wrapped in R with tryCatch(), so errors and interrupts become terminal stream errors. C evaluates the wrapper inside R_ToplevelExec(), so other non-local exits such as an abort restart cannot escape through the C stream interface. Reentrant calls are rejected, and callback evaluation is restricted to the R main thread. fun must not release the stream while it is being evaluated. Tests cover empty and multi-batch streams, coercion and validation, errors and interrupts, abort restarts, warnings, export, finalizers, release/GC behavior, and release from a non-R thread.

Related to #386

Assisted-by: Opus 5.5 and GPT-6.1 Sol

@eitsupi eitsupi changed the title feat(r): Add function_array_stream() for lazy R callbacks feat(r): add a function for lazy R callbacks Oct 18, 2026
@eitsupi

eitsupi commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Feedback requested on the evaluation boundary: the implementation uses R_ToplevelExec() around R_tryCatch() for errors and interrupts. R_tryCatch() alone does not contain non-local exits such as invokeRestart("abort"); allowing those jumps across an Arrow C Stream callback can bypass a C/C++ consumer's cleanup. The outer boundary converts these exits to EIO.

This isolation also means the caller's calling handlers do not apply inside the callback, and warnings are handled in the isolated top-level context unless the callback handles them itself. These semantics are documented. Is this boundary and its handler behavior appropriate for the R API, or would you prefer another approach using public R APIs?

@codecov-commenter

codecov-commenter commented Oct 18, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.60000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.44%. Comparing base (cc67c6b) to head (7ed4697).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
r/src/array_stream.c 97.56% 2 Missing ⚠️
r/src/nanoarrow_cpp.cc 93.33% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #975      +/-   ##
==========================================
+ Coverage   78.29%   78.44%   +0.14%     
==========================================
  Files         106      106              
  Lines       16914    17044     +130     
  Branches     2014     2016       +2     
==========================================
+ Hits        13243    13370     +127     
- Misses       2429     2432       +3     
  Partials     1242     1242              

☔ 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.

@eitsupi
eitsupi marked this pull request as ready for review October 18, 2026 01:49

@paleolimbot paleolimbot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this!

I have some suggestions about how to simplify the evaluation approach a little, although I think this implementation will also work and is close!

Comment thread r/src/array_stream.c Outdated
Comment on lines +183 to +191
if (data->release_pending) {
if (out->release != NULL) {
out->release(out);
}
out->release = NULL;
// The stream itself may already have been freed by a finalizer during evaluation.
release_function_array_stream_data(data);
return EINVAL;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I'm misunderstanding here, this is definitely not something that should be happening...a caller is required by the array stream contract to serialize calls and wait until a get_next result before releasing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed the deferred-release handling.
The docs now state that fun must not release the stream while it is being evaluated.

Comment thread r/src/array_stream.c Outdated

struct FunctionArrayStreamData {
struct ArrowSchema schema;
SEXP fun;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of storing the function, I think it is easier to store the expression and the environment. You can construct both at the R level...create a throwaway environment inheriting from the caller's environment; assign fun to it, then construct an expression that is a call to fun(). In C, then all you have to do is Rf_eval().

Comment thread r/src/array_stream.c Outdated
Comment on lines +120 to +122
SEXP result =
PROTECT(R_tryCatch(&function_array_stream_eval_call, callback, classes,
&function_array_stream_condition_handler, call, NULL, NULL));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can also do this at the R level (wrap the user function in an R tryCatch or withcallinghandlers before assigning it into the env that you save), which is a little simpler.

Comment thread r/src/array_stream.c Outdated
Comment on lines +174 to +181
// R_tryCatch() does not catch restart jumps or failures in conditionMessage().
// All R API calls must stay inside this outer boundary.
if (!R_ToplevelExec(&function_array_stream_eval, &call)) {
function_array_stream_set_error(
data, EIO, "R callback exited non-locally (e.g., via an abort restart)");
call.code = data->error_code;
}
data->evaluating = 0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not familiar with the distiction between R_TopLevelExec and a straight Rf_eval()...if we do need it, this comment would be a good place to explain it!

Comment thread r/R/array-stream.R Outdated
Comment on lines +103 to +104
function_array_stream_get_next_wrapper <- function(fun, schema, validate) {
result <- fun()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where I think you can do the tryCatch wrapper. At the C level you may still need an R_UnwindProtect in the event something about the tryCatch itself goes sidways; however at least for the first implementation I think it is better to keep the error handling in cushy R code (it can be moved to C later if based on a benchmark it's too slow).

Build the callback environment and its tryCatch() wrapper in R so the C
stream only preserves and evaluates an expression. Keep R_ToplevelExec()
as the boundary that stops other non-local exits, and drop support for
releasing the stream from its own callback.

@paleolimbot paleolimbot left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

@paleolimbot
paleolimbot merged commit 9e886d9 into apache:main Oct 20, 2026
12 checks passed
@eitsupi
eitsupi deleted the r-function-array-stream branch October 20, 2026 01:58
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.

3 participants