Files
hado/core/app/Delivery/Tick.php
nikita.hohlov affac4ce1a feat: tick planner, delivery job, scheduler and purge
Also: test uses disabled channels instead of deleting them (FK cascade); ChannelRegistry no longer final so tests can extend it (controller ruling).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
2026-09-04 07:38:16 -03:00

109 lines
3.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Delivery;
use App\Channels\ChannelRegistry;
use App\Enums\DeliveryResult;
use App\Enums\EventState;
use App\Enums\Presence;
use App\Models\Channel;
use App\Models\Delivery;
use App\Models\Event;
use App\Models\User;
use App\Scheduling\DueResolver;
use App\Scheduling\StateResolver;
use App\Scheduling\Window;
use App\Scheduling\WindowResolver;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Один тик планировщика (раз в минуту): переходы состояний по датам и решение,
* доставлять ли напоминание в открытое окно и куда.
*/
final class Tick
{
public function __construct(
private readonly DueResolver $due,
private readonly StateResolver $states,
private readonly WindowResolver $windows,
private readonly ChannelRegistry $registry,
private readonly Cache $cache,
private readonly int $presenceTtl,
) {}
public function run(): void
{
Event::query()
->whereIn('state', EventState::nonTerminalValues())
->with('user')
->chunkById(200, fn (Collection $events) => $events->each(fn (Event $e) => $this->process($e)));
}
private function process(Event $event): void
{
$user = $event->user;
$now = $user->now();
$state = $this->states->resolve($event, $user, $now);
if ($state !== $event->state) {
$event->state = $state;
$event->save();
}
if (! $state->isActive() || $now->lt($event->quiet_until)) {
return;
}
$window = $this->windows->current($state, $user, $this->due->resolve($event, $user), $now);
if ($window === null) {
return;
}
if (Delivery::where('event_id', $event->id)->where('window_start', $window->start->utc())->exists()) {
return;
}
$channels = $user->channels()->where('enabled', true)->get();
if ($channels->isEmpty()) {
return;
}
$present = $channels->filter(fn (Channel $c) => $this->presence($c, $user) === Presence::Present);
if ($present->isNotEmpty()) {
$this->dispatch($event, $present, $window);
return;
}
if ($window->fallbackAllowed && $now->gte($window->fallbackAt)) {
$this->dispatch($event, $channels, $window);
}
}
private function presence(Channel $channel, User $user): Presence
{
return $this->cache->remember(
"presence:{$channel->id}",
$this->presenceTtl,
fn () => $this->registry->for($channel->type)->presence($user, $channel->config),
);
}
/** Строка pending — в транзакции ДО постановки job-а: следующий тик её уже видит. */
private function dispatch(Event $event, Collection $channels, Window $window): void
{
DB::transaction(function () use ($event, $channels, $window) {
foreach ($channels as $channel) {
$delivery = Delivery::create([
'event_id' => $event->id,
'channel_id' => $channel->id,
'window_start' => $window->start->utc(),
'action_token' => Str::random(48),
'result' => DeliveryResult::Pending,
]);
DeliverJob::dispatch($delivery->id)->afterCommit();
}
});
}
}