feat: Atomically read, mutate, and replace a composition snapshot.
Signed-off-by: Sebastian <krupinski01@gmail.com>
This commit is contained in:
+113
-25
@@ -6,6 +6,8 @@ namespace KTXM\Mail\Stores;
|
||||
|
||||
use DI\Attribute\Inject;
|
||||
use KTXF\Resource\BinaryResource;
|
||||
use RuntimeException;
|
||||
use UnexpectedValueException;
|
||||
|
||||
final class CompositionStore {
|
||||
|
||||
@@ -20,38 +22,118 @@ final class CompositionStore {
|
||||
}
|
||||
|
||||
public function compositionFetch(string $tenantId, string $userId, string $draftId): ?array {
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
if (!is_dir($draftDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$messagePath = $draftDir . '/' . self::COMPOSITION_FILENAME;
|
||||
if (!file_exists($messagePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)file_get_contents($messagePath), true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
return $this->withCompositionLock(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$draftId,
|
||||
LOCK_SH,
|
||||
fn(): ?array => $this->compositionRead($tenantId, $userId, $draftId),
|
||||
);
|
||||
}
|
||||
|
||||
public function compositionSave(string $tenantId, string $userId, string $draftId, array $snapshot): array {
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
if (!is_dir($draftDir)) {
|
||||
mkdir($draftDir, 0755, true);
|
||||
}
|
||||
/**
|
||||
* Atomically read, mutate, and replace a composition snapshot.
|
||||
*
|
||||
* Returning null from the callback leaves the stored snapshot unchanged.
|
||||
* The callback receives null when the composition does not exist.
|
||||
*
|
||||
* @param callable(?array): ?array $update
|
||||
*/
|
||||
public function compositionSave(string $tenantId, string $userId, string $draftId, callable $update): ?array {
|
||||
return $this->withCompositionLock(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$draftId,
|
||||
LOCK_EX,
|
||||
function () use ($tenantId, $userId, $draftId, $update): ?array {
|
||||
$snapshot = $update($this->compositionRead($tenantId, $userId, $draftId));
|
||||
if ($snapshot === null) {
|
||||
return null;
|
||||
}
|
||||
if (!is_array($snapshot)) {
|
||||
throw new UnexpectedValueException('Composition save callback must return an array or null');
|
||||
}
|
||||
|
||||
file_put_contents($draftDir . '/' . self::COMPOSITION_FILENAME, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
return $snapshot;
|
||||
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
$this->ensureDirectory($draftDir);
|
||||
|
||||
$temporaryPath = tempnam($draftDir, '.composition-');
|
||||
if ($temporaryPath === false) {
|
||||
throw new RuntimeException("Unable to create a temporary composition file for '$draftId'");
|
||||
}
|
||||
|
||||
try {
|
||||
$written = file_put_contents($temporaryPath, $json);
|
||||
if ($written !== strlen($json)) {
|
||||
throw new RuntimeException("Unable to write composition '$draftId'");
|
||||
}
|
||||
if (!rename($temporaryPath, $draftDir . '/' . self::COMPOSITION_FILENAME)) {
|
||||
throw new RuntimeException("Unable to replace composition '$draftId'");
|
||||
}
|
||||
} finally {
|
||||
if (is_file($temporaryPath)) {
|
||||
unlink($temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
return $snapshot;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function compositionDiscard(string $tenantId, string $userId, string $draftId): bool {
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
if (!is_dir($draftDir)) {
|
||||
return false;
|
||||
return $this->withCompositionLock(
|
||||
$tenantId,
|
||||
$userId,
|
||||
$draftId,
|
||||
LOCK_EX,
|
||||
function () use ($tenantId, $userId, $draftId): bool {
|
||||
$draftDir = $this->draftDir($tenantId, $userId, $draftId);
|
||||
if (!is_dir($draftDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->deleteDir($draftDir);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private function compositionRead(string $tenantId, string $userId, string $draftId): ?array {
|
||||
$messagePath = $this->draftDir($tenantId, $userId, $draftId) . '/' . self::COMPOSITION_FILENAME;
|
||||
if (!is_file($messagePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->deleteDir($draftDir);
|
||||
return true;
|
||||
$contents = file_get_contents($messagePath);
|
||||
if ($contents === false) {
|
||||
throw new RuntimeException("Unable to read composition '$draftId'");
|
||||
}
|
||||
|
||||
$decoded = json_decode($contents, true);
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
private function withCompositionLock(string $tenantId, string $userId, string $draftId, int $operation, callable $callback): mixed {
|
||||
$lockDir = $this->storagePath . '/.locks';
|
||||
$this->ensureDirectory($lockDir);
|
||||
$lockId = hash('sha256', $tenantId . "\0" . $userId . "\0" . $draftId);
|
||||
$handle = fopen($lockDir . '/' . $lockId . '.lock', 'c+b');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException("Unable to open composition lock for '$draftId'");
|
||||
}
|
||||
|
||||
try {
|
||||
if (!flock($handle, $operation)) {
|
||||
throw new RuntimeException("Unable to lock composition '$draftId'");
|
||||
}
|
||||
|
||||
return $callback();
|
||||
} finally {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
public function attachmentStageFromStream(string $tenantId, string $userId, string $compositionId, string $attachmentId, BinaryResource $data): array {
|
||||
@@ -112,6 +194,12 @@ final class CompositionStore {
|
||||
return $this->storagePath . '/' . $tenantId . '/' . $userId . '/' . $draftId;
|
||||
}
|
||||
|
||||
private function ensureDirectory(string $path): void {
|
||||
if (!is_dir($path) && !mkdir($path, 0755, true) && !is_dir($path)) {
|
||||
throw new RuntimeException("Unable to create directory '$path'");
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteDir(string $path): void {
|
||||
if (!is_dir($path)) {
|
||||
return;
|
||||
@@ -133,4 +221,4 @@ final class CompositionStore {
|
||||
|
||||
rmdir($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user