Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 2.12.0 - 2026-10-23

- Preserve UTC instants and microseconds in datetime search attributes and
embedded visibility predicates, including values with non-UTC offsets.
- Add `workflow:v2:repair-search-datetimes` to preview or explicitly repair
selected datetime projections from matching retained, typed history.

## 2.11.4 - 2026-10-23

- Preserve whole-valued floats and signed zero in CLI history-export JSON,
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"dev-main": "2.0.x-dev"
},
"durable-workflow": {
"product-train": "2.11.4",
"product-train": "2.12.0",
"laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json",
"laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json"
},
Expand Down
52 changes: 52 additions & 0 deletions docs/architecture/projection-timestamp-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,55 @@ views show the intended instants and that operator projection warnings clear.
The rebuild changes derived rows only. It cannot recover an offset already
lost from authoritative history; investigate such rows under the history
timestamp procedure before interpreting a rebuilt projection as corrected.

## Datetime search attributes

Datetime search attributes also store UTC instants with microsecond precision.
An input such as `2026-10-08T12:34:56.123456+02:00` represents
`2026-10-08T10:34:56.123456Z`. Offset conversion must happen before writing the
timezone-free column and before binding a visibility predicate. Hydration uses
UTC even when the application's default timezone differs.

Earlier writers could store the input's local wall time while retaining its
offset in the `SearchAttributesUpserted` history event. Upgrading the writer
prevents new errors but does not identify or rewrite those existing rows.
Never infer an original offset from a stored wall time.

The `workflow:v2:repair-search-datetimes` command, added in Workflow 2.12.0,
derives a repair
from the latest retained, explicitly typed upsert for each selected key. Back
up the database and upgrade every writer before inspecting affected runs:

```sh
php artisan workflow:v2:repair-search-datetimes RUN_ID --namespace=production --json
php artisan workflow:v2:repair-search-datetimes RUN_ID --namespace=production --key=SubmittedAt --json
```

The default is a preview. `would_repair` reports the number of selected rows
whose stored value differs from their history-backed UTC value. Review the
selected run and keys, then explicitly apply the same selection:

```sh
php artisan workflow:v2:repair-search-datetimes RUN_ID --namespace=production --key=SubmittedAt --apply --json
```

The command locks the selected run and attributes, validates all selected keys
before writing, and updates the run summary in the same storage transaction.
It preserves history, business commands, run/task identities and leases. A
repeat apply reports zero repairs when the selected values already match.
The command operates on one exact run. `--namespace` adds an equality guard;
omit it only when the exact run ID is sufficient, including legacy null
namespaces. Repeated `--key` options restrict the operation to those keys.

Pruned history, missing or stale typed evidence, and timestamps without an
unambiguous timezone are refused. A selected key must still be a datetime
attribute with the matching original upsert sequence. One unprovable key
refuses the entire selection. Inspect the original history and select only
proven keys rather than guessing a timezone. An unexpected storage error is
reported as `unverified`; inspect the rows before retrying because the command
cannot promise the outcome of an uncertain database commit.

This command repairs derived search attributes and their summary. It does not
restore pruned history, change authoritative input, or correct the separate
Server visibility-query binding path. That path requires its corresponding
Server fix.
94 changes: 94 additions & 0 deletions src/Commands/V2RepairSearchDatetimesCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

namespace Workflow\Commands;

use DomainException;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use InvalidArgumentException;
use Throwable;
use Workflow\V2\Models\WorkflowRun;
use Workflow\V2\Support\ConfiguredV2Models;
use Workflow\V2\Support\SearchAttributeDatetimeRepair;

final class V2RepairSearchDatetimesCommand extends Command
{
protected $signature = 'workflow:v2:repair-search-datetimes
{run : The exact workflow run id whose datetime projections should be inspected}
{--namespace= : Require the selected run to belong to this namespace}
{--key=* : Inspect only these datetime attribute keys}
{--apply : Repair the selected attributes after locking and validating current evidence}
{--json : Output the repair report as JSON}';

protected $description = 'Preview or repair selected datetime search attributes from retained typed history';

public function handle(SearchAttributeDatetimeRepair $repair): int
{
$status = self::SUCCESS;

try {
$model = ConfiguredV2Models::resolve('run_model', WorkflowRun::class);
$query = $model::query()->whereKey((string) $this->argument('run'));
$namespace = $this->option('namespace');

if ($namespace !== null) {
if (! is_string($namespace) || $namespace === '') {
throw new InvalidArgumentException('An explicit namespace must be a non-empty string.');
}

$query->where('namespace', $namespace);
}

$keys = $this->option('key');

if (! is_array($keys)) {
throw new InvalidArgumentException('Datetime keys must be a list of strings.');
}

foreach ($keys as $key) {
if (! is_string($key) || $key === '') {
throw new InvalidArgumentException('Each selected datetime key must be a non-empty string.');
}
}

$keys = array_values(array_unique($keys));
$report = $repair->repair($query->firstOrFail(), (bool) $this->option('apply'), $keys);

if ((bool) $this->option('json')) {
$this->line(json_encode($report, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
} else {
$this->table(['Run', 'Examined', 'Would repair', 'Repaired', 'Already correct'], [[
$report['run_id'], $report['examined'], $report['would_repair'],
$report['repaired'], $report['already_correct'],
]]);
}
} catch (ModelNotFoundException) {
$status = $this->failure('The selected run was not found in the requested namespace.', 'refused');
} catch (DomainException|InvalidArgumentException $exception) {
$status = $this->failure($exception->getMessage(), 'refused');
} catch (Throwable) {
$status = $this->failure(
'Repair could not be verified. Inspect the selected projections before retrying.',
'unverified',
);
}

return $status;
}

private function failure(string $message, string $status): int
{
if ((bool) $this->option('json')) {
$this->line(json_encode([
'status' => $status,
'message' => $message,
], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
} else {
$this->error($message);
}

return self::FAILURE;
}
}
2 changes: 2 additions & 0 deletions src/Providers/WorkflowServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Workflow\Commands\V2HistoryImportCommand;
use Workflow\Commands\V2RebuildProjectionsCommand;
use Workflow\Commands\V2RepairPassCommand;
use Workflow\Commands\V2RepairSearchDatetimesCommand;
use Workflow\Commands\V2ReplayConformanceCommand;
use Workflow\Commands\V2ReplaySimulateCommand;
use Workflow\Commands\V2ReplayVerifyCommand;
Expand Down Expand Up @@ -189,6 +190,7 @@ public function boot(): void
V2HistoryExportCommand::class,
V2HistoryImportCommand::class,
V2RepairPassCommand::class,
V2RepairSearchDatetimesCommand::class,
V2RebuildProjectionsCommand::class,
V2ReplayConformanceCommand::class,
V2ReplaySimulateCommand::class,
Expand Down
3 changes: 2 additions & 1 deletion src/V2/Models/WorkflowSearchAttribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use InvalidArgumentException;
use Workflow\Traits\ResolvesStorageConnection;
use Workflow\V2\Support\ConfiguredV2Models;
use Workflow\V2\Support\UtcScheduleTimestamp;

/**
* Workflow Search Attribute - Typed indexed metadata.
Expand Down Expand Up @@ -97,7 +98,7 @@ class WorkflowSearchAttribute extends Model
'value_int' => 'integer',
'value_float' => 'float',
'value_bool' => 'boolean',
'value_datetime' => 'datetime',
'value_datetime' => UtcScheduleTimestamp::class,
'value_keyword_list' => 'array',
'upserted_at_sequence' => 'integer',
'inherited_from_parent' => 'boolean',
Expand Down
Loading
Loading