factory/dispatch (0.2.0)
Installation
{
"repositories": [{
"type": "composer",
"url": " "
}
]
}composer require factory/dispatch:0.2.0About this package
factory/dispatch
Who is near this job, and who ends up with it.
A geohash supply index with nearest-available search, and offer/accept/reject windows that expire on their own. Reusable engine 9 — built project-agnostic and composed by any product that hands work to whoever is closest: couriers, freight, field service, roadside, home visits.
use Factory\Dispatch\Dispatcher;
use Factory\GeoArea\Geometry\Point;
$dispatch = app(Dispatcher::class);
$pickup = Point::make(lat: 4.0511, lng: 9.7679);
// Who's around?
$riders = $dispatch->nearest($pickup, metres: 3000, kind: 'bike');
$riders->first()->distance_metres; // real metres, great-circle
// Offer it, one at a time, skipping anyone already asked.
$rider = $dispatch->nextCandidate('job-91', $pickup, metres: 3000);
$dispatch->offer('job-91', $rider->ref, windowSeconds: 45);
// Later, from the courier's app:
$dispatch->accept('job-91', $rider->ref); // throws if lapsed or already claimed
$dispatch->reject('job-91', $rider->ref);
Keeping the index warm is the product's job — a heartbeat endpoint that does
$unit->moveTo($point)->save().
The decisions worth knowing
A prefix is not proximity. Two couriers 50 m apart can sit either side of a geohash cell boundary and share no prefix at all. The failure that causes — the nearest driver was never offered the job — is invisible from the outside, because nothing errors and somebody does eventually get it. So every search reads the cell and its eight neighbours, and haversine decides the order. The geohash narrows; it never decides. (There is a test that plants a unit across a real cell boundary; deleting the neighbour sweep turns it red.)
Great-circle, not flat plane. factory/geo-area uses a flat plane on purpose
— at city scale it is below the error of a hand-drawn polygon. Dispatch is the one
place it is not good enough: a list sorted by a plane approximation puts the wrong
person first often enough to notice, and "nearest" is the entire promise.
Expiry is derived from the clock, never stored. Offer::state() reads
expires_at and reports Expired whether or not a sweeper has run. A stored
expired written by a cron means every minute the cron is late is a minute a
courier can accept a job that was already reassigned — and that only ever happens
in production.
A job is claimed exactly once. accept() locks every offer for the job, not
just the caller's, because the race is two different couriers accepting at the
same moment — a row lock on one offer would not see it. Offering a job that is
already claimed throws: it would send a second driver on a trip that does not
exist.
A lapsed offer can still be rejected. The courier pressing "no" after the
window closed is telling us something true, and refusing the press leaves the row
saying offered forever.
ref is opaque, and so is job_ref. No foreign keys to the product's own
tables. The thing being dispatched is a booking in one product and a delivery in
another; an engine that constrains to one of them is an engine only one product
can compose.
No spatial extension. MariaDB in production and SQLite in tests; a package
that behaves differently in the two is a package nobody can test. Prefix LIKE
on an indexed column works identically on both.
No routes, no config key, no middleware alias. Accepting a job is the
product's own endpoint with the product's own authorization on it. This engine
has no idea who a courier is, so an accept route it shipped would be an
unauthenticated one. Tables are factory_-prefixed for the same family of
reason. (DoctrineTest asserts all of it, so it stays true.)
Tenancy
Both models use BelongsToProduct: rows are scoped to the resolved product and
stamped on insert. Two products can use the same job_ref without colliding.
Tests
composer install && vendor/bin/phpunit # 26 tests
Dependencies
Dependencies
| ID | Version |
|---|---|
| factory/geo-area | ^0.1 || ^0.2 |
| factory/kernel | ^0.4 || ^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9 |
| illuminate/database | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/support | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| php | ^8.2 |
Development dependencies
| ID | Version |
|---|---|
| orchestra/testbench | ^8.0 || ^9.0 || ^10.0 || ^11.0 |
| phpunit/phpunit | ^10.1 || ^11.0 || ^12.0 |