Skip to content

Commit d203f5b

Browse files
committed
feat(callable): add CachedCallable for cached function resolution (#310)
Adds `CachedCallable` type that caches `zend_fcall_info_cache` for repeated PHP function calls from Rust, skipping expensive string lookups and hash table searches on subsequent invocations. - `ZendCallable::cache()` resolves once via `zend_is_callable_ex` - `CachedCallable::try_call{,_with_named,_named}` calls via cached fcc - `CachedCallableError` enum with typed recovery (exceptions recoverable, engine failure poisons) - C wrappers for `zend_fcc_addref`/`zend_fcc_dtor` (inline, not bindgen-reachable) - Integration tests, gungraun benchmarks, guide docs
1 parent 6b2a610 commit d203f5b

File tree

13 files changed

+534
-6
lines changed

13 files changed

+534
-6
lines changed

benches/benches/binary_bench.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,31 @@ binary_benchmark_group!(
126126
benchmarks = callback_calls
127127
);
128128

129+
#[binary_benchmark]
130+
#[bench::single_cached_callback_call(args = ("cached_callback_call.php", 1))]
131+
#[bench::multiple_cached_callback_calls(args = ("cached_callback_call.php", 10))]
132+
#[bench::lots_of_cached_callback_calls(args = ("cached_callback_call.php", 100_000))]
133+
fn cached_callback_calls(script: &str, cnt: usize) -> gungraun::Command {
134+
setup();
135+
136+
gungraun::Command::new("php")
137+
.arg(format!("-dextension={}", *EXT_LIB))
138+
.arg(bench_script(script))
139+
.arg(cnt.to_string())
140+
.build()
141+
}
142+
143+
binary_benchmark_group!(
144+
name = cached_callback;
145+
config = BinaryBenchmarkConfig::default()
146+
.tool(Callgrind::with_args([
147+
CACHE_SIM[0], CACHE_SIM[1], CACHE_SIM[2],
148+
"--collect-atstart=no",
149+
"--toggle-collect=*_internal_bench_cached_callback_function*handler*",
150+
]).flamegraph(FlamegraphConfig::default()));
151+
benchmarks = cached_callback_calls
152+
);
153+
129154
#[binary_benchmark]
130155
#[bench::single_method_call(args = ("method_call.php", 1))]
131156
#[bench::multiple_method_calls(args = ("method_call.php", 10))]
@@ -177,5 +202,5 @@ binary_benchmark_group!(
177202
);
178203

179204
main!(
180-
binary_benchmark_groups = function, callback, method, static_method
205+
binary_benchmark_groups = function, callback, cached_callback, method, static_method
181206
);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
bench_cached_callback_function(fn ($i) => $i * 2, (int) $argv[1]);

benches/ext/src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ pub fn bench_callback_function(callback: ZendCallable, n: usize) {
2222
}
2323
}
2424

25+
#[php_function]
26+
pub fn bench_cached_callback_function(callback: ZendCallable, n: usize) {
27+
let cached = callback.cache().expect("Failed to cache callback");
28+
for i in 0..n {
29+
cached
30+
.try_call(vec![&i])
31+
.expect("Failed to call cached callback");
32+
}
33+
}
34+
2535
#[php_class]
2636
pub struct BenchClass;
2737

@@ -45,5 +55,6 @@ pub fn build_module(module: ModuleBuilder) -> ModuleBuilder {
4555
module
4656
.function(wrap_function!(bench_function))
4757
.function(wrap_function!(bench_callback_function))
58+
.function(wrap_function!(bench_cached_callback_function))
4859
.class::<BenchClass>()
4960
}

