本文へ移動
BlackOps1.xは試験的なバージョンです。Production Readyは2.xを予定しています。Releases
BlackOps
Esc
navigateopen⌘Jpreview
このページの内容

Scheduled Operation

one-shot Scheduleを構成し、実行結果、Misfire、Overlap、Crash Recoveryを観測する。

Scheduled Application Operationは、公開済みExperimental Stable 1.2.0で提供するCapabilityです。ReleasesでApplication-owned責務とFrameworkの境界を確認してから、専用のProject Rootで試してください。

何を作るか

一つのOperationへ一つの#[ScheduledBy]を付け、Schedule名、5 Field POSIX Cron、IANA Timezoneを宣言します。#[ScheduledBy]は入口Metadataであり、Execution Strategyを変更しません。

  • InlineはAttributeを追加せず、通常のInline Dispatcherで同じProcess内に完了します。
  • Deferredは#[Deferred]を追加し、受付後に通常のTransportとWorkerで完了します。
  • Schedule専用のValue Factoryはありません。OperationValueは必須Constructor引数なしで構築でき、定刻はExecutionContext::schedule()から読みます。
  • 手動の#[ConsoleCommand]実行はScheduled Occurrenceを使わず、新しいRoot Operationとして扱います。

Canonical Authoring

次の例は公開済みExperimental Stable 1.2.0のCanonical Authoringです。#[Deferred]を外すとInlineになります。

<?php

declare(strict_types=1);

namespace App\Feature\Report\GenerateDailyReport;

use BlackOps\Core\Attribute\Deferred;
use BlackOps\Core\Attribute\OperationType;
use BlackOps\Core\Attribute\ScheduledBy;
use BlackOps\Core\ExecutionContext;
use BlackOps\Core\Operation;
use BlackOps\Core\OperationValue;
use BlackOps\Core\Outcome;

#[OperationType('report.generate_daily')]
#[ScheduledBy(
    name: 'reports.daily',
    cron: '0 0 * * *',
    timezone: 'Asia/Tokyo',
)]
#[Deferred]
final readonly class GenerateDailyReport implements Operation
{
    public function handle(
        GenerateDailyReportValue $value,
        ExecutionContext $context,
    ): DailyReportGenerated {
        $schedule = $context->schedule();
        // Application Serviceへ$schedule?->scheduledAt()を渡す。
        return new DailyReportGenerated();
    }
}

final readonly class GenerateDailyReportValue implements OperationValue {}

final readonly class DailyReportGenerated implements Outcome {}

Schedule名は^[a-z0-9]+(?:\.[a-z0-9]+)*$でApplication内に一意でなければなりません。Cronは5 Fieldだけを使います。

Field 範囲
Minute 0-59 0
Hour 0-23 0
Day of month 1-31 *
Month 1-12 *
Day of week 0-707は日曜) *

*、数値、Comma List、Inclusive Range、Stepを組み合わせられます。秒Field、@dailyなどのNickname、Month/Weekday Name、LW#は使えません。Day of monthとDay of weekを両方制限した場合はPOSIXのORです。Timezoneを省略するとUTCになります。HostのTimezone設定には依存しません。

必須Constructor引数のあるValue、Ephemeral Outcome、重複Schedule名、不正Cron/Timezoneはbuild:compileで拒否されます。任意Payload、Credential、Actor ID、定刻をAttributeやValueへ保存しません。

InlineとDeferred

ScheduledByとStrategyは独立しています。

Authoring operation:schedule:runの動作 完了確認
#[ScheduledBy]だけ Value構築、Validation、Authorization、Journal、Inline Handlerを同じProcessで実行 Occurrenceがcompleted、JournalがTerminal Event
#[ScheduledBy]#[Deferred] ValueをEncodeし、通常のDeferred Acceptance/Transportへ受理 Occurrenceがacceptedworker:run後にcompleted

どちらも通常Lifecycleを迂回しません。Deferredはat-least-onceであり、外部副作用のExactly Onceは保証されないため、Application Handlerを冪等に設計します。

Schedule Context

ExecutionContext::schedule()はScheduled Rootでだけ非nullです。ScheduleContextは次の読み取り専用値を持ち、scheduledAt()はUTCへ正規化されます。

$schedule = $context->schedule();

if ($schedule !== null) {
    $name = $schedule->name();
    $scheduledAtUtc = $schedule->scheduledAt();
    $timezone = $schedule->timezone();
}

HTTP、通常のConsoleCommand、Application child dispatchではnullです。実際の評価時刻、受理時刻、Raw Cron Parser State、CredentialはContextに入りません。Deferred TransportとWorker Retryでは同じSchedule Contextが維持されます。

Authorized ScheduleのProvider

