Skip to content

Commit a6b0a63

Browse files
Add schedule lifecycle audit history
1 parent b38634e commit a6b0a63

6 files changed

Lines changed: 307 additions & 0 deletions

File tree

src/V2/Models/WorkflowSchedule.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Illuminate\Database\Eloquent\Concerns\HasUlids;
1111
use Illuminate\Database\Eloquent\Model;
1212
use Illuminate\Database\Eloquent\Relations\BelongsTo;
13+
use Illuminate\Database\Eloquent\Relations\HasMany;
1314
use Workflow\V2\Enums\ScheduleStatus;
1415
use Workflow\V2\Support\ConfiguredV2Models;
1516

@@ -78,6 +79,14 @@ public function latestInstance(): BelongsTo
7879
);
7980
}
8081

82+
public function historyEvents(): HasMany
83+
{
84+
return $this->hasMany(
85+
ConfiguredV2Models::resolve('schedule_history_event_model', WorkflowScheduleHistoryEvent::class),
86+
'workflow_schedule_id',
87+
)->orderBy('sequence');
88+
}
89+
8190
// ── Convenience accessors projecting spec/action JSON ────────────
8291

8392
public function getWorkflowTypeAttribute(): ?string
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Workflow\V2\Models;
6+
7+
use Carbon\CarbonInterface;
8+
use Illuminate\Database\Eloquent\Concerns\HasUlids;
9+
use Illuminate\Database\Eloquent\Model;
10+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
11+
use Workflow\V2\Enums\HistoryEventType;
12+
use Workflow\V2\Support\ConfiguredV2Models;
13+
14+
class WorkflowScheduleHistoryEvent extends Model
15+
{
16+
use HasUlids;
17+
18+
public $incrementing = false;
19+
20+
protected $table = 'workflow_schedule_history_events';
21+
22+
protected $guarded = [];
23+
24+
protected $keyType = 'string';
25+
26+
protected $dateFormat = 'Y-m-d H:i:s.u';
27+
28+
protected $casts = [
29+
'event_type' => HistoryEventType::class,
30+
'payload' => 'array',
31+
'recorded_at' => 'datetime',
32+
];
33+
34+
public function schedule(): BelongsTo
35+
{
36+
return $this->belongsTo(
37+
ConfiguredV2Models::resolve('schedule_model', WorkflowSchedule::class),
38+
'workflow_schedule_id',
39+
);
40+
}
41+
42+
/**
43+
* @param array<string, mixed> $payload
44+
*/
45+
public static function record(
46+
WorkflowSchedule $schedule,
47+
HistoryEventType $eventType,
48+
array $payload = [],
49+
): self {
50+
$sequence = ((int) ConfiguredV2Models::query('schedule_history_event_model', self::class)
51+
->where('workflow_schedule_id', $schedule->id)
52+
->max('sequence')) + 1;
53+
54+
/** @var self $event */
55+
$event = ConfiguredV2Models::query('schedule_history_event_model', self::class)->create([
56+
'workflow_schedule_id' => $schedule->id,
57+
'schedule_id' => $schedule->schedule_id,
58+
'namespace' => $schedule->namespace,
59+
'sequence' => $sequence,
60+
'event_type' => $eventType->value,
61+
'payload' => self::snapshotPayload($schedule, $payload),
62+
'workflow_instance_id' => self::stringValue($payload['workflow_instance_id'] ?? null),
63+
'workflow_run_id' => self::stringValue($payload['workflow_run_id'] ?? null),
64+
'recorded_at' => now(),
65+
]);
66+
67+
return $event;
68+
}
69+
70+
/**
71+
* @param array<string, mixed> $payload
72+
* @return array<string, mixed>
73+
*/
74+
private static function snapshotPayload(WorkflowSchedule $schedule, array $payload): array
75+
{
76+
if (! array_key_exists('schedule', $payload)) {
77+
$payload['schedule'] = self::scheduleSnapshot($schedule);
78+
}
79+
80+
return $payload;
81+
}
82+
83+
/**
84+
* @return array<string, mixed>
85+
*/
86+
private static function scheduleSnapshot(WorkflowSchedule $schedule): array
87+
{
88+
return array_filter([
89+
'id' => $schedule->id,
90+
'schedule_id' => $schedule->schedule_id,
91+
'namespace' => $schedule->namespace,
92+
'status' => $schedule->status?->value,
93+
'overlap_policy' => $schedule->overlap_policy,
94+
'next_fire_at' => self::timestamp($schedule->next_fire_at),
95+
'last_fired_at' => self::timestamp($schedule->last_fired_at),
96+
'paused_at' => self::timestamp($schedule->paused_at),
97+
'deleted_at' => self::timestamp($schedule->deleted_at),
98+
'last_skip_reason' => $schedule->last_skip_reason,
99+
'last_skipped_at' => self::timestamp($schedule->last_skipped_at),
100+
'fires_count' => (int) $schedule->fires_count,
101+
'skipped_trigger_count' => (int) ($schedule->skipped_trigger_count ?? 0),
102+
'latest_workflow_instance_id' => $schedule->latest_workflow_instance_id,
103+
'note' => $schedule->note,
104+
], static fn (mixed $value): bool => $value !== null);
105+
}
106+
107+
private static function timestamp(mixed $value): ?string
108+
{
109+
if ($value instanceof CarbonInterface) {
110+
return $value->toIso8601String();
111+
}
112+
113+
if ($value instanceof \DateTimeInterface) {
114+
return $value->format(\DateTimeInterface::ATOM);
115+
}
116+
117+
return is_string($value) && $value !== '' ? $value : null;
118+
}
119+
120+
private static function stringValue(mixed $value): ?string
121+
{
122+
return is_string($value) && $value !== '' ? $value : null;
123+
}
124+
}

