Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
* `asyncWrites` parameter on the `VectorDatabase` constructor and on `VectorDatabase::open()`, defaulting to `false`, to opt into writing document files in forked child processes.

### Changed
* Document files are written synchronously by default. `addDocument()` used to fork a child process for every document whenever `ext-pcntl` was loaded, which cost more than the write itself.

### Fixed
* An async write child ended with `exit(0)`, which ran the shutdown functions and destructors inherited from the parent. In a host application this could close resources the parent still used, such as a shared database connection. The child now ends with `SIGKILL`, so async writes also require `ext-posix`.
* Async write children were reaped only by `save()`, so bulk imports accumulated zombie processes. Finished children are now reaped on every async write.
* A failed async write went unnoticed. It now raises a `RuntimeException` when the child is reaped.

## [0.4.1] - 2026-09-02

Documentation only. No code changes, no behaviour changes.
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A pure-PHP vector database implementing **HNSW** (Hierarchical Navigable Small W

- PHP 8.2+
- No external PHP extensions required for core functionality
- `ext-pcntl` (optional) — enables asynchronous document writes for lower insert latency
- `ext-pcntl` and `ext-posix` (optional), for the opt-in asynchronous document writes (`asyncWrites: true`)

## Installation

Expand Down Expand Up @@ -187,10 +187,10 @@ $db = new VectorDatabase(

## Persistence

PHPVector uses a **folder-based** persistence model. Each database lives in its own directory containing separate files for the HNSW graph, the BM25 index, and one file per document. This design has two key advantages:
PHPVector uses a **folder-based** persistence model. Each database lives in its own directory containing separate files for the HNSW graph, the BM25 index, and one file per document. This design keeps memory low and inserts cheap:

- **Low memory footprint on load** — only the HNSW graph and BM25 index are loaded into memory. Individual document files (`docs/{n}.bin`) are read lazily, only for the documents that appear in search results.
- **Low insert latency** — document files are written to disk asynchronously in a forked child process (requires `ext-pcntl`), so `addDocument()` returns immediately.
- **Incremental writes**. `addDocument()` writes only its own document file, so inserting does not rewrite the rest of the database.

### Folder layout

Expand All @@ -208,7 +208,9 @@ PHPVector uses a **folder-based** persistence model. Each database lives in its

### Saving

Pass a `path` to the constructor to enable persistence. Each `addDocument()` call writes the document file to `docs/` (asynchronously when `ext-pcntl` is available). Call `save()` once to flush the HNSW graph and BM25 index — it waits for any outstanding async writes before proceeding.
Pass a `path` to the constructor to enable persistence. Each `addDocument()` call writes the document file to `docs/`. Call `save()` once to flush the HNSW graph and BM25 index.

Document files are written synchronously by default. Passing `asyncWrites: true` to the constructor or to `open()` writes each one in a forked child process instead (requires `ext-pcntl` and `ext-posix`, otherwise writes stay synchronous), and `save()` waits for the pending writes before flushing the indexes. A fork usually costs more than writing one small file, so only enable it when document writes are slow, for example on network storage. The child ends without running shutdown functions or destructors, so it cannot close resources it shares with the parent, such as a database connection.

```php
use PHPVector\Document;
Expand Down
120 changes: 96 additions & 24 deletions src/Persistence/DocumentStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,16 @@
* [4 bytes: textLen] [textLen bytes: utf-8 text] (textLen=0 → null text)
* [4 bytes: metaLen] [metaLen bytes: JSON metadata] (metaLen=0 → empty array)
*
* Writes can be dispatched to forked child processes (pcntl_fork) to avoid
* blocking the caller. Call waitAll() before reading back or writing index files.
* Writes are synchronous by default. Async writes dispatch each one to a
* forked child process (pcntl_fork) and need both ext-pcntl and ext-posix;
* without them they fall back to synchronous writes. Call waitAll() before
* reading back or writing index files.
*
* An async child ends by sending itself SIGKILL instead of calling exit(), so
* it never runs the shutdown functions and destructors it inherited from the
* parent (which could, for example, close a database connection the parent
* still uses). Finished children are reaped on every async write, so a long
* import does not accumulate zombie processes.
*
* Every write goes through AtomicFile, so a reader loading `{nodeId}.bin`
* lazily always sees a complete record: either the previous version or the new
Expand Down Expand Up @@ -47,74 +55,138 @@ public function __construct(private readonly string $docsDir) {}
/**
* Persist a document to disk.
*
* When $async is true and pcntl_fork() is available the write is
* dispatched to a child process; the parent returns immediately.
* When unavailable the write is synchronous.
* When $async is true and async writes are supported (see
* supportsAsync()) the write is dispatched to a child process and the
* parent returns immediately. Otherwise the write is synchronous.
*
* @param int $nodeId
* @param string|int $docId Must NOT be null (UUID already assigned by caller).
* @param string|null $text
* @param array<string, mixed> $metadata
* @param bool $async
*
* @throws \RuntimeException if a synchronous write fails.
*/
public function write(
int $nodeId,
string|int $docId,
?string $text,
array $metadata,
bool $async = true,
bool $async = false,
): void {
if ($async && function_exists('pcntl_fork')) {
if ($async && self::supportsAsync()) {
$this->reapFinished();

$pid = pcntl_fork();

if ($pid === -1) {
// Fork failed — fall through to synchronous write.
} elseif ($pid === 0) {
// Child: write and exit immediately.
$this->writeSync($nodeId, $docId, $text, $metadata);
exit(0);
} else {
// Parent: record PID keyed by nodeId and return.
if ($pid === 0) {
// Child: never return into the caller's code, whatever happens.
try {
$this->writeSync($nodeId, $docId, $text, $metadata);
} catch (\Throwable $e) {
// The parent notices the missing file when it reaps this child.
error_log(sprintf('PHPVector: async write of node %d failed: %s', $nodeId, $e->getMessage()));
} finally {
posix_kill(getmypid(), SIGKILL);
}
}

if ($pid > 0) {
$this->pendingPids[$nodeId] = $pid;
return;
}

// Fork failed: fall through to a synchronous write.
}

// Synchronous path (no fork or fork failed).
$this->writeSync($nodeId, $docId, $text, $metadata);
}

/**
* Whether async writes can run in this process: forking needs ext-pcntl,
* and ending the child without running shutdown code needs ext-posix.
*/
public static function supportsAsync(): bool
{
return function_exists('pcntl_fork')
&& function_exists('pcntl_waitpid')
&& function_exists('posix_kill');
}

/** Number of async writes started but not yet reaped. */
public function pendingCount(): int
{
return count($this->pendingPids);
}

/**
* Block until the async write for a specific node has completed.
*
* Use this before deleting a node's file so a late child write cannot
* recreate {nodeId}.bin after the unlink().
*
* @throws \RuntimeException if the child finished without writing the file.
*/
public function waitForNode(int $nodeId): void
{
if (!isset($this->pendingPids[$nodeId])) {
return;
}

if (function_exists('pcntl_waitpid')) {
pcntl_waitpid($this->pendingPids[$nodeId], $status);
}

pcntl_waitpid($this->pendingPids[$nodeId], $status);
unset($this->pendingPids[$nodeId]);
$this->assertWritten($nodeId);
}

/**
* Block until every outstanding async write has completed.
* Must be called before index files are written (see VectorDatabase::save()).
*
* @throws \RuntimeException if a child finished without writing its file.
*/
public function waitAll(): void
{
foreach ($this->pendingPids as $pid) {
if (function_exists('pcntl_waitpid')) {
pcntl_waitpid($pid, $status);
$pending = $this->pendingPids;
$this->pendingPids = [];

foreach ($pending as $pid) {
pcntl_waitpid($pid, $status);
}
foreach (array_keys($pending) as $nodeId) {
$this->assertWritten($nodeId);
}
}

/**
* Reap children that already exited, without blocking.
*
* @throws \RuntimeException if a child finished without writing its file.
*/
private function reapFinished(): void
{
foreach ($this->pendingPids as $nodeId => $pid) {
if (pcntl_waitpid($pid, $status, WNOHANG) !== 0) {
unset($this->pendingPids[$nodeId]);
$this->assertWritten($nodeId);
}
}
$this->pendingPids = [];
}

/**
* Async children cannot report errors through their exit status (they end
* with SIGKILL), so a missing file is the failure signal.
*
* @throws \RuntimeException
*/
private function assertWritten(int $nodeId): void
{
clearstatcache(true, $this->filePath($nodeId));
if (!is_file($this->filePath($nodeId))) {
throw new \RuntimeException(sprintf(
'Async write of document file failed: %s',
$this->filePath($nodeId),
));
}
}

// ------------------------------------------------------------------
Expand Down
27 changes: 18 additions & 9 deletions src/VectorDatabase.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@
* loaded into memory by `open()`; individual `docs/{n}.bin` files are read on
* demand when search results are hydrated.
*
* Individual document files are written **asynchronously** (via `pcntl_fork`)
* on each `addDocument()` call when the extension is available. `save()`
* waits for all pending writes before flushing the index files.
* Individual document files are written on each `addDocument()` call,
* synchronously by default. With `$asyncWrites` each write runs in a forked
* child process instead (requires ext-pcntl and ext-posix). `save()` waits
* for all pending writes before flushing the index files.
*
* Quick start
* -----------
Expand Down Expand Up @@ -117,6 +118,10 @@ final class VectorDatabase
/**
* @param float $lockTimeout Seconds save() waits for the folder lock before
* throwing a LockTimeoutException.
* @param bool $asyncWrites Write document files in forked child processes
* (requires ext-pcntl and ext-posix, otherwise
* writes stay synchronous). Only worth it when a
* single file write is slower than a fork.
*/
public function __construct(
HNSWConfig $hnswConfig = new HNSWConfig(),
Expand All @@ -125,6 +130,7 @@ public function __construct(
private readonly ?string $path = null,
private readonly int $overFetchMultiplier = 5,
private readonly float $lockTimeout = FileLock::DEFAULT_TIMEOUT,
private readonly bool $asyncWrites = false,
) {
if ($overFetchMultiplier < 1) {
throw new \InvalidArgumentException('overFetchMultiplier must be at least 1.');
Expand Down Expand Up @@ -156,8 +162,8 @@ public function isPersistent(): bool
* Add a single document.
*
* If `$document->id` is null a random UUID v4 is assigned automatically.
* When a folder path is configured the document is written to disk
* asynchronously (pcntl_fork when available, synchronous otherwise).
* When a folder path is configured the document file is written to disk,
* in a forked child process when `$asyncWrites` is enabled.
*
* @throws \RuntimeException if a document with the same ID already exists.
*/
Expand Down Expand Up @@ -186,15 +192,15 @@ public function addDocument(Document $document): void
$this->hnswIndex->insert($document);
$this->bm25Index->addDocument($nodeId, $document);

// Persist doc file asynchronously when a path is configured.
// Persist the doc file when a path is configured.
if ($this->path !== null) {
$this->ensureDocsDir();
$this->getDocumentStore()->write(
nodeId: $nodeId,
docId: $document->id,
text: $document->text,
metadata: $document->metadata,
async: true,
async: $this->asyncWrites,
);
}
}
Expand Down Expand Up @@ -723,6 +729,7 @@ public function save(): void
*
* @param float $lockTimeout Seconds to wait for the folder lock before
* throwing a LockTimeoutException.
* @param bool $asyncWrites See the constructor.
*
* @throws LockTimeoutException if another process is writing the folder.
* @throws \RuntimeException on I/O failure or distance metric mismatch.
Expand All @@ -734,6 +741,7 @@ public static function open(
TokenizerInterface $tokenizer = new SimpleTokenizer(),
int $overFetchMultiplier = 5,
float $lockTimeout = FileLock::DEFAULT_TIMEOUT,
bool $asyncWrites = false,
): self {
$metaPath = $path . '/meta.json';
if (!file_exists($metaPath)) {
Expand All @@ -744,7 +752,7 @@ public static function open(
$lock->acquireShared($lockTimeout);

try {
return self::loadLocked($path, $hnswConfig, $bm25Config, $tokenizer, $overFetchMultiplier, $lockTimeout);
return self::loadLocked($path, $hnswConfig, $bm25Config, $tokenizer, $overFetchMultiplier, $lockTimeout, $asyncWrites);
} finally {
$lock->release();
}
Expand All @@ -761,6 +769,7 @@ private static function loadLocked(
TokenizerInterface $tokenizer,
int $overFetchMultiplier,
float $lockTimeout,
bool $asyncWrites,
): self {
$metaPath = $path . '/meta.json';
$meta = json_decode(file_get_contents($metaPath), true, 512, JSON_THROW_ON_ERROR);
Expand All @@ -776,7 +785,7 @@ private static function loadLocked(
));
}

$db = new self($hnswConfig, $bm25Config, $tokenizer, $path, $overFetchMultiplier, $lockTimeout);
$db = new self($hnswConfig, $bm25Config, $tokenizer, $path, $overFetchMultiplier, $lockTimeout, $asyncWrites);
$db->nextId = (int) $meta['nextId'];
$db->docIdToNodeId = $meta['docIdToNodeId'];

Expand Down
Loading
Loading