#[Authorize]をScheduled Operationへ付ける場合だけ、Application-owned ScheduledActorProviderをService Providerへ登録します。Console用のProviderは共用しません。ProviderはSchedule ContextからActorを解決し、CredentialやSecretを返しません。

<?php

declare(strict_types=1);

namespace App\Security;

use BlackOps\Core\ActorRef;
use BlackOps\Core\ScheduleContext;
use BlackOps\Scheduling\ScheduledActorProvider;

final readonly class ReportScheduleActorProvider implements ScheduledActorProvider
{
    public function actor(ScheduleContext $context): ?ActorRef
    {
        return new ActorRef('report-service', 'service');
    }
}

Service ProviderでBindingします。

<?php

declare(strict_types=1);

namespace App;

use App\Security\ReportScheduleActorProvider;
use BlackOps\Core\DependencyInjection\ServiceProvider;
use BlackOps\Core\DependencyInjection\ServiceRegistry;
use BlackOps\Scheduling\ScheduledActorProvider;

final readonly class ApplicationServiceProvider implements ServiceProvider
{
    public function register(ServiceRegistry $services): void
    {
        $services->autowire(ScheduledActorProvider::class, ReportScheduleActorProvider::class);
    }
}

ApplicationServiceProviderconfig/app.phpservicesへ登録してからBuildします。既存のService登録があれば同じ配列へ追加し、登録だけでなくProvider実装と同じApplication Rootへ配置してください。

// config/app.php
return [
    'services' => [
        App\ApplicationServiceProvider::class,
    ],
];

Authorized ScheduleがあるのにProviderがない、または登録Typeが不正ならBuild/Bootstrapが安全なConfiguration Errorで停止します。Providerが明示的にnullを返す場合は匿名Fallbackせず、通常Authorization境界で拒否されます。AuthorizationなしのScheduleだけならProvider登録は不要です。

Migration、Build、初回実行

Project Rootで次の順序を守ります。MigrationとBuildは暗黙に実行されません。

php blackops database:migrate
php blackops build:compile
php blackops operation:schedule:run --json

build:compileはOperation Manifest、HTTP/Frontend Artifact、Compiled Containerを同じApplication Build IDで生成します。RuntimeはSource DiscoveryへFallbackしません。Databaseはblackops Schema(またはApplication設定のFramework Schema)へSchedule State/Occurrenceを作成します。

初回評価は過去をBackfillせず、現在のCalendar Minuteだけを候補にします。次回以降はCursorより後を評価し、複数Slotが溜まっている場合は最新一件だけを実行候補にします。

Daily例の0 0 * * *はAsia/Tokyoの毎日0、任意時刻に初回実行するとaccepted: 0(No Schedule)になるのが正常です。上のJSON/Human例のaccepted: 2はCountのShapeを示すサンプルで、固定された期待値ではありません。初回の受理を確実に検証する場合は、検証用OperationだけCronを* * * * *へ変更し、build:compile後に同じCalendar Minute内でone-shot Commandを実行します。確認後は実運用Cronへ戻し、再度build:compileしてArtifactを更新してください。

CLIの結果とExit Code

operation:schedule:runは一回評価して終了します。Cron、systemd timer、Kubernetes CronJobなどの外部Supervisorが頻度、Timeout、Restart、Alertを所有します。

成功時のJSONは固定Shapeです。

{"schemaVersion":1,"status":"ok","evaluated":2,"accepted":2,"skipped_misfire":0,"skipped_overlap":0,"failed":0}

Human出力は次のCountを同じ順で表示します。

Scheduled operation run completed.
evaluated: 2
accepted: 2
skipped_misfire: 0
skipped_overlap: 0
failed: 0
Exit 条件 出力
0 No Schedule、またはfailed: 0 Human CountまたはJSON status: "ok"
1 Occurrence単位のEvaluation、Validation、Authorization、Invocation、Acceptance Failureを集計 Human CountまたはJSON status: "failed"。Runner/Bootstrap境界のTop-level Runtime ErrorはJSON code: "runtime_error"(Humanは安全な失敗表示)
2 Unknown Option、Manifest/Build ID/Providerなど安全な入力・設定Error configuration_error。Raw Exception、SQL、Value、Credentialは出さない

Unknown Optionでも--jsonが指定されていれば、入力Bind前にJSON Error Shapeへ縮約されます。

Occurrence単位のFailureはCountsへ集計されてstatus: "failed"になります。Runner/BootstrapでCommand自体を構成できないTop-level Runtime Errorは、--jsonなら{"schemaVersion":1,"status":"failed","code":"runtime_error"}として返り、Occurrence Countとは別に扱います。

Misfire、Overlap、Crash Recovery

