Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 16 additions & 28 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
name: build

# Builds and tests both PHP bindings (FFI cdylib + ext-php-rs extension) for the tantivy-php
# workspace, per spec §6 (docs/superpowers/specs/2026-07-08-tantivy-php-ext-php-rs-binding-design.md)
# and plan Task 9 (docs/superpowers/plans/2026-07-08-tantivy-php-ext-php-rs-binding.md).
# Builds and tests the ext-php-rs PHP extension for the tantivy-php workspace, per spec §6
# (docs/superpowers/specs/2026-07-08-tantivy-php-ext-php-rs-binding-design.md) and plan Task 9
# (docs/superpowers/plans/2026-07-08-tantivy-php-ext-php-rs-binding.md).
#
# Scope is pinned to PHP 8.4 NTS, x86_64, per the project's stated constraint (see spec §10
# no-objetivos: no ZTS, no other PHP versions, no macOS).
Expand Down Expand Up @@ -36,10 +36,9 @@ jobs:
# against whichever ABI php-config reports, so we assert NTS explicitly below rather
# than trust the default silently.
tools: none
# ffi must be loaded for the FFI-backend smoke; ffi.enable=1 alone does nothing if the
# extension isn't present. The ext-backend smoke needs no PHP extension of its own.
extensions: ffi
ini-values: ffi.enable=1
# The ext backend IS the extension under test (loaded via -d extension= in the smoke
# step), so no PHP extension needs to be preinstalled here.
extensions: none

- name: Assert PHP build is NTS (project is pinned to 8.4 NTS only)
run: php -v | grep -q 'NTS' || { echo "::error::expected an NTS PHP build, got:"; php -v; exit 1; }
Expand All @@ -58,33 +57,27 @@ jobs:
php -v
php-config --version

# Builds crates/tantivy-ffi (libtantivyphp.so) and crates/tantivy-ext (libtantivyphp_ext.so)
# from the workspace root in one pass. On this Linux setup (stable Rust + clang present),
# no special env vars (LIBCLANG_PATH / BINDGEN_EXTRA_CLANG_ARGS) are needed — see progress
# notes in docs/superpowers/sdd/progress.md for the historical libclang-only fallback.
- name: cargo build --release (builds both cdylibs)
# Builds crates/tantivy-ext (libtantivyphp_ext.so) from the workspace root. On this Linux
# setup (stable Rust + clang present), no special env vars (LIBCLANG_PATH /
# BINDGEN_EXTRA_CLANG_ARGS) are needed — see progress notes in
# docs/superpowers/sdd/progress.md for the historical libclang-only fallback.
- name: cargo build --release
run: cargo build --release

- name: cargo test (tantivy-core + tantivy-ffi roundtrip)
- name: cargo test (tantivy-core + tantivy-ext)
run: cargo test

- name: cargo clippy -D warnings
run: cargo clippy --release --all-targets -- -D warnings

- name: Smoke test — FFI backend (ffi.enable, TANTIVYPHP_LIB)
env:
TANTIVYPHP_LIB: ${{ github.workspace }}/target/release/libtantivyphp.so
run: php -d ffi.enable=1 php/tests/smoke.php

- name: Smoke test — ext backend (extension=, no ffi.enable needed)
run: php -d extension="${{ github.workspace }}/target/release/libtantivyphp_ext.so" php/tests/smoke.php

# Package the Linux half of the 4-artifact matrix (spec §6): FFI cdylib as-is, ext cdylib
# renamed to tantivyphp.so so it drops straight into extension_dir per the install guide.
# Package the Linux ext artifact: the ext cdylib renamed to tantivyphp.so so it drops
# straight into extension_dir per the install guide.
- name: Stage Linux artifacts
run: |
mkdir -p dist/linux
cp target/release/libtantivyphp.so dist/linux/libtantivyphp.so
cp target/release/libtantivyphp_ext.so dist/linux/tantivyphp.so

