The Problem #
You are processing some data through background job. But before the processing is done, another request had been made to read the related data.
In this case you are either providing a historic data or serving wrong information.
Solution #
Holding the request until the job is executed, could be the simplest solution.
I am not saying it is the only solution, but the simplest one.
Some Scenarios #
Lets discuss about some possible scenarios.
Booking Job
When a user request to book a resource between two specifics dates. Let's assume that it is done by a job. So it might take some time in production load.
In the meantime if another request is asking for that specific users booking data.
File Importing Job
User uploads a file like CSV or XML, you have accepted the file but it also needs processing, which should be done in by a job.
If the user ask for the status of the CSV resource in another request.
The Package #
aihimel/laravel-waiting-request is a small Laravel package that solves exactly this β it lets one request
parkuntil another piece of work (a job, a sync, a long-running controller action) signals that the resource is ready to read.
Install
composer require aihimel/laravel-waiting-request
Optionally publish the config:
php artisan vendor:publish --tag="waiting-request-config"
How it works
The package exposes a tiny API around four ideas: block, wait, check, resolve. Under the hood it is backed by your Laravel cache β no extra infrastructure, no queue plumbing.
A blocker is identified by a class path and a resource id. That pair becomes a unique cache key, so blockers are per-resource (booking 42
does not interfere with booking 43
).
use Aihimel\LaravelWaitingRequest\Facades\LWRequest;
// 1. Block β call this where the background work *starts*
LWRequest::addBlocker(Booking::class, $booking->id);
// 2. Wait β call this in the request that wants to read the resource
$resolved = LWRequest::whenResolved(Booking::class, $booking->id);
if ($resolved) {
return BookingResource::make($booking->fresh());
}
return response()->json(['message' => 'Still processing, try again'], 202);
// 3. Resolve β call this when the background work finishes
LWRequest::resolveBlocker(Booking::class, $booking->id);
You can also peek without waiting:
if (LWRequest::isBlocked(Booking::class, $booking->id)) {
// resource is mid-flight
}
Applying it to the scenarios
Booking job. The controller that accepts the booking calls addBlocker(Booking::class, $id)
and dispatches the job. The job calls resolveBlocker(...)
in its handle()
(or in a finally
block). Any reader that hits GET /bookings/{id}
in the meantime calls whenResolved(...)
first and only reads the model once the writer is done.
File importing job. Same shape: addBlocker(Import::class, $import->id)
when the upload is accepted, resolveBlocker(...)
when the parser finishes (success or failure β both should release). The status endpoint calls whenResolved(...)
so the client gets a settled answer instead of a half-imported snapshot.
Sensible defaults you can tune
Every knob lives in config/waiting-request.php
and is overridable via env:
| Config | Env | Default | What it does |
|---|---|---|---|
cache_prefix |
|||
LW_REQUEST_CACHE_PREFIX |
|||
lw_request_ |
|||
| Namespace for cache keys | |||
timeout |
|||
LW_REQUEST_MAX_WAITING_TIME |
|||
5 |
|||
How long whenResolved() waits before giving up (seconds) |
|||
check_interval |
|||
LW_REQUEST_CHECK_INTERVAL |
|||
250 |
|||
Poll interval inside whenResolved() (milliseconds) |
|||
max_blocking_time |
|||
LW_REQUEST_MAX_BLOCKING_TIME |
|||
10 |
|||
| Max lifetime of a blocker before it auto-expires (seconds) |
addBlocker()
takes an optional third argument so you can bump the TTL per call when you know a particular job runs longer:
LWRequest::addBlocker(Import::class, $import->id, 120); // 2 minutes
Why the blocker has a lifetime
If a job crashes before calling resolveBlocker()
, you do not want readers to wait forever. From v2.x every blocker carries a Unix expiry timestamp. The next isBlocked()
/ whenResolved()
call after that timestamp will:
- Forget the cache entry, and
- Emit
Log::warning('Waiting-request blocker expired without being resolved', [...])
So even if your job dies, traffic recovers on its own and you get a log line telling you it happened.
Do's and Don'ts #
Do
Do release the blocker in Wrap your job body so a thrown exception still hitsfinally
.resolveBlocker()
. Auto-expiry is a backstop, not a happy path. -
Do set If your import averages 8s and worst-cases at 25s, a 10s default will auto-release while the job is still running β defeating the lock.max_blocking_time
to comfortably exceed your worst-case job duration. -
Do tune If a client is willing to wait 2s for a synchronous response, settimeout
to match your UX budget.timeout=2
; do not letwhenResolved()
hold an HTTP worker for 30s. -
Do flush the cache when upgrading from 1.x to 2.x. Pre-upgrade values stored astrue
will be read as1
, treated as already-expired, and produce a one-time burst of warning logs. -
Do treat a Respond withfalse
return fromwhenResolved()
as "still pending".202 Accepted
(or similar) and let the client poll β do not pretend the data is ready.
Don't
Don't put It evicts expired entries and writes a log line. That is intentional, but worth knowing.isBlocked()
on a hot, read-only path you expect to be side-effect-free. -
Don't use it as a distributed mutex for This package is forwrites.readers waiting on writerson a best-effort basis. If two writers race,Cache::add()
will reject the secondaddBlocker()
(it returnsfalse
), but the package does not give you queueing, fairness, or strict mutual exclusion. -
Don't share a single blocker across unrelated resources. Key it by the real resource (Booking::class + $id
), not by something coarse like the user id, or you will block requests that have nothing to do with each other. -
Don't forget the cache driver matters.array
orfile
drivers will not work across processes. In production, useredis
/memcached
so the worker that resolves the blocker and the web process that is waiting actually share the same cache. -
Don't lean on Polling inside a worker burns a worker slot. Workers shouldwhenResolved()
from a queue worker.resolveblockers, notwaiton them.
That's the whole package β a couple of facade calls, a cache key per resource, and a sane expiry so nothing wedges. If you've ever shipped a ?retry=true
hack or a sleep-and-pray in a controller, this is the cleaner version of that.
Source & issues: github.com/aihimel/laravel-waiting-request