›
byrcsc/laravel-assignment · 1.x
An overdue offer releases its slot at its expiry timestamp, and the tick ends the row and advances its cascade.
When you give an offer a TTL (time to live), the offer stops holding its slot
at expires_at, because the timestamp alone decides. The row ends later, when
whatever runs next ends it and advances its cascade.
Reads never wait for the row to end. Keep that in mind when you decide how often to schedule the tick.
$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 for open rows now excludes it. The open scope checks
expires_at as well as status.
The assign() call in the example ends the old offer with reason expired
and dispatches OfferExpired before it writes the new row. The assignee who
let the offer lapse gets the same notification as when the tick expires it.
A stopped worker therefore cannot leave a slot blocked. At worst, it leaves
rows that still read offered and cascades that have not advanced.
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('assignment:tick')->everyMinute();assignment:tick runs these steps in order:
expired, dispatches OfferExpired
for each, and advances its cascade.Expired 3 offer(s) and assigned 1 queued item(s).The tick reads overdue offers in batches of 100, so a large backlog does not load every row at once.
Every minute is a reasonable default. Running the tick more often costs one query when there is nothing to do. Running it less often leaves declined and expired work waiting for longer.
Cron runs at most once a minute, so an offer that expires at 12:00:05 waits until 12:01:00 for its cascade to move. When that delay matters, have the engine also dispatch a delayed job per offer:
// config/assignment.php
'dispatch_expiry_jobs' => true,When the engine creates an offer, it queues a job with a delay that matches
expires_at. When the job runs, it expires that offer and advances its
cascade.
The engine queues a job only when all of these are true:
expires_at. Offers with no TTL never queue a job.A job whose offer was already answered or already expired does nothing. You can run both the job and the tick. Whichever reaches the offer first expires it, and the other does nothing.
Keep the tick scheduled when you turn jobs on. A lost or failed job leaves a cascade stalled. The tick expires the offers that such a job missed, so it is a fallback, not 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 stays open until the assignee answers it. Null suits
work where nobody else is waiting. Any other value must be at least 1. A
profile that returns zero or less makes autoAssign(), flushQueue(), and
assignment:tick throw InvalidArgumentException. A short TTL suits work that
must keep moving, at the cost of asking more people.
With cascades, the engine re-offers the assignable only after it processes the expiry. Unless delayed jobs are on, the effective delay is the TTL plus the time until the next tick.
The tick is a convenience. You can run the queue half on its 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.
Split them when you want to flush the queue on your own domain events and sweep expiry on a schedule. Splitting them also avoids the tick's exhaust-and-re-offer behaviour.