src/V2/Support/ScheduleManager.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Workflow\V2\Models\WorkflowHistoryEvent;
1919
use Workflow\V2\Models\WorkflowRun;
2020
use Workflow\V2\Models\WorkflowSchedule;
21+
use Workflow\V2\Models\WorkflowScheduleHistoryEvent;
2122

2223
/**
2324
* Single source of truth for workflow schedule lifecycle.
@@ -137,6 +138,13 @@ public static function createFromSpec(
137138
$schedule->next_fire_at = $schedule->computeNextFireAtWithJitter();
138139
$schedule->save();
139140

141+
self::recordScheduleEvent($schedule, HistoryEventType::ScheduleCreated, [
142+
'spec' => is_array($schedule->spec) ? $schedule->spec : [],
143+
'action' => is_array($schedule->action) ? $schedule->action : [],
144+
'overlap_policy' => $schedule->overlap_policy,
145+
'next_fire_at' => $schedule->next_fire_at?->toIso8601String(),
146+
]);
147+
140148
return $schedule;
141149
}
142150

@@ -157,6 +165,11 @@ public static function pause(WorkflowSchedule $schedule, ?string $reason = null)
157165

158166
$schedule->forceFill($updates)->save();
159167

168+
self::recordScheduleEvent($schedule, HistoryEventType::SchedulePaused, array_filter([
169+
'reason' => $reason,
170+
'paused_at' => $schedule->paused_at?->toIso8601String(),
171+
], static fn (mixed $value): bool => $value !== null));
172+
160173
return $schedule;
161174
}
162175

@@ -174,6 +187,10 @@ public static function resume(WorkflowSchedule $schedule): WorkflowSchedule
174187
$schedule->next_fire_at = $schedule->computeNextFireAtWithJitter();
175188
$schedule->save();
176189

190+
self::recordScheduleEvent($schedule, HistoryEventType::ScheduleResumed, [
191+
'next_fire_at' => $schedule->next_fire_at?->toIso8601String(),
192+
]);
193+
177194
return $schedule;
178195
}
179196

@@ -248,11 +265,21 @@ public static function update(
248265
: (int) $schedule->remaining_actions;
249266
}
250267

268+
$changedFields = array_values(array_keys($updates));
269+
251270
$schedule->forceFill($updates)->save();
252271

253272
$schedule->next_fire_at = $schedule->computeNextFireAtWithJitter();
254273
$schedule->save();
255274

275+
self::recordScheduleEvent($schedule, HistoryEventType::ScheduleUpdated, [
276+
'changed_fields' => $changedFields,
277+
'spec' => is_array($schedule->spec) ? $schedule->spec : [],
278+
'action' => is_array($schedule->action) ? $schedule->action : [],
279+
'overlap_policy' => $schedule->overlap_policy,
280+
'next_fire_at' => $schedule->next_fire_at?->toIso8601String(),
281+
]);
282+
256283
return $schedule;
257284
}
258285

@@ -268,6 +295,11 @@ public static function delete(WorkflowSchedule $schedule): WorkflowSchedule
268295
'next_fire_at' => null,
269296
])->save();
270297

298+
self::recordScheduleEvent($schedule, HistoryEventType::ScheduleDeleted, [
299+
'reason' => 'deleted',
300+
'deleted_at' => $schedule->deleted_at?->toIso8601String(),
301+
]);
302+
271303
return $schedule;
272304
}
273305

@@ -523,14 +555,20 @@ private static function triggerForBackfill(
523555
$schedule = WorkflowSchedule::query()->lockForUpdate()->findOrFail($schedule->id);
524556

525557
if (! $schedule->status->allowsTrigger()) {
558+
self::recordSkip($schedule, 'status_not_triggerable');
559+
526560
return null;
527561
}
528562

529563
if ($schedule->remaining_actions !== null && $schedule->remaining_actions <= 0) {
564+
self::recordSkip($schedule, 'remaining_actions_exhausted');
565+
530566
return null;
531567
}
532568

533569
if (! self::overlapAllowed($schedule, $effectivePolicy)) {
570+
self::recordSkip($schedule, 'overlap_policy_' . $effectivePolicy->value);
571+
534572
return null;
535573
}
536574

@@ -564,6 +602,14 @@ private static function startRun(
564602
}
565603

566604
self::recordScheduleTriggered($schedule, $result->runId, $occurrenceTime);
605+
self::recordScheduleEvent($schedule, HistoryEventType::ScheduleTriggered, array_filter([
606+
'workflow_instance_id' => $result->instanceId,
607+
'workflow_run_id' => $result->runId,
608+
'outcome' => $outcome,
609+
'effective_overlap_policy' => $effectiveOverlapPolicy ?? $schedule->overlap_policy,
610+
'trigger_number' => (int) $schedule->fires_count + 1,
611+
'occurrence_time' => $occurrenceTime?->format('Y-m-d\TH:i:s.uP'),
612+
], static fn (mixed $value): bool => $value !== null));
567613

568614
$schedule->recordFire($result->instanceId, $result->runId, $outcome);
569615
$schedule->forceFill([
@@ -583,6 +629,10 @@ private static function startRun(
583629
'deleted_at' => now(),
584630
'next_fire_at' => null,
585631
])->save();
632+
self::recordScheduleEvent($schedule, HistoryEventType::ScheduleDeleted, [
633+
'reason' => 'max_runs_exhausted',
634+
'deleted_at' => $schedule->deleted_at?->toIso8601String(),
635+
]);
586636
}
587637

588638
return $result;
@@ -627,6 +677,23 @@ private static function recordSkip(WorkflowSchedule $schedule, string $reason):
627677
'last_skipped_at' => now(),
628678
'skipped_trigger_count' => ($schedule->skipped_trigger_count ?? 0) + 1,
629679
])->save();
680+
681+
self::recordScheduleEvent($schedule, HistoryEventType::ScheduleTriggerSkipped, [
682+
'reason' => $reason,
683+
'skipped_trigger_count' => (int) $schedule->skipped_trigger_count,
684+
'last_skipped_at' => $schedule->last_skipped_at?->toIso8601String(),
685+
]);
686+
}
687+
688+
/**
689+
* @param array<string, mixed> $payload
690+
*/
691+
private static function recordScheduleEvent(
692+
WorkflowSchedule $schedule,
693+
HistoryEventType $eventType,
694+
array $payload = [],
695+
): void {
696+
WorkflowScheduleHistoryEvent::record($schedule, $eventType, $payload);
630697
}
631698

