166 lines
5.3 KiB
PHP
166 lines
5.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace NetBoxStore\Database;
|
|
|
|
use JsonException;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
final class JsonStoreRepository implements StoreRepository
|
|
{
|
|
private const MAX_DATABASE_BYTES = 134_217_728;
|
|
private string $lockPath;
|
|
|
|
public function __construct(private readonly string $path, private readonly int $maxDatabaseBytes = self::MAX_DATABASE_BYTES)
|
|
{
|
|
$this->lockPath = $path . '.lock';
|
|
}
|
|
|
|
public function initialize(): void
|
|
{
|
|
$directory = dirname($this->path);
|
|
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
|
|
throw new RuntimeException('Could not create datastore directory.');
|
|
}
|
|
if (!is_file($this->path)) {
|
|
$lock = $this->lock(LOCK_EX);
|
|
try {
|
|
if (!is_file($this->path)) {
|
|
$this->atomicWrite(State::empty());
|
|
}
|
|
} finally {
|
|
$this->unlock($lock);
|
|
}
|
|
}
|
|
@chmod($this->path, 0600);
|
|
$this->read();
|
|
}
|
|
|
|
public function read(): array
|
|
{
|
|
$lock = $this->lock(LOCK_SH);
|
|
try {
|
|
return $this->readUnlocked();
|
|
} finally {
|
|
$this->unlock($lock);
|
|
}
|
|
}
|
|
|
|
public function acquireLease(string $name): ?ExclusiveLease
|
|
{
|
|
$leasePath = $this->path . '.lease-' . substr(hash('sha256', $name), 0, 24) . '.lock';
|
|
$handle = fopen($leasePath, 'c+b');
|
|
if ($handle === false) {
|
|
throw new RuntimeException('Could not open exclusive lease file.');
|
|
}
|
|
@chmod($leasePath, 0600);
|
|
if (!flock($handle, LOCK_EX | LOCK_NB)) {
|
|
fclose($handle);
|
|
return null;
|
|
}
|
|
return new CallbackLease(static function () use ($handle): void {
|
|
flock($handle, LOCK_UN);
|
|
fclose($handle);
|
|
});
|
|
}
|
|
|
|
public function transaction(callable $callback): mixed
|
|
{
|
|
$lock = $this->lock(LOCK_EX);
|
|
try {
|
|
$draft = $this->readUnlocked();
|
|
$result = $callback($draft);
|
|
State::validate($draft);
|
|
$this->atomicWrite($draft);
|
|
return $result;
|
|
} finally {
|
|
$this->unlock($lock);
|
|
}
|
|
}
|
|
|
|
/** @return resource */
|
|
private function lock(int $operation): mixed
|
|
{
|
|
$handle = fopen($this->lockPath, 'c+b');
|
|
if ($handle === false || !flock($handle, $operation)) {
|
|
throw new RuntimeException('Could not acquire datastore lock.');
|
|
}
|
|
@chmod($this->lockPath, 0600);
|
|
return $handle;
|
|
}
|
|
|
|
/** @param resource $handle */
|
|
private function unlock(mixed $handle): void
|
|
{
|
|
flock($handle, LOCK_UN);
|
|
fclose($handle);
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function readUnlocked(): array
|
|
{
|
|
$size = @filesize($this->path);
|
|
if ($size === false || $size > $this->maxDatabaseBytes) {
|
|
throw new RuntimeException('JSON datastore is missing or exceeds 128 MB.');
|
|
}
|
|
$contents = file_get_contents($this->path);
|
|
if ($contents === false) {
|
|
throw new RuntimeException('Could not read JSON datastore.');
|
|
}
|
|
try {
|
|
$state = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
|
|
} catch (JsonException $exception) {
|
|
throw new RuntimeException('JSON datastore is malformed.', 0, $exception);
|
|
}
|
|
if (!is_array($state)) {
|
|
throw new RuntimeException('JSON datastore root is invalid.');
|
|
}
|
|
State::validate($state);
|
|
return $state;
|
|
}
|
|
|
|
/** @param array<string,mixed> $state */
|
|
private function atomicWrite(array $state): void
|
|
{
|
|
$payload = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n";
|
|
if (strlen($payload) > $this->maxDatabaseBytes) {
|
|
throw new RuntimeException('JSON datastore would exceed its configured 128 MB safety limit.');
|
|
}
|
|
$temporary = dirname($this->path) . '/.' . basename($this->path) . '.tmp-' . bin2hex(random_bytes(8));
|
|
$handle = fopen($temporary, 'x+b');
|
|
if ($handle === false) {
|
|
throw new RuntimeException('Could not create temporary datastore file.');
|
|
}
|
|
try {
|
|
$written = 0;
|
|
while ($written < strlen($payload)) {
|
|
$chunk = fwrite($handle, substr($payload, $written));
|
|
if ($chunk === false || $chunk === 0) {
|
|
throw new RuntimeException('Could not write temporary datastore file.');
|
|
}
|
|
$written += $chunk;
|
|
}
|
|
if (!fflush($handle)) {
|
|
throw new RuntimeException('Could not flush temporary datastore file.');
|
|
}
|
|
if (function_exists('fsync')) {
|
|
fsync($handle);
|
|
}
|
|
fclose($handle);
|
|
$handle = null;
|
|
@chmod($temporary, 0600);
|
|
if (!rename($temporary, $this->path)) {
|
|
throw new RuntimeException('Atomic datastore rename failed.');
|
|
}
|
|
} catch (Throwable $exception) {
|
|
if (is_resource($handle)) {
|
|
fclose($handle);
|
|
}
|
|
@unlink($temporary);
|
|
throw $exception;
|
|
}
|
|
}
|
|
}
|