›
›
›
  1. docs
  2. ›
  3. byrcsc/laravel-assignment
1.x
Browse documentationOpenClose

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Assignments and slots
  • Assigning and offering
  • Selection policies
  • Scopes and rotation
  • Profiles
  • The queue
  • Offer cascades

Operations

  • Expiry and scheduling
  • Reading assignments
  • Notifications
  • Events and listeners
  • Concurrency guarantees

Reference

  • Configuration
  • API reference
  • Console commands
  • Exceptions
  • Testing
  • Troubleshooting

Getting started

  • Introduction
  • Installation and setup
  • Quick start

Core concepts

  • Assignments and slots
  • Assigning and offering
  • Selection policies
  • Scopes and rotation
  • Profiles
  • The queue
  • Offer cascades

Operations

  • Expiry and scheduling
  • Reading assignments
  • Notifications
  • Events and listeners
  • Concurrency guarantees

Reference

  • Configuration
  • API reference
  • Console commands
  • Exceptions
  • Testing
  • Troubleshooting

byrcsc/laravel-assignment · 1.x

Expiry and scheduling.

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.

The timestamp decides

$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');   // succeeds

The 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.

The tick

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('assignment:tick')->everyMinute();

assignment:tick does two things in order:

  1. Ends every overdue offer with reason expired, dispatching OfferExpired for each, and advances its cascade.
  2. Flushes the queue.
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.

Second-level precision

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:

  • The offer must have an expires_at. Offers with no TTL never queue a job.
  • The offer must belong to a cascade, so it came from a profile in offer mode. Ad-hoc offers never queue a job.
  • The job is dispatched after the surrounding transaction commits, so a rolled back offer leaves no job behind.

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.

Choosing a TTL

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.

Running the pieces separately

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 type

There 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.

What it does not do

  • Expire assignments. Only offers have a TTL. An active assignment stays active until something ends it.
  • Register the schedule for you. Nothing is scheduled unless you schedule it.
  • Retry a failed job. Delayed jobs follow your queue's normal retry rules, and the tick covers what they miss.

What to read next

  • Offer cascades for what happens after an offer expires.
  • Console commands for the command's exact signature.
  • Configuration for the job toggle.
PreviousOffer cascadesNextReading assignments
View source

On this page

  1. The timestamp decides
  2. The tick
  3. Second-level precision
  4. Choosing a TTL
  5. Running the pieces separately
  6. What it does not do
  7. What to read next