From a129b6b5cdb9cb2b914cddc5bcdf79686783e88f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 13 Jun 2026 15:26:54 -0300 Subject: [PATCH] Denormalize automation trigger_type into an indexed column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduler command ran every minute and loaded all active automations, then filtered by trigger_type in PHP because that value lived buried in the nodes JSON array — effectively a full-table scan plus a JSON decode per row each minute, discarding every non-schedule automation. Derive trigger_type into a real, indexed column on save (recomputed in the existing saving() hook so it can never drift from nodes) and filter on it in SQL. Applies to both the schedule firer and the post-trigger dispatcher. --- .../DispatchPostTriggerAutomations.php | 7 +++++-- .../Automation/FireScheduleTriggers.php | 6 ++---- app/Models/Automation.php | 16 ++++++++++++++ ..._05_22_211640_create_automations_table.php | 2 ++ .../Automation/AutomationModelTest.php | 21 +++++++++++++++++++ .../Command/FireScheduleTriggersTest.php | 11 ++++++++++ 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/app/Actions/Automation/Trigger/DispatchPostTriggerAutomations.php b/app/Actions/Automation/Trigger/DispatchPostTriggerAutomations.php index 9c86fb39..b36316a1 100644 --- a/app/Actions/Automation/Trigger/DispatchPostTriggerAutomations.php +++ b/app/Actions/Automation/Trigger/DispatchPostTriggerAutomations.php @@ -5,6 +5,7 @@ namespace App\Actions\Automation\Trigger; use App\Actions\Automation\Run\AdvanceAutomationRun; +use App\Enums\Automation\Node\Type as NodeType; use App\Enums\Automation\Run\Status as RunStatus; use App\Enums\Automation\Status as AutomationStatus; use App\Enums\Automation\Trigger\Type as TriggerType; @@ -32,11 +33,13 @@ public function __invoke(Post $post, TriggerType $triggerType): void $automations = Automation::query() ->where('workspace_id', $post->workspace_id) ->where('status', AutomationStatus::Active) + ->where('trigger_type', $triggerType->value) ->get(); foreach ($automations as $automation) { - $triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger'); - if (data_get($triggerNode, 'data.trigger_type') !== $triggerType->value) { + $triggerNode = collect($automation->nodes ?? [])->firstWhere('type', NodeType::Trigger->value); + + if ($triggerNode === null) { continue; } diff --git a/app/Console/Commands/Automation/FireScheduleTriggers.php b/app/Console/Commands/Automation/FireScheduleTriggers.php index 277b49e5..ce90cdde 100644 --- a/app/Console/Commands/Automation/FireScheduleTriggers.php +++ b/app/Console/Commands/Automation/FireScheduleTriggers.php @@ -6,6 +6,7 @@ use App\Actions\Automation\Trigger\FireScheduleTrigger; use App\Enums\Automation\Status; +use App\Enums\Automation\Trigger\Type as TriggerType; use App\Models\Automation; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; @@ -19,12 +20,9 @@ public function handle(FireScheduleTrigger $fire): int { Automation::query() ->where('status', Status::Active) + ->where('trigger_type', TriggerType::Schedule->value) ->chunkById(50, function ($automations) use ($fire) { foreach ($automations as $automation) { - $triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger'); - if (($triggerNode['data']['trigger_type'] ?? null) !== 'schedule') { - continue; - } $fire($automation); } }); diff --git a/app/Models/Automation.php b/app/Models/Automation.php index b81fd554..8189913b 100644 --- a/app/Models/Automation.php +++ b/app/Models/Automation.php @@ -4,6 +4,7 @@ namespace App\Models; +use App\Enums\Automation\Node\Type as NodeType; use App\Enums\Automation\Status; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -49,6 +50,7 @@ protected static function booted(): void $automation->variables ?? [], $automation->getOriginal('variables') ?? [], ); + $automation->trigger_type = self::deriveTriggerType($automation->nodes ?? []); }); } @@ -100,6 +102,20 @@ public function runs(): HasMany * never received the real value) so we keep the existing ciphertext. Plain * text values get encrypted; already-encrypted strings pass through. * + * Denormalize the trigger node's type into an indexed column so the + * scheduler can filter by it in SQL instead of decoding every automation's + * `nodes` JSON each minute. Recomputed on every save so it cannot drift. + * + * @param array> $nodes + */ + private static function deriveTriggerType(array $nodes): ?string + { + $triggerNode = collect($nodes)->firstWhere('type', NodeType::Trigger->value); + + return data_get($triggerNode, 'data.trigger_type'); + } + + /** * @param array> $incoming * @param array>|string $original * @return array> diff --git a/database/migrations/2026_05_22_211640_create_automations_table.php b/database/migrations/2026_05_22_211640_create_automations_table.php index 23f5d423..c7de2ea6 100644 --- a/database/migrations/2026_05_22_211640_create_automations_table.php +++ b/database/migrations/2026_05_22_211640_create_automations_table.php @@ -19,6 +19,7 @@ public function up(): void $table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete(); $table->string('name'); $table->string('status')->default('draft'); + $table->string('trigger_type')->nullable(); $table->json('nodes')->nullable(); $table->json('connections')->nullable(); $table->timestamp('activated_at')->nullable(); @@ -26,6 +27,7 @@ public function up(): void $table->timestamps(); $table->index(['workspace_id', 'status']); + $table->index(['status', 'trigger_type']); }); } diff --git a/tests/Feature/Automation/AutomationModelTest.php b/tests/Feature/Automation/AutomationModelTest.php index 437c1af1..4a83c620 100644 --- a/tests/Feature/Automation/AutomationModelTest.php +++ b/tests/Feature/Automation/AutomationModelTest.php @@ -17,6 +17,27 @@ expect($automation->nodes[0]['type'])->toBe('trigger'); }); +it('derives the trigger_type column from the trigger node on save', function () { + $automation = Automation::factory()->withScheduleTrigger()->create(); + + expect($automation->trigger_type)->toBe('schedule'); + + $automation->update([ + 'nodes' => [ + ['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], + 'data' => ['trigger_type' => 'post_published']], + ], + ]); + + expect($automation->fresh()->trigger_type)->toBe('post_published'); +}); + +it('nulls the trigger_type column when there is no trigger node', function () { + $automation = Automation::factory()->create(['nodes' => []]); + + expect($automation->trigger_type)->toBeNull(); +}); + it('relates trigger items, runs and node runs', function () { $automation = Automation::factory()->create(); $item = AutomationTriggerItem::factory()->for($automation)->create(); diff --git a/tests/Feature/Automation/Command/FireScheduleTriggersTest.php b/tests/Feature/Automation/Command/FireScheduleTriggersTest.php index 7ce79e30..50dcf3f1 100644 --- a/tests/Feature/Automation/Command/FireScheduleTriggersTest.php +++ b/tests/Feature/Automation/Command/FireScheduleTriggersTest.php @@ -17,3 +17,14 @@ expect(AutomationTriggerItem::where('automation_id', $scheduleAutomation->id)->count())->toBe(1); expect(AutomationTriggerItem::where('automation_id', $rssAutomation->id)->count())->toBe(0); }); + +it('ignores automations whose trigger is not a schedule', function () { + $postAutomation = Automation::factory()->active()->create([ + 'nodes' => [['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], + 'data' => ['trigger_type' => 'post_published']]], + ]); + + $this->artisan('automation:fire-schedule')->assertSuccessful(); + + expect(AutomationTriggerItem::where('automation_id', $postAutomation->id)->count())->toBe(0); +});