›
›
›
  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

Troubleshooting.

Each section names a symptom that looks like a bug, its cause, and the change that fixes it.

Most problems on this page come from one of these facts. A row's status can differ from whether it is open. Eligibility is separate from selection. The package schedules nothing for you.

An offer still says "offered" after it expired

This is expected. The package decides expiry by comparing expires_at to the current time. The row stops holding its slot at once, but nothing has written to it yet.

$offer->status;        // AssignmentStatus::Offered
$offer->isExpired();   // true
$offer->isOpen();      // false

assignment:tick ends the row with reason expired the next time it runs. If dispatch_expiry_jobs is on, a delayed job can end it sooner. Assert on isOpen() or isExpired() rather than on status.

An expired offer is never re-offered

The cascade advances only when something processes the expiry. Check:

  1. assignment:tick is scheduled and your scheduler is running.
  2. The offer came from a profile in offer mode. An offer made through the builder or $model->offer() has no cascade and stops when it expires.
  3. The candidate list still has somebody who has not already declined or let an offer lapse in this cascade.

A crew who declined was offered the same work again

Exclusions last for one cascade. When a cascade runs out of candidates, the engine queues the assignable. A later flush starts a fresh cascade, and everybody is eligible again.

assignment:tick expires offers and then flushes the queue in one run, so exhaustion and a fresh cascade can happen seconds apart. Run the flush on your own schedule, separately from the tick, when you want a gap.

autoAssign() returns null and nothing is queued

Either the profile's queueWhenEmpty() returns false, or the slot already holds an open assignment. Check with isAssigned() and read the profile.

autoAssign() also returns null when a policy returns null. A closure policy can return null to mean "not now".

Nobody is ever assigned, and the queue only grows

The candidate list is coming back empty. The engine never filters candidates, so the cause is in your query:

public function candidates(Model $assignable, ?string $role): iterable
{
    return Crew::query()
        ->where('suburb', $assignable->suburb)   // no crews in this suburb?
        ->where('available', true)              // everybody offline?
        ->get();
}

Run the query by hand for one queued assignable. The cause is almost always a filter that matches nothing, not a package problem.

Somebody ineligible got assigned

The engine assigns whoever it is given. If an off-shift crew was in the candidate list, an off-shift crew gets the work.

Eligibility belongs in the query that builds the list. When state can change between building the list and writing the assignment, check the assignee in a listener on Assigned and reassign.

SlotOccupied on a slot that looks empty

The cause is a race or the wrong slot.

A race. Another request claimed the slot between your read and your write. The constraint is working as designed. Retry, or queue the work.

The wrong slot. The role-less slot and a named slot are different slots. $callout->assign($crew) and $callout->assign($crew, role: 'crew') do not collide, and reading $callout->assignee() with no role does not see the crew.

Round robin keeps picking the same person

Check the scope. Every call with a different scope string has its own cursor, so a scope built from something that changes each time, such as an id or a timestamp, gives every assignment a fresh rotation that starts at the front.

->scope('enquiry:'.$enquiry->id)          // a new cursor every time
->scope('trade:'.$enquiry->trade)   // one cursor per trade

Round robin restarts when the candidate list changes

Rotation tracks the last assignee, not a position. It starts from the front only when that assignee is no longer in the list. A query whose results change between calls, or one with no stable orderBy, looks like it restarts.

Notifications are not arriving

Work through these in order:

  1. Is the assignee using Laravel's Notifiable trait? The package skips assignees without it and raises no error.
  2. Is the moment's config key set to null?
  3. Is notification_channels set to what you expect? Mail is the only default.
  4. For database, does the notifications table exist?
  5. Can the notifiable route to the channel? A model with no email attribute routes nowhere for mail, and nothing is sent.

A decline sends nothing to the assignee who declined, by design.

Declining sends no notification but assigning does

This is expected. The assignee chose not to take the work, so there is nothing to tell them. The next candidate in the cascade gets their own offer notification.

Two workers are flushing the queue and one does nothing

This is expected. Each flush claims entries with a row lock, so the second worker finds them taken and moves on. The return value counts only the entries that worker assigned.

Assignments do not appear after a transaction

The package dispatches events after commit, so listeners and notifications wait for the outermost transaction. Inside a test wrapped in a transaction, or a long application transaction, the wait can look like nothing happened. The rows exist. The side effects run after the commit.

workload() and LeastWorkload disagree with my dashboard

Both count open assignments: active rows plus unanswered offers. A dashboard counting only active rows reads lower. Use activeAssignments for "currently holding" and workload() for "how loaded". Pick one definition for both your filter and your display.

Migration fails on an unsupported key type

ASSIGNMENT_ASSIGNABLE_KEY_TYPE and ASSIGNMENT_ASSIGNEE_KEY_TYPE accept id, uuid, ulid, and string. Anything else raises an InvalidArgumentException. Both are read when the migration runs, so publish the config before migrating.

What to read next

  • Exceptions for the failures that do throw.
  • Concurrency guarantees for what races are meant to do.
  • Offer cascades for the sequence behind most surprises.
PreviousTesting
View source

On this page

  1. An offer still says "offered" after it expired
  2. An expired offer is never re-offered
  3. A crew who declined was offered the same work again
  4. autoAssign() returns null and nothing is queued
  5. Nobody is ever assigned, and the queue only grows
  6. Somebody ineligible got assigned
  7. SlotOccupied on a slot that looks empty
  8. Round robin keeps picking the same person
  9. Round robin restarts when the candidate list changes
  10. Notifications are not arriving
  11. Declining sends no notification but assigning does
  12. Two workers are flushing the queue and one does nothing
  13. Assignments do not appear after a transaction
  14. workload() and LeastWorkload disagree with my dashboard
  15. Migration fails on an unsupported key type
  16. What to read next