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

Symptoms that look like bugs, what causes them, and what to change.

Most surprises come from three places: the difference between a row's status and whether it is open, the separation between eligibility and selection, and the fact that nothing is scheduled unless you schedule it.

An offer still says "offered" after it expired

Expected. Expiry is decided by comparing expires_at to now, so the row stops holding its slot immediately, but nothing has written to it yet.

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

The row is ended, with reason expired, when assignment:tick next runs, or by a delayed job if dispatch_expiry_jobs is on. Assert on isOpen() or isExpired() rather than on status.

An expired offer is never re-offered

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

  1. assignment:tick is scheduled and your scheduler is actually 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 assignable is queued, and a later flush starts a fresh cascade with everybody 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.

Remember that null is also what you get when a policy returns null, which is a legitimate way for a closure policy to say "not now".

Nobody is ever assigned, and the queue only grows

The candidate list is coming back empty. The engine never filters candidates, so this is 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. It 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 the assignment landing, verify in a listener on Assigned and reassign.

SlotOccupied on a slot that looks empty

Two causes.

A race. Another request claimed the slot between your read and your write. That is the constraint working. 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 is anchored to the last assignee, not to a position. It starts from the front only when that assignee is no longer in the list. A query whose results churn, or one with no stable orderBy, will look like it restarts.

Notifications are not arriving

Work through these in order:

  1. Is the assignee using Laravel's Notifiable trait? Assignees without it are skipped silently.
  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

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

Expected. Entries are claimed 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

Events are dispatched after commit, so listeners and notifications wait for the outermost transaction. Inside a test wrapped in a transaction, or a long application transaction, that can look like nothing happened. The rows are there; the side effects are pending.

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", and 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