The sdsearch extension exposes three symbols to PHP: the function sdsearch_version()
and the classes SdSearch\Engine (search) and SdSearch\Writer (indexing). All data
crosses the boundary as JSON strings.
The full signatures with PHPDoc live in sdsearch.stub.php at the
repo root — point your IDE / PHPStan at it for autocompletion and type-checking (it is a
stub, never loaded at runtime). This page is the narrative guide with runnable examples.
Error handling. Every method throws a catchable
\Exceptionon any failure (malformed JSON, missing index, lock contention, internal error). The FFI boundary is panic-safe: an internal Rust panic becomes an\Exception, never a crashed PHP worker. Wrap calls intry/catch.
; php.ini
extension=sdsearch.so ; Linux
; extension=sdsearch.dll ; Windowsecho sdsearch_version(); // "0.1.0" — also a smoke test that the extension loaded| Method | Purpose | Throws |
|---|---|---|
__construct() |
Create an engine. | — |
search(string $indexDir, string $paramsJson): string |
Run a query, return hits as JSON. | bad params JSON, missing index, engine error |
| Method | Purpose | Throws |
|---|---|---|
__construct() |
Create a writer (not yet open). | — |
open(string $indexDir): void |
Take the write-lock + open. | index locked, open error |
try_open(string $indexDir): bool |
Like open but returns false if busy. |
any error except "locked" |
find_doc_id(string $idField, string $value): int |
<idField>_key:value → global doc id, or -1. |
writer not open |
find_doc_ids(string $field, string $value): int[] |
Literal <field>:value → all matching doc ids. |
writer not open |
delete_document(int $docId): void |
Mark a doc deleted (neg/out-of-range = no-op). | writer not open |
add_document(string $docJson): void |
Buffer a doc for the next commit. | bad JSON, unknown kind, writer closed |
commit(): int |
Flush adds+deletes, consume the writer. | writer not open |
optimize(): int |
Commit then merge into one segment, consume. | writer not open |
document_count(): int |
Live base + buffered − deletes. | writer not open |
commit() and optimize() consume the writer: after either, the object is closed and any
further method throws "writer not open". Create a fresh Writer for the next batch.
When to call optimize(). Each commit() adds one or more segments; the segment count
only shrinks when you optimize() (there is no automatic merge policy). A read (search) opens
every live segment, so an index that is committed many times without optimizing gets slower
and heavier to open. The recommended pattern for a bulk feed is open once → add_document
per doc → optimize() once at the end (rather than open/commit per document), which keeps
the index compacted to a single segment.
optimize() resource profile. The merge is streaming and bounded-memory: peak heap is a
per-term working set plus small per-document bookkeeping, independent of the corpus text
volume (on a ~135k-doc index, ~0.1 GB peak heap). While it runs it writes temporary files
(<segment>.fdt.tmp / .frq.tmp / .prx.tmp) into the index directory sized in aggregate
close to the final segment, so provision disk accordingly. Stale generation manifests
(segments_N) are pruned automatically after each commit/optimize.
use SdSearch\Writer;
$indexDir = '/var/lib/app/search-index';
$w = new Writer();
$w->open($indexDir); // throws if another writer holds the lock
// Add a document. Field kinds: "text" (tokenized+stored), "keyword" (exact+stored),
// "unindexed" (stored only).
$doc = [
'fields' => [
['name' => 'id_key', 'value' => '42', 'kind' => 'keyword'],
['name' => 'title', 'value' => 'How to reset a password', 'kind' => 'text'],
['name' => 'body', 'value' => 'Open settings, ...', 'kind' => 'text'],
['name' => 'status', 'value' => 'published', 'kind' => 'keyword'],
],
];
$w->add_document(json_encode($doc));
$w->commit(); // flush; writer is now closedDeletes are by internal doc id, so resolve first, then delete, then add the new version in the same batch:
$w = new Writer();
$w->open($indexDir);
$docId = $w->find_doc_id('id', '42'); // resolves id_key:42 → global id, or -1
if ($docId !== -1) {
$w->delete_document($docId);
}
$w->add_document(json_encode($updatedDoc));
$w->optimize(); // commit + compact into a single segment$w = new Writer();
if (!$w->try_open($indexDir)) {
// another worker is writing — skip this cycle instead of throwing/blocking
return;
}
// ... add/delete ...
$w->commit();use SdSearch\Engine;
$engine = new Engine();
$params = [
'text' => 'reset password',
'where' => [
['field' => 'status', 'values' => ['published'], 'occur' => 'must'],
],
'in' => [
['field' => 'category_key', 'values' => ['10', '11']],
],
'min_score' => 0.0,
'limit' => 20,
];
$json = $engine->search($indexDir, json_encode($params));
$hits = json_decode($json, true);
foreach ($hits as $hit) {
// $hit = ['id' => int, 'score' => float, 'fields' => ['name' => 'value', ...]]
printf("#%d score=%.3f %s\n", $hit['id'], $hit['score'], $hit['fields']['title'] ?? '');
}| Key | Type | Meaning |
|---|---|---|
text |
string | Free-text query over tokenized fields. |
where |
array | Each {field, values[], occur}; occur ∈ must | mustnot | should (default should). |
in |
array | Each {field, values[]}; matches the (literal, key-suffixed) field against any value. |
min_score |
float | Drop hits below this score. |
limit |
int | Maximum hits to return. |
Each hit is { "id": int, "score": float, "fields": { name: value, ... } }, where id is
the global internal document id and fields are the document's stored fields.
try {
$json = (new Engine())->search($indexDir, json_encode($params));
$hits = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
} catch (\Exception $e) {
// missing index, malformed params, or internal engine error — never a crashed worker
error_log('sdsearch: ' . $e->getMessage());
$hits = [];
}