- name: Upload Linux artifacts
Expand All @@ -99,8 +92,7 @@ jobs:
runs-on: windows-latest
# Recipe mirrors ext-php-rs's own upstream Windows CI (setup-php + nightly, no dev-pack step,
# default MSVC linker). Kept `continue-on-error: true` only until this job has gone green here
# at least once — a Windows-ext failure then never blocks the pipeline, and the FFI cdylib
# already covers Windows customers who can enable ffi.enable=1. Flip to false once it's green.
# at least once — a Windows-ext failure then never blocks the pipeline. Flip to false once green.
continue-on-error: true
steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -153,11 +145,7 @@ jobs:
php -d extension="${{ github.workspace }}\target\release\tantivyphp_ext.dll" -m
php -d extension="${{ github.workspace }}\target\release\tantivyphp_ext.dll" -r "var_dump(class_exists('Tantivy\Native\Index'));"

# Windows half of the 4-artifact matrix (spec §6). NOTE: this only stages the ext .dll.
# The FFI Windows artifact (tantivyphp.dll, "existing path" per spec §6) is not built by
# this workflow yet — it was previously flagged (docs/superpowers/sdd/progress.md, "Carried
# items") as not verified in any environment and is tracked separately, not part of this
# ext-focused spike/CI task.
# Stages the Windows ext .dll artifact.
- name: Stage Windows ext artifact
shell: pwsh
run: |
Expand Down
8 changes: 0 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
[workspace]
resolver = "2"
members = ["crates/tantivy-core", "crates/tantivy-ffi", "crates/tantivy-ext"]
members = ["crates/tantivy-core", "crates/tantivy-ext"]

[workspace.package]
version = "0.1.0"
edition = "2021"
description = "Embed the tantivy search engine in PHP, via a Rust cdylib (FFI) or a native ext-php-rs extension."
description = "Embed the tantivy search engine in PHP, via a native ext-php-rs extension."
repository = "https://github.com/InvGate/php-tantivy"
license = "MIT OR Apache-2.0"

[profile.release]
# Strip the local symbol table from distributed artifacts (no DWARF is emitted anyway).
# Exported ABI symbols live in .dynsym and are preserved, so FFI::cdef and the PHP extension
# entry point still resolve.
# Exported ABI symbols live in .dynsym and are preserved, so the PHP extension entry point
# still resolves.
strip = true

[workspace.dependencies]
Expand Down
46 changes: 12 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,33 @@