632699
/**

src/config/workflows.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
'run_timer_entry_model' => Workflow\V2\Models\WorkflowRunTimerEntry::class,
3737
'run_lineage_entry_model' => Workflow\V2\Models\WorkflowRunLineageEntry::class,
3838
'schedule_model' => Workflow\V2\Models\WorkflowSchedule::class,
39+
'schedule_history_event_model' => Workflow\V2\Models\WorkflowScheduleHistoryEvent::class,
3940
'types' => [
4041
'workflows' => [
4142
// 'billing.invoice-sync' => App\Workflows\InvoiceSyncWorkflow::class,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Illuminate\Database\Migrations\Migration;
6+
use Illuminate\Database\Schema\Blueprint;
7+
use Illuminate\Support\Facades\Schema;
8+
9+
return new class() extends Migration {
10+
public function up(): void
11+
{
12+
Schema::create('workflow_schedule_history_events', static function (Blueprint $table): void {
13+
$table->string('id', 26)->primary();
14+
$table->string('workflow_schedule_id', 26)->index();
15+
$table->string('schedule_id', 255)->index();
16+
$table->string('namespace', 255)->nullable()->index();
17+
$table->unsignedInteger('sequence');
18+
$table->string('event_type');
19+
$table->json('payload')->nullable();
20+
$table->string('workflow_instance_id', 191)->nullable()->index();
21+
$table->string('workflow_run_id', 26)->nullable()->index();
22+
$table->timestamp('recorded_at', 6)->nullable();
23+
$table->timestamps(6);
24+
25+
$table->unique(['workflow_schedule_id', 'sequence']);
26+
$table->index(['namespace', 'schedule_id']);
27+
$table->index(['event_type', 'recorded_at']);
28+
});
29+
}
30+
31+
public function down(): void
32+
{
33+
Schema::dropIfExists('workflow_schedule_history_events');
34+
}
35+
};

0 commit comments

Comments
 (0)