›
byrcsc/laravel-assignment · 1.x
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.
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(); // falseThe 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.
The cascade only advances when something processes the expiry. Check three things:
assignment:tick is scheduled and your scheduler is actually running.$model->offer() has no cascade and stops when it
expires.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.
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".
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.
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.
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.
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 tradeRotation 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.
Work through these in order:
Notifiable trait? Assignees without it are
skipped silently.notification_channels set to what you expect? Mail is the only default.database, does the notifications table exist?email attribute
routes nowhere for mail, and nothing is sent.A decline sends nothing to the assignee who declined, by design.
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.
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.
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.
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.
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.