Embed the [tantivy](https://github.com/quickwit-oss/tantivy) search engine (Rust) in PHP.

The Rust engine is exposed to PHP through **two interchangeable backends** that share the
same core, so you can pick whichever your environment allows:

| Backend | Loaded via | Needs `ffi.enable`? | Artifact |
|---|---|---|---|
| **FFI** | `FFI::cdef` | yes | `libtantivyphp.so` / `tantivyphp.dll` (plain cdylib, PHP-version-independent) |
| **Native extension** | `extension=` in `php.ini` | **no** | `libtantivyphp_ext.so` / `tantivyphp_ext.dll` (built with [ext-php-rs](https://github.com/davidcole1340/ext-php-rs)) |

Use the FFI backend when FFI is available; use the native extension when a hardened
environment forbids `ffi.enable`. Both are behaviorally identical — same engine, same
JSON boundary, same results.
The Rust engine is exposed to PHP as a **native PHP extension** built with
[ext-php-rs](https://github.com/davidcole1340/ext-php-rs) and loaded via `extension=` in
`php.ini` — no `ffi.enable` required. Artifact: `libtantivyphp_ext.so` / `tantivyphp_ext.dll`.

## Requirements

- PHP **8.4**, NTS, x86_64 (Linux or Windows)
- For the FFI backend: `ffi.enable=1` and the cdylib on disk
- For the native extension: the extension loaded via `php.ini` (no FFI)
- The extension loaded via `php.ini`

## Install / build

See [docs/BUILD.md](docs/BUILD.md). In short:

```bash
cargo build --release # builds both libtantivyphp.so and libtantivyphp_ext.so
```

Then either:

```ini
; FFI backend
ffi.enable=1
; and point TANTIVYPHP_LIB / TANTIVYPHP_HEADER at the cdylib + include/tantivyphp.h
cargo build --release # builds target/release/libtantivyphp_ext.so
```

or:
Then load it:

```ini
; native extension backend
extension=tantivyphp_ext.so
```

## Usage

The PHP-facing API is a single facade, `Tantivy\Client`, which auto-selects the backend
(native extension if loaded, else FFI). Consumers depend only on `Tantivy\Client` and
`Tantivy\ClientInterface`.
The PHP-facing API is a single facade, `Tantivy\Client`, backed by the native extension.
Consumers depend only on `Tantivy\Client` and `Tantivy\ClientInterface`.

```php
use Tantivy\Client;
Expand Down Expand Up @@ -85,18 +66,15 @@ from refresh; batch your writes and commit periodically for throughput.

```
crates/tantivy-core Rust engine (schema, index registry, writer, query) — binding-agnostic
crates/tantivy-ffi C-ABI cdylib (the FFI backend)
crates/tantivy-ext ext-php-rs native extension (the extension backend)
include/tantivyphp.h C header for the FFI backend
php/src PHP client: Client (facade), ClientInterface, FfiClient, ExtClient
php/tests/smoke.php backend-agnostic smoke test
crates/tantivy-ext ext-php-rs native extension
php/src PHP client: Client (facade), ClientInterface, ExtClient
php/tests/smoke.php smoke test (requires the extension loaded)
```

## Testing

```bash
cargo test # Rust core + FFI roundtrip
TANTIVYPHP_LIB=target/release/libtantivyphp.so php -d ffi.enable=1 php/tests/smoke.php
cargo test # Rust core + ext
php -d extension=target/release/libtantivyphp_ext.so php/tests/smoke.php
```

Expand Down
41 changes: 38 additions & 3 deletions crates/tantivy-core/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ fn table() -> &'static Mutex<HashMap<u64, IndexState>> {
TABLE.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Toma el lock de la tabla recuperándose de un envenenamiento. Si un panic desenrolló mientras
/// otro llamador tenía el lock, `lock()` devuelve `PoisonError`; hacer `.unwrap()` propagaría ese
/// panic y dejaría la tabla inutilizable para TODO el proceso (crítico en un worker PHP-FPM de larga
/// vida). Un panic aislado no corrompe el HashMap en sí, así que recuperamos el guard y seguimos.
fn lock_table() -> std::sync::MutexGuard<'static, HashMap<u64, IndexState>> {
table().lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}

/// Abre un índice existente o lo crea en `cfg.path`. Devuelve un handle opaco.
pub fn open_or_create(cfg: IndexConfig) -> Result<u64, String> {
std::fs::create_dir_all(&cfg.path).map_err(|e| format!("no se pudo crear el dir: {e}"))?;
Expand Down Expand Up @@ -78,7 +86,7 @@ fn register_state(index: Index, id_field: &str, writer_heap_bytes: usize) -> Res
};

let handle = NEXT_HANDLE.fetch_add(1, Ordering::SeqCst);
table().lock().unwrap().insert(handle, state);
lock_table().insert(handle, state);
Ok(handle)
}

Expand All @@ -87,7 +95,7 @@ pub fn with_state<T>(
handle: u64,
f: impl FnOnce(&mut IndexState) -> Result<T, String>,
) -> Result<T, String> {
let mut guard = table().lock().unwrap();
let mut guard = lock_table();
let state = guard
.get_mut(&handle)
.ok_or_else(|| format!("handle inválido: {handle}"))?;
Expand All @@ -96,7 +104,7 @@ pub fn with_state<T>(

/// Cierra y descarta el índice. Devuelve true si existía.
pub fn close(handle: u64) -> bool {
table().lock().unwrap().remove(&handle).is_some()
lock_table().remove(&handle).is_some()
}

#[cfg(test)]
Expand Down Expand Up @@ -170,6 +178,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn recovers_after_a_poisoned_lock() {
let dir = std::env::temp_dir().join(format!("tv_test_{}_poison", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let h = open_or_create(cfg(dir.to_str().unwrap())).unwrap();

// Envenenamos el mutex global: forzamos un panic mientras with_state tiene el lock tomado.
// (silenciamos el hook de panic para no ensuciar la salida del test con el backtrace esperado.)
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = with_state(h, |_s| -> Result<(), String> {
panic!("boom con el lock tomado")
});
}));
std::panic::set_hook(prev_hook);
assert!(panicked.is_err(), "la clausura debía paniquear");

// Con .lock().unwrap() esto panicaría (mutex envenenado) y dejaría la tabla inutilizable
// para todo el proceso. Al recuperar el lock envenenado, el registro sigue operativo.
let n = with_state(h, |s| s.doc_count()).unwrap();
assert_eq!(n, 0);
assert!(close(h));

let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn open_read_only_fails_on_missing_index_and_opens_existing() {
let dir = std::env::temp_dir().join(format!("tv_test_{}_readonly", std::process::id()));
Expand Down
2 changes: 1 addition & 1 deletion crates/tantivy-core/src/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::registry::IndexState;
/// Prefijo estable y neutro (no depende del idioma del mensaje) que marca el caso "el writer lock
/// exclusivo del índice está tomado por otro proceso" (p. ej. un rebuild en curso). Los bindings PHP
/// lo mapean a `Tantivy\IndexBusyException` para que los consumidores chequeen el TIPO, no el texto.
/// Es un contrato: si se cambia acá, actualizar los clientes (FfiClient/ExtClient).
/// Es un contrato: si se cambia acá, actualizar el cliente (ExtClient / TantivyException::forOperation).
pub const WRITER_LOCKED_PREFIX: &str = "index_locked:";

/// Obtiene (o crea) el IndexWriter cacheado del estado. tantivy permite un solo writer por
Expand Down
51 changes: 50 additions & 1 deletion crates/tantivy-ext/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ pub struct Index {
handle: u64,
}

/// RAII: cuando PHP libera el objeto `Tantivy\Native\Index` (refcount a 0 / GC), ext-php-rs dropea
/// este struct y liberamos el estado del registro. Sin esto, cada open sin un close() explícito fuga
/// un IndexState (reader mmap + FDs de segmentos, y hasta el heap del writer) por toda la vida del
/// proceso — crítico en un worker PHP-FPM de larga vida que abre un índice por request. close() es
/// idempotente, así que un close() manual previo más este Drop no se pisan.
impl Drop for Index {
fn drop(&mut self) {
registry::close(self.handle);
}
}

#[php_impl]
impl Index {
#[php(name = "openOrCreate")]
Expand Down Expand Up @@ -69,7 +80,7 @@ impl Index {

#[php(name = "optimize")]
pub fn optimize(&self) -> PhpResult<()> {
// v1: no-op, igual que el path FFI (tv_optimize).
// v1: no-op (el merge se agenda en el plan de rebuild). Nunca falla.
Ok(())
}

Expand All @@ -95,3 +106,41 @@ impl Index {
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
module.class::<Index>()
}

#[cfg(test)]
mod tests {
use super::*;
use tantivy_core::schema::{FieldsDescriptor, IndexConfig};

fn cfg(path: &str) -> IndexConfig {
IndexConfig {
path: path.to_string(),
id_field: "id_key".into(),
fields: FieldsDescriptor {
text: vec!["title".into()],
keys: vec!["id_key".into()],
attributes: vec![],
},
writer_heap_bytes: 15_000_000,
}
}

#[test]
fn dropping_index_releases_the_registry_handle() {
let dir = std::env::temp_dir().join(format!("tv_ext_drop_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);

let handle = registry::open_or_create(cfg(dir.to_str().unwrap())).unwrap();
let index = Index { handle };
drop(index);

// Si Drop cerró el handle, close() acá devuelve false (ya no está). Sin Drop, el estado
// seguiría en el registro (fuga) y close() devolvería true.
assert!(
!registry::close(handle),
"Drop debía liberar el handle del registro"
);

let _ = std::fs::remove_dir_all(&dir);
}
}
12 changes: 0 additions & 12 deletions crates/tantivy-ffi/Cargo.toml

This file was deleted.

Loading
Loading