›
byrcsc/laravel-assignment · 1.x
An overdue offer stops holding its slot on the timestamp alone, and the tick advances what follows.
Give an offer a TTL and two separate things happen at expires_at. The offer
stops holding its slot immediately, decided by the timestamp. The row is then
tidied up and its cascade advanced by whatever runs next.
Reads never wait for the second part. That is the property worth understanding before you decide how often to schedule anything.
$offer = $callout->offer($crew, role: 'crew', ttlSeconds: 120);
// two minutes and one second later, with no command having run:
$offer->isOpen(); // false
$offer->isExpired(); // true
$callout->openAssignment('crew'); // null
$callout->assign($otherCrew, 'crew'); // succeedsThe row still reads offered and its ended_at is still null. What changed is
that every query treating the row as open excludes it, because the open scope
checks expires_at as well as status.
A stopped worker therefore cannot wedge a slot. The worst it can do is leave rows that look offered and cascades that have not moved on.
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('assignment:tick')->everyMinute();assignment:tick does two things in order:
expired, dispatching OfferExpired
for each, and advances its cascade.Expired 3 offer(s) and assigned 1 queued item(s).Overdue offers are read in batches of 100, so a large backlog does not load every row at once.
Every minute is a reasonable default. The cost of running it more often is one query when there is nothing to do; the cost of running it less often is that declined and expired work sits still for longer.
Cron granularity means an offer that expires at 12:00:05 waits until 12:01:00 for its cascade to move. When that matters, have the engine dispatch a delayed job per offer as well:
// config/assignment.php
'dispatch_expiry_jobs' => true,At the moment an offer is created, a job is queued with a delay matching
expires_at. When it runs, it expires that offer and advances its cascade.
Three conditions apply:
expires_at. Offers with no TTL never queue a job.A job whose offer was already answered or already expired does nothing. Running both the job and the tick is safe; whichever gets there first wins and the other becomes a no-op.
Keep the tick scheduled when you turn jobs on. A lost or failed job leaves a cascade stalled. The tick is what catches that, which is why it is a safety net rather than a duplicate.
A TTL is per offer, and profiles set it per role:
public function offerTtl(Model $assignable, ?string $role): ?int
{
return $role === 'crew' ? 120 : null;
}Return null and the offer stands until answered, which suits work where nobody else is waiting. A short TTL suits work that must keep moving, at the cost of asking more people.
Remember what a TTL means with cascades: the assignable is only re-offered after expiry is processed, so the effective delay is the TTL plus the time until the next tick, unless delayed jobs are on.
The tick is a convenience. Both halves are callable on their own:
use ByRcsc\LaravelAssignment\Facades\Assignment;
Assignment::flushQueue(); // the queue half
Assignment::flushQueue(Enquiry::class); // one assignable typeThere is no public entry point for the expiry half other than the command, so
run assignment:tick when you want expiry processed.
Splitting them is worth it when you want the queue flushed on your own domain events and expiry swept on a schedule, or when you want to avoid the tick's exhaust-and-re-offer behaviour.