Inline and Deferred
同じOperation ModelをInline HTTPとDeferred Workerへ接続し、受付と完了確認を分ける。
Operationの実行経路はDirectoryではなくMetadataで決まります。HTTP Routeを持つOperationはCompile済みHTTP Manifestへ登録され、Execution Strategyを指定しない場合はInline、Deferredを指定した場合はDurable受付になります。
InlineはHTTP Request内でoperation.receivedから直接Attemptを開始し、OperationのOutcomeをHTTP Responseへ変換して返します。DeferredはValueとContextをDurable Storeへ保存し、operation.acceptedの後にHTTP 202とOperation IDを返します。Workerは後からClaimを取得し、Attempt、Outcome保存、完了Journalを実行します。
Transactional Outboxへの登録
Application MutationとDeferred child Operationを同じFramework管理Transactionへ結び付ける場合は、OperationsをConstructor Injectionします。
use BlackOps\Core\Attribute\OperationType;
use BlackOps\Core\Operation;
use BlackOps\Core\Attribute\Deferred;
use BlackOps\Database\Attribute\Transactional;
use BlackOps\Execution\Operations;
#[OperationType('order.place')]
readonly class PlaceOrder implements Operation
{
public function __construct(
private OrderRepository $orders,
private Operations $operations,
) {}
#[Transactional(connection: 'app')]
public function handle(PlaceOrderValue $value): OrderPlaced
{
$order = $this->orders->place($value->customerId, $value->productCode, $value->quantity);
$this->operations->dispatch(
NotifyOrderOwner::class,
new NotifyOrderOwnerValue($order->id()),
);
return new OrderPlaced($order->id());
}
}
#[OperationType('order.notify-owner')]
#[Deferred]
final readonly class NotifyOrderOwner implements Operation
{
public function handle(NotifyOrderOwnerValue $value): NotificationSent
{
return new NotificationSent($value->orderId);
}
}
NotifyOrderOwnerはfinalなDeferred child Operationとして#[OperationType]と引数なしの#[Deferred]を付けます。Operations::dispatch()へ渡せるのはDeferred child Operationだけです。親OperationのExecution ContextからCorrelation/Causation/Actor/Deadlineを継承し、親Idempotency Key Hashは子へ渡しません。OutboxはApplication Database ConfigurationのFramework Named Connectionと同じConnection Instanceを所有するFramework管理Transaction内でのみ動作します。Transaction外、別Connectionが最上位にある場合、Manual Transactionによるnesting変更/commit、または所有者不明のTransactionではFail-fastし、Direct TransportへFallbackしません。同じConnectionのNested Requiredは外側のScopeへ参加できます。
MutationとOutbox Rowは最外Commitで同時に残ります。ThrowableまたはInsert Failureでは両方Rollbackされ、Nested Requiredの途中でRollback-onlyになった場合も最外ScopeがRollbackするためRowは残りません。登録結果はchild Operation IDとUTC dispatch時刻だけを公開し、Outbox Record IDは露出しません。
Transactional OutboxはOutbox Persistence、有限BatchのRelay、Retry/Backoff、Lease/Fencing、Dead Letter再開までを提供します。配送はat-least-onceであり、Relay停止中のpending Rowは再開後に同じchild Operation Identityで再配送されます。Outboxを使わないDeferred呼出は既存Direct Transportの受付契約を維持します。
MutationのPOST/PUT/PATCH/DELETEでは、認証・認可後にOptional Idempotency-KeyをAtomic Claimします。同じFingerprintのTerminal ResultはTyped Resultまたは安全なHTTP Responseとして再利用し、Replay ResponseだけにIdempotency-Replayed: trueとCache-Control: private, no-storeを投影します。GET/HEAD、Anonymous Actor、Ephemeral OutcomeではKeyを受理しません。
Malformed Key、複数Key、未対応Method、Anonymous Actor、Ephemeral OutcomeはClaim前に安全な4xxとして拒否します。異なるFingerprintや既存のIn-Progress ClaimはConflictとして扱い、既存結果がなく期限切れなら再実行せずHTTP 409の安定Code idempotency_expiredを返します。Key付きHandlerがThrowableを投げた場合はFailure Boundaryが内部詳細を保存せず安全な失敗結果をJournalへ確定し、同じKeyの再送はHandlerを再実行せずその失敗結果をReplayします。
Inline HTTP
use BlackOps\Core\Attribute\OperationType;
use BlackOps\Core\Operation;
use BlackOps\Http\Attribute\Route;
#[Route(method: 'GET', path: '/welcome')]
#[OperationType('welcome.show')]
final readonly class ShowWelcome implements Operation
{
public function handle(WelcomeValue $value): WelcomeShown
{
return new WelcomeShown('Welcome to BlackOps');
}
}
HTTP HandlerはCompile済みRouteを照合し、RequestをValueへBindして、ContainerからOperationを解決します。Handler実行とLifecycle JournalをRequest内で完了し、OutcomeをHTTP Responseへ変換します。
Deferred HTTP
use BlackOps\Core\Attribute\Deferred;
use BlackOps\Core\Attribute\OperationType;
use BlackOps\Core\ExecutionContext;
use BlackOps\Core\Operation;
use BlackOps\Http\Attribute\Route;
#[Route(method: 'POST', path: '/reports')]
#[OperationType('report.generate')]
#[Deferred]
final readonly class GenerateReport implements Operation
{
public function handle(GenerateReportValue $value, ExecutionContext $context): ReportGenerated
{
return new ReportGenerated($value->reportName, $context->operationId()->toString());
}
}
Deferred RouteはHTTP 202とOperation IDを返し、HandlerをHTTP Process内で実行しません。Operation Value、Context、受付JournalをPostgreSQLへDurableに保存します。
Frontendから受付と完了を分ける
Generated Operation Objectは三つの異なる操作を明示します。
| Method | 通信 | Result |
|---|---|---|
.fetch(value, options) |
Operation Routeへ1 Request | Inline完了、またはDeferred受付202。自動Pollingしない |
.status(operationId, options) |
Status Resourceへ1 GET | 7 Lifecycle State、または401/404/410/500/Transport Failure |
.wait(operationId, options) |
Retry-Afterに従う有限のStatus GET |
Completed/Rejected/Failed/Dead Lettered、またはFailure |
const accepted = await GenerateReport.fetch(value, options);
if (accepted.ok && accepted.kind === 'accepted') {
const current = await GenerateReport.status(accepted.data.operationId, options);
const controller = new AbortController();
const terminal = await GenerateReport.wait(accepted.data.operationId, {
...options,
signal: controller.signal,
maxWaitMilliseconds: 15_000,
});
void current;
void terminal;
}
.wait()は正のSafe Integer Deadlineと購読可能なAbort Signalを必須にします。Non-terminalだけをServerの正整数Retry-Afterに従って再取得し、401、404、410、500、Network Error、不正Responseでは停止します。無限待機、独自Backoff、Global Mutable Clientは提供しません。
Worker
BlackOps CLIからWorkerを起動します。
php blackops worker:run --idle-sleep-milliseconds=1000
Workerは期限切れAttemptをRecoveryしてからClaimし、一度に最大1 Claimを処理します。Smoke Testでは--iterations=NでLoop回数を制限できます。常駐ProcessはProcess ManagerまたはCompose Worker Profileで監督してください。
PCNTL HeartbeatはHandler実行中だけLeaseを更新します。Heartbeat間隔はLeaseより短い正数にし、Heartbeat用DBAL ConnectionをClaim/Settlement用Connectionと分離します。
SIGTERM/SIGINTでは新しいClaimを停止し、Grace Period内で実行中Handlerの完了を待ちます。Heartbeat失敗やGrace Timeout時はClaimを成功扱いせず、Lease ExpiryとRecoveryへ委ねます。
Runtime Boundary
HTTPとWorkerはCompile済みOperation Manifest、HTTP Manifest、DI Containerだけを読み込みます。Runtime起動時にSource Discovery、Artifact Compile、Database MigrationへFallbackしません。Artifact不足、Schema Version不正、Build ID不一致は起動エラーです。
BuildとRuntimeの入口はBlackOps CLI、Contextの読み取りはExecution Contextを参照してください。