Actions and Metrics

Queued Actions

Run an action in queued jobs, as the user who sent the request, and follow a batch from the client.

An action that takes time can run in queued jobs. The request reads the rows, puts their keys into one job per chunk, and answers at once. The worker runs handle() as the user who sent the request.

Queuing an action

Implement ShouldQueue:

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Collection;
use Lomkit\Rest\Actions\Action;
use Lomkit\Rest\Actions\ActionFields;

class ExportOrders extends Action implements ShouldQueue
{
    public function handle(ActionFields $fields, Collection $models): void
    {
        // Runs in the worker, as the user who sent the request.
    }
}
  • The request pushes one Lomkit\Rest\Actions\QueuedAction job per chunk of $chunkCount rows, and answers {"data": {"impacted": 250, "result": null}}. impacted counts the rows put into the jobs.
  • One run creates at most rest.limits.sizes.jobs jobs, 100 by default and at least 1. When the rows need more jobs, each job takes the number of rows divided by that limit, rounded up, instead of $chunkCount: 250 rows with a $chunkCount of 2 give 84 jobs of 3 rows, not 125 jobs. Raise rest.limits.sizes.jobs when each job must keep $chunkCount rows, see Limits.
  • respond() runs in the request, once the jobs are pushed.
  • In tests, assert Queue::assertPushed(QueuedAction::class). The job shows the action class as its name.

Choosing the queue

Put Laravel's queue attributes on the action, or route the action class with Queue::route():

use Illuminate\Queue\Attributes\Queue;
use Illuminate\Queue\Attributes\Tries;

#[Queue('exports')]
#[Tries(3)]
class ExportOrders extends Action implements ShouldQueue
{
    // ...
}
Queue::route(ExportOrders::class, 'exports', 'redis');

#[Connection], #[Queue], #[Delay], #[Tries], #[Timeout], #[Backoff], #[MaxExceptions] and #[FailOnTimeout] apply to the jobs of the action. An attribute wins over a route. An action has no $queue or $connection property and no dispatch() method.

release(), attempts(), fail(), delete() and batch() work in handle(). An action that implements Illuminate\Contracts\Queue\ShouldBeEncrypted gets its job payload encrypted, input values included.

What the worker runs

The job holds the resource class, the action class and key, the input values as the client sent them, the keys of the rows and a reference to the user. The worker builds the request context again:

  • $this->request()->user() returns the user who sent the request, in handle(), in perimeterQuery() and in the policies that the package checks. The worker logs nobody in: auth()->user() and the Gate facade see no user there.
  • The worker finds the action by its key among the actions() of the resource, so the options set when declaring it are kept, and casts the input values with fields() again.
  • The worker loads the rows by key through perimeterQuery() and the global scopes of the model. A row deleted or moved out of the perimeter before the job runs is skipped, and a job whose rows are all gone does not call handle().
  • The worker loads the columns of the rows, without the includes and aggregates of the search. Load the relations that handle() needs there: $models->load('author').
  • The rows were checked in the request with authorizeEach(). The worker does not check them again: call $this->resource->authorizedTo('update', $model) in handle() to check a row as it is when the job runs.
  • Each chunk runs in a transaction around handle(), unless the action uses ActionTransaction::None. With ActionTransaction::Row, each row of the job runs in its own transaction around handleRow().

A job fails with Lomkit\Rest\Exceptions\QueuedActionException when the user provider no longer finds the user, or when the resource no longer declares the action, for example after a deploy:

The resource [App\Rest\Resources\PostResource] no longer declares the action [App\Rest\Actions\PublishPosts] under the key [publish-posts].
With several tenants, initialise the tenant in the worker before the job runs, as the QueueTenancyBootstrapper of stancl/tenancy 3 does from the job payload. The worker reads the user and the rows on the database connections that are current when the job runs.

Do not put a resource, an action or a request context in a job of your own: they cannot be serialized. Pass class names and keys. ActionFields can be serialized: it keeps the cast values and finds the user again when it is unserialized.

Batched actions

Implement Lomkit\Rest\Contracts\BatchableAction next to ShouldQueue to run the jobs in a Laravel batch. withBatch() registers the callbacks of the batch:

use Illuminate\Bus\Batch;
use Illuminate\Bus\PendingBatch;
use Illuminate\Contracts\Queue\ShouldQueue;
use Lomkit\Rest\Actions\Action;
use Lomkit\Rest\Actions\ActionFields;
use Lomkit\Rest\Contracts\BatchableAction;
use Throwable;

class ExportOrders extends Action implements ShouldQueue, BatchableAction
{
    public function withBatch(ActionFields $fields, PendingBatch $batch): void
    {
        $batch->then(function (Batch $batch) {
            // Every job ran.
        })->catch(function (Batch $batch, Throwable $e) {
            // A job failed.
        });
    }
}

The response gives the id of the batch, between impacted and result:

{"data": {"impacted": 250, "batch": "9f0c4d2e-5a1b-4c3d-8e7f-0a1b2c3d4e5f", "result": null}}
  • Read the batch with Bus::findBatch($id). The batch keeps the resource, the action and the user who started it in its options.
  • The jobs skip their rows once the batch is cancelled: $this->batch()?->cancel() in handle(), or Bus::findBatch($id)?->cancel(), stops the jobs that did not run yet.
  • A batched action sends its batch to the connection and queue of its jobs.
  • Exports writes the rows of a search to a CSV file with a batched action.

Following a batch

withBatchProgress() adds a progress route to the resource:

Rest::resource('invoices', InvoiceController::class)->withBatchProgress();
GET /api/invoices/actions/export-orders/batches/9f0c4d2e-5a1b-4c3d-8e7f-0a1b2c3d4e5f
{"data": {"id": "9f0c4d2e-5a1b-4c3d-8e7f-0a1b2c3d4e5f", "total_jobs": 3, "pending_jobs": 1, "failed_jobs": 0, "progress": 67, "finished": false, "cancelled": false}}
  • The route is named {name}.batch. It answers the user who started the batch, through the same guard.
  • Another user, a guest, another action and an unknown batch get a 404, and so does every batch started by a guest.
  • The route checks the abilities that the resource's abilities() returns for Endpoint::Batch, none by default.
  • progress goes from 0 to 100.