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
This commit is contained in:
58
core/app/Delivery/DeliverJob.php
Normal file
58
core/app/Delivery/DeliverJob.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Delivery;
|
||||
|
||||
use App\Channels\ChannelRegistry;
|
||||
use App\Enums\DeliveryResult;
|
||||
use App\Models\Delivery;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
final class DeliverJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly int $deliveryId) {}
|
||||
|
||||
/** @return list<int> */
|
||||
public function backoff(): array
|
||||
{
|
||||
return [30, 120, 600];
|
||||
}
|
||||
|
||||
public function handle(ChannelRegistry $registry): void
|
||||
{
|
||||
$delivery = Delivery::with(['event.user', 'event.source', 'channel'])->findOrFail($this->deliveryId);
|
||||
if ($delivery->result !== DeliveryResult::Pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($delivery->event->isTerminal()) {
|
||||
$delivery->update(['result' => DeliveryResult::Failed, 'error' => 'skipped: событие уже закрыто']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$outcome = $registry->for($delivery->channel->type)
|
||||
->deliver($delivery->event, $delivery->event->user, $delivery->channel->config, $delivery);
|
||||
|
||||
if ($outcome->ok) {
|
||||
$delivery->update(['result' => DeliveryResult::Ok, 'sent_at' => now(), 'error' => null, 'meta' => $outcome->meta]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$delivery->update(['error' => $outcome->error]);
|
||||
throw new DeliveryFailed($outcome->error ?? 'delivery failed');
|
||||
}
|
||||
|
||||
public function failed(Throwable $e): void
|
||||
{
|
||||
Delivery::whereKey($this->deliveryId)
|
||||
->where('result', DeliveryResult::Pending->value)
|
||||
->update(['result' => DeliveryResult::Failed, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
7
core/app/Delivery/DeliveryFailed.php
Normal file
7
core/app/Delivery/DeliveryFailed.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Delivery;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class DeliveryFailed extends RuntimeException {}
|
||||
108
core/app/Delivery/Tick.php
Normal file
108
core/app/Delivery/Tick.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user