guide/src/types/functions.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,88 @@ pub fn test_method() -> () {
2626
2727
# fn main() {}
2828
```
29+
30+
## Cached Callables
31+
32+
When calling the same PHP function repeatedly from Rust, use `CachedCallable`
33+
to avoid re-resolving the function on every call. The first resolution caches
34+
the internal `zend_fcall_info_cache`, and subsequent calls skip all string
35+
lookups and hash table searches.
36+
37+
```rust,no_run
38+
# #![cfg_attr(windows, feature(abi_vectorcall))]
39+
# extern crate ext_php_rs;
40+
use ext_php_rs::prelude::*;
41+
use ext_php_rs::types::ZendCallable;
42+
43+
#[php_function]
44+
pub fn call_many_times(callback: ZendCallable) -> () {
45+
let cached = callback.cache().expect("Failed to cache callable");
46+
47+
for i in 0..1000i64 {
48+
let _ = cached.try_call(vec![&i]);
49+
}
50+
}
51+
# fn main() {}
52+
```
53+
54+
### When to use `CachedCallable`
55+
56+
- **Use `CachedCallable`** when calling the same callable multiple times (loops,
57+
event handlers, iterators like `array_map` patterns).
58+
- **Use `ZendCallable`** for single-shot calls where caching overhead is wasted.
59+
60+
### Error handling
61+
62+
`CachedCallable` returns `CachedCallableError` which provides granular
63+
error variants:
64+
65+
```rust,no_run
66+
# #![cfg_attr(windows, feature(abi_vectorcall))]
67+
# extern crate ext_php_rs;
68+
use ext_php_rs::prelude::*;
69+
use ext_php_rs::error::CachedCallableError;
70+
use ext_php_rs::types::ZendCallable;
71+
72+
#[php_function]
73+
pub fn resilient_caller(callback: ZendCallable) -> () {
74+
let cached = callback.cache().expect("Failed to cache");
75+
76+
match cached.try_call(vec![&42i64]) {
77+
Ok(result) => { /* use result */ },
78+
Err(CachedCallableError::PhpException(_)) => {
79+
// PHP exception — callable is still valid, can retry
80+
let _ = cached.try_call(vec![&0i64]);
81+
},
82+
Err(CachedCallableError::Poisoned) => {
83+
// Engine failure happened before — cannot reuse
84+
},
85+
Err(e) => { /* other errors */ },
86+
}
87+
}
88+
# fn main() {}
89+
```
90+
91+
### Named arguments
92+
93+
`CachedCallable` supports the same named argument methods as `ZendCallable`:
94+
95+
```rust,no_run
96+
# #![cfg_attr(windows, feature(abi_vectorcall))]
97+
# extern crate ext_php_rs;
98+
use ext_php_rs::prelude::*;
99+
use ext_php_rs::types::ZendCallable;
100+
101+
#[php_function]
102+
pub fn cached_with_named() -> () {
103+
let func = ZendCallable::try_from_name("str_replace").unwrap();
104+
let cached = func.cache().unwrap();
105+
106+
let _ = cached.try_call_named(&[
107+
("search", &"world"),
108+
("replace", &"PHP"),
109+
("subject", &"Hello world"),
110+
]);
111+
}
112+
# fn main() {}
113+
```

src/error.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,54 @@ impl From<Error> for PhpException {
147147
}
148148
}
149149

150+
/// Error type for [`CachedCallable`](crate::types::CachedCallable) operations.
151+
#[derive(Debug)]
152+
pub enum CachedCallableError {
153+
/// The callable could not be resolved at cache time.
154+
ResolutionFailed,
155+
/// The call mechanism itself failed (`zend_call_function` returned < 0).
156+
/// The `CachedCallable` is now poisoned and cannot be reused.
157+
CallFailed,
158+
/// A PHP exception was thrown during execution.
159+
/// The `CachedCallable` remains valid for subsequent calls.
160+
PhpException(ZBox<ZendObject>),
161+
/// The `CachedCallable` was poisoned by a prior engine failure.
162+
Poisoned,
163+
/// Integer overflow when converting parameter count.
164+
IntegerOverflow,
165+
/// A parameter could not be converted to a Zval.
166+
ParamConversion,
167+
}
168+
169+
impl Display for CachedCallableError {
170+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171+
match self {
172+
Self::ResolutionFailed => write!(f, "Could not resolve callable for caching."),
173+
Self::CallFailed => {
174+
write!(f, "Cached callable call failed; callable is now poisoned.")
175+
}
176+
Self::PhpException(e) => write!(f, "PHP exception thrown during cached call: {e:?}"),
177+
Self::Poisoned => write!(f, "Cached callable is poisoned by a prior engine failure."),
178+
Self::IntegerOverflow => {
179+
write!(f, "Converting integer arguments resulted in an overflow.")
180+
}
181+
Self::ParamConversion => write!(f, "A parameter could not be converted to a Zval."),
182+
}
183+
}
184+
}
185+
186+
impl ErrorTrait for CachedCallableError {}
187+
188+
impl From<CachedCallableError> for Error {
189+
fn from(e: CachedCallableError) -> Self {
190+
match e {
191+
CachedCallableError::PhpException(e) => Error::Exception(e),
192+
CachedCallableError::IntegerOverflow => Error::IntegerOverflow,
193+
_ => Error::Callable,
194+
}
195+
}
196+
}
197+
150198
/// Trigger an error that is reported in PHP the same way `trigger_error()` is.
151199
///
152200
/// See specific error type descriptions at <https://www.php.net/manual/en/errorfunc.constants.php>.

src/ffi.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ unsafe extern "C" {
4949
filename: *const c_char,
5050
) -> *mut zend_op_array;
5151
pub fn ext_php_rs_zend_execute(op_array: *mut zend_op_array);
52+
53+
pub fn _ext_php_rs_zend_fcc_addref(fcc: *mut _zend_fcall_info_cache);
54+
pub fn _ext_php_rs_zend_fcc_dtor(fcc: *mut _zend_fcall_info_cache);
55+
pub fn _ext_php_rs_cached_call_function(
56+
fcc: *mut _zend_fcall_info_cache,
57+
retval: *mut zval,
58+
param_count: u32,
59+
params: *mut zval,
60+
named_params: *mut HashTable,
61+
) -> ::std::os::raw::c_int;
5262
}
5363

5464
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ pub mod prelude {
6868
pub use crate::php_print;
6969
pub use crate::php_println;
7070
pub use crate::php_write;
71+
pub use crate::types::CachedCallable;
7172
pub use crate::types::ZendCallable;
7273
pub use crate::zend::BailoutGuard;
7374
#[cfg(feature = "observer")]

0 commit comments

Comments
 (0)