Event Sourcing

bear/event-sourcing は、Semantic Logger によるリソース実行の観測から不変のイベントを導出します。ドメインコードは何も dispatch しません。書き込みモデルはリソースそのもので、イベントは「アプリケーションが何をしたと観測されたか」から抽出されます。

Semantic Logger observations -> Events -> optional EventStore

イベントはどれもリソース操作(POST app://self/users のような uri への method)なので、ストリームは記録された状態変更の監査履歴になります。どの書き込みが、いつ、どのリソースに起きたか。読み取りや失敗まで含めた完全な観測履歴は、ストアではなくログが持ちます。

インストール

composer require bear/event-sourcing

コアが要求するのは koriym/semantic-loggerray/di だけです。BEAR.Resource 観測ブリッジ(bear/resource)と SQL イベントストア(ray/media-query)は、使うときだけ入れる optional な機能です。

記録と観測

ログとイベントストリームは別の問いに答えます。イベントストリームが記録するのは再現できるもの: 境界の書き込みリクエスト、つまり再生が再実行する入力です。ログが観測するのは起きたこと: 読み取り・失敗・入れ子のリクエスト・所要時間まで、すべてのノードです。透明性とデバッグはこちらが担います。

だから抽出はルートのエントリだけを取ります。handler の中で PUT app://self/inventory を発行する POST app://self/orders から生まれるイベントは、POST の 1 つだけです。再生で POST を再実行すれば handler がもう一度 PUT を発行するので、PUT まで記録してあると再生で二重に適用されてしまう。入れ子の PUT は観測としてログに残ります。

再実行による再生は、パッケージが前提にする(しかし強制はしない)2 つの条件の上に成り立ちます。

  • handler が決定的であること。 handler が自分で clock や乱数や外部の値を読むと、そのリクエストは再生できません。そうした値は params として渡し、記録に含めます。
  • リクエストがトランザクション境界であること。 1 つのリクエストの書き込みは、全部 commit されるか、全部されないか。

リソース実行の観測

ブリッジには optional の bear/resource を入れます。

composer require bear/resource

ResourceObservationModule は実物の InvokerInterface を装飾し、リソース呼び出し 1 回につき open/close のペアを 1 つ書きます。

use BEAR\EventSourcing\Resource\ResourceObservationModule;
use BEAR\Resource\Module\ResourceClientModule;
use Ray\Di\Injector;

$injector = new Injector(new ResourceObservationModule(
    module: new ResourceClientModule(),
));

既定では NullBodyStore が入るため、レスポンスボディは保存されません。ブリッジは flush しません。リクエスト境界を持つのはアプリケーションです。リクエストごとに 1 回 flush し、返ってきたログから抽出します。

use BEAR\EventSourcing\SemanticLogExtractor;

$log = $semanticLogger->flush();
$events = (new SemanticLogExtractor())->extract($log);

flush -> 抽出 -> (必要なら)保存 を 1 呼び出しに束ねるのが EventCollector です。リクエスト終端の handler から呼びます。第 3 引数に EventStoreInterface を渡すと保存まで行います。

use BEAR\EventSourcing\EventCollector;

$collect = new EventCollector($logger, $extractor);

$events = $collect(); // リクエストごとに 1 回、境界で

ローカルの AI・デバッグ作業には DevLogModule を使います。書き込みに加えて GET も記録し、レンダリング済みボディを body_ref ポインタの先のファイルに置くので、ログの木は小さいままボディも調べられます。

use BEAR\EventSourcing\Resource\DevLogModule;
use BEAR\Resource\Module\ResourceClientModule;
use Ray\Di\Injector;

$injector = new Injector(new DevLogModule(
    bodyDir: __DIR__ . '/var/es/bodies',
    module: new ResourceClientModule(),
));

木として描画すると、観測されたリクエストは「意図が入って、結果が出る」形で読めます。リソースがリソースを呼べば、親子としてネストします。

request="POST app://self/orders?order_id=O-1000"
├── request="PUT app://self/inventory/SKU-1?sku=SKU-1&quantity=1"
│   └── code=200 body_ref=file://var/es/bodies/000001.json
└── code=201 body_ref=file://var/es/bodies/000002.json

イベントとは何か

Event が運ぶのは urimethodparamstimestampresult、そして決定的な id(method、uri、UTC に正規化した timestamp、キーでソートした params の sha256)です。同じログを 2 回抽出すると同じ id が出ます。ストアの冪等性はこの同一性が支えています。resultclose.context.body から取ります。ブリッジの close コンテキストはボディの代わりに body_ref ポインタを記録するので、ブリッジのログから抽出したイベントの resultnull です。

既定で対象になるのは状態を変えるメソッド(POST/PUT/PATCH/DELETE)です。ルートの GET は opt-in の読み取りポリシーを入れたときだけ対象になります。

use BEAR\EventSourcing\Module\EventSourcingModule;
use BEAR\EventSourcing\RecordedMethods;

$this->install(new EventSourcingModule(
    methods: new RecordedMethods(RecordedMethods::WITH_READS),
));

フィルタと再生

Events は countable で iterable なコレクションで、クエリメソッドを持ちません。選択は PHP 標準のイテレータで行います。

use BEAR\EventSourcing\Event;

$orderEvents = new CallbackFilterIterator(
    $events->getIterator(),
    static fn (Event $event): bool => str_starts_with($event->uri, 'app://self/orders'),
);

イベントの保存

SQL ストアには optional の ray/media-queryray/aura-sql-module を入れます。

composer require ray/media-query ray/aura-sql-module

EventStoreInterface は小さな永続化ポート(appendappendAllall)で、ランタイムフックではありません。append は Event::$id ごとに冪等なので、バッチをリトライしても事実は重複しません。テストには InMemoryEventStore を、SQL には Ray.MediaQuery 経由の MediaQueryEventStore を使います。データベースはアプリケーションが所有します。

use BEAR\EventSourcing\EventStoreInterface;
use BEAR\EventSourcing\Module\EventSourcingModule;
use BEAR\EventSourcing\Module\MediaQueryEventStoreModule;
use Ray\AuraSqlModule\AuraSqlModule;
use Ray\Di\AbstractModule;
use Ray\MediaQuery\MediaQuerySqlModule;

final class AppModule extends AbstractModule
{
    protected function configure(): void
    {
        $packageDir = __DIR__ . '/vendor/bear/event-sourcing';
        $this->install(new AuraSqlModule('sqlite:' . __DIR__ . '/events.sqlite'));
        $this->install(new MediaQuerySqlModule(
            interfaceDir: $packageDir . '/src/Query',
            sqlDir: $packageDir . '/sql/event_store',
        ));
        $this->install(new EventSourcingModule());
        $this->install(new MediaQueryEventStoreModule());
    }
}

先に sql/event_store/schema.sql をマイグレーションツールで適用します。event_id の UNIQUE 制約が append の冪等性の実体です。appendAll はアトミックではないので、全か無かが要るバッチは自前のトランザクションで包みます。

BEAR.QueryRepository と 1 本の木

BEAR.QueryRepository のセマンティックキャッシュログは #[CacheLog] で修飾された SemanticLoggerInterface に書きます。同じインスタンスを両方のキーに束縛すると、2 つの観測が 1 本の木になります。キャッシュのスコープが、それを起こした resource_request スコープの中にネストします。

use BEAR\RepositoryModule\Annotation\CacheLog;
use Koriym\SemanticLogger\SemanticLogger;
use Koriym\SemanticLogger\SemanticLoggerInterface;

$logger = new SemanticLogger();

$this->install(new DevLogModule($bodyDir, logger: $logger, module: $appModule));
$this->bind(SemanticLoggerInterface::class)->annotatedWith(CacheLog::class)->toInstance($logger);

木が合流しても抽出は安全です。イベントになるのは resource_request エントリだけなので、キャッシュのスコープが状態変化と誤読されることはありません。

デモとスキーマ

すべてのコンテキストが自分の JSON Schema を schemaUrl で名乗ります。正典のスキーマは https://bearsunday.github.io/BEAR.EventSourcing/schemas/ の下に公開されています(例: resource-request.json)。リポジトリには実走するウォークスルーが入っていて、composer observe が実アプリケーションを端から端まで動かします。入れ子の書き込み、ボディの外部化、木の描画、抽出、決定的 id、2 つのストア、再生、そして契約が壊れるとデモ自体が落ちるスキーマ検証までを 1 回の実行で通します。

worker ランタイムでの flush の規則、既存の Ray.MediaQuery 設定との #[SqlDir] の共有、BEAR.Sunday コンテキスト内での配線は README にあります。