Schedule StateのCursorとPostgreSQL Occurrenceが正本です。(schedule_name, scheduled_at UTC)は一意で、Claim時に発行したOperation IDを再評価、Crash Recovery、Acceptance Retry、Worker Retryで維持します。

  • 古いSlotはskipped_misfireとして残り、Operation IDはありません。
  • 直前Occurrenceが非Terminal(Claim中、Accepted、Retry中など)なら新しいSlotはskipped_overlapとなり、Operation IDはありません。
  • Completed、Rejected、Failed、Dead LetteredはTerminalです。
  • Claim後、Invocation前にProcessが停止しても、次の一回実行が同じOccurrence/Operation IDを先に再開します。
  • 複数Processが同時に起動しても、PostgreSQL Transaction、Unique Constraint、Schedule Advisory Lockで一つのSlotへ収束します。

FrameworkはHandlerの外部副作用をExactly Onceにしません。同じOperation IDで再試行できるよう、外部APIやDB Mutationの重複耐性をApplicationで設計します。

OccurrenceとJournalを安全に確認する

Occurrenceの診断では、Canonical Value、Outcome、Credentialを取得しません。Read-onlyの運用Queryでは、Schedule名、UTC Slot、評価時刻、State、Safe Category、Operation ID、Accepted時刻だけを読みます。

QueryはProject RootからFramework SchemaへRead-only接続して実行します。SkeletonのDocker環境ではdocker compose exec -T postgres psql -U blackops -d blackopsを起動し、別環境ではApplicationのConnection/Schemaへ接続するRead-only PostgreSQL Clientを使います。blackops Schemaは設定に合わせて置き換え、CredentialをCommandへ直書きしません。

SELECT schedule_name,
       scheduled_at,
       evaluated_at,
       state,
       category,
       operation_id::text,
       accepted_at
FROM blackops.schedule_occurrences
WHERE schedule_name = 'reports.daily'
ORDER BY scheduled_at DESC
LIMIT 20;

Skip StateのOperation IDはNULLです。実行候補だけが固定Operation IDを持ちます。Operation IDを得たら、BlackOps CLIoperation:inspectでSafe Statusを確認し、Canonical JournalのEvent列と同じIDで相関します。

Schedule Contextを持つJournalの安全なOperation Projectionは次の形です。

{
  "operation": {
    "id": "019f0000-0000-7000-8000-000000000001",
    "type": "report.generate_daily",
    "strategy": "deferred",
    "schedule": {
      "name": "reports.daily",
      "scheduled_at": "2026-07-22T15:00:00.000000Z",
      "timezone": "Asia/Tokyo"
    }
  },
  "event": "operation.accepted"
}

JournalのSchedule ProjectionはSchedule名、Calendar上の定刻、Timezoneだけを持ちます。Actor ID、Credential、Raw Value、Cron Parser Stateは保存・表示しません。Deferredの代表的なEvent列はoperation.receivedoperation.acceptedattempt.started → Terminal Eventです。Inlineはoperation.receivedattempt.started → Terminal Eventです。

DSTと外部Supervisor

Calendar評価はAttributeのIANA Timezoneで行い、永続化と比較はUTC Instantです。DST開始で存在しないLocal TimeはOccurrenceを作らず、DST終了で二度現れるLocal Timeは最初のUTC Instantだけを候補にします。

外部Supervisorはone-shot Commandを起動します。例としてsystemd timerやKubernetes CronJobをApplication側で定義できますが、FrameworkがManifestを生成したり、常駐Processを起動したりはしません。

External Supervisor
    -> php blackops operation:schedule:run --json
        -> Inline completion or Deferred acceptance
            -> php blackops worker:run (Deferred only)

php blackops scheduler:runscheduler:daemonはRetention等のFramework Maintenance専用です。Application Scheduleを起動せず、operation:schedule:runとは別Processとして監督します。

Failure対応

  1. 2ならbuild:compile、Application Build ID、Manifest、Provider登録、Optionを確認します。Schedule Stateは設定Errorで変更されません。
  2. 1ならJSON CountのfailedとSafe Categoryを記録し、対象OccurrenceをRead-onlyで確認します。
  3. claimedが残っていれば次のone-shot実行でRecoveryを先に行います。同じOperation IDから別IDへ置き換えません。
  4. Deferredがacceptedのままなら、同じDatabase/Schema/Build ArtifactのWorkerを有限Loopで実行します。
  5. JournalとOutcomeを確認するときもRaw Payload、Credential、Provider Errorを公開Logへコピーしません。

既存のHTTP/Deferred/Journalの一般的な境界はInline and DeferredExecution ContextJournalDeploymentTroubleshootingを参照してください。