Pular para conteúdo

Jobs e Execucao Assincrona

O MIDDAG separa tres conceitos para trabalho executavel:

  • Command -- a unidade de trabalho (o que fazer).
  • dispatch_async -- execucao em background via Moodle adhoc task (fire-and-forget).
  • Job -- governanca de execucao com retry, correlacao e observabilidade.

Command vs Job

Aspecto Command Job
Papel Payload da acao (value object) Registro de governanca (aggregate)
Persistencia Serializado no adhoc task Tabelas middag_job e middag_job_attempt
Identidade Sem identidade propria UUID v7 com identidade estavel
Retry Nao governa Politica de tentativas sob controle do aggregate
Correlacao Nao possui ID opaco de correlacao
Quando usar Sempre (e a unidade basica) Quando precisar de observabilidade ou retry

Nem todo command precisa de um job. A criacao de um job e uma decisao explicita, nao automatica.


Quick example

1. Criar o command

namespace local_meuplugin\extensions\catalogo\item;

use local_middag\base\command;

final class sync_inventory_command extends command
{
    public function __construct(
        public readonly int $warehouse_id,
        public readonly string $source,
    ) {}

    public function to_payload(): array
    {
        return [
            'warehouse_id' => $this->warehouse_id,
            'source'       => $this->source,
        ];
    }

    public static function from_payload(array $payload): static
    {
        return new static(
            warehouse_id: (int) ($payload['warehouse_id'] ?? 0),
            source:       (string) ($payload['source'] ?? ''),
        );
    }
}

2. Criar o handler

O handler e resolvido por convencao: {Command}_handler, mesmo namespace, metodo __invoke():

namespace local_meuplugin\extensions\catalogo\item;

use local_middag\base\command_handler;

final class sync_inventory_command_handler extends command_handler
{
    public function __construct(
        private readonly inventory_service_interface $inventory,
    ) {}

    public function __invoke(sync_inventory_command $command): void
    {
        $this->inventory->sync($command->warehouse_id, $command->source);
    }
}

3. Despachar

use local_middag\facade\command_bus;

// Sincrono -- executa agora.
command_bus::handle(new sync_inventory_command(
    warehouse_id: 7,
    source: 'erp',
));

// Assincrono -- enfileira no cron do Moodle.
command_bus::dispatch_async(new sync_inventory_command(
    warehouse_id: 7,
    source: 'erp',
));

API reference

command_interface

Contract base de todo command:

Metodo Retorno Descricao
to_payload() array<string, mixed> Serializa para primitivos (persistencia)
from_payload($p) static Reconstroi o command a partir do payload salvo

command_bus_interface

Bus que resolve e executa commands:

Metodo Retorno Descricao
handle($command) void Execucao sincrona -- resolve handler e executa imediatamente
dispatch_async($command, ?$options) void Serializa o command e enfileira como Moodle adhoc task

Classes base

Classe Namespace Papel
base\command local_middag\base Classe base para commands de extensions
base\command_handler local_middag\base Classe base (opcional) para handlers de extensions

Dispatch sincrono (handle)

Resolve o handler pelo FQCN do command + sufixo _handler, injeta dependencias do container DI, e chama __invoke():

command_bus::handle(new publish_item_command(42, 1));

O handler executa no mesmo request. Se falhar, a excecao propaga para o chamador.


Dispatch assincrono (dispatch_async)

Serializa o command via to_payload(), cria um Moodle adhoc task, e delega ao cron. O handler sera executado na proxima rodada do cron:

command_bus::dispatch_async(new send_notification_command(
    item_id: 42,
    recipients: [10, 20, 30],
));

O adhoc task reconstroi o command via from_payload() e executa o handler normalmente. Sem retry, sem correlacao, sem registro persistido -- e fire-and-forget.


Quando usar job

Se voce precisa de retry, correlacao ou observabilidade, use o job_service em vez de dispatch_async direto:

use local_middag\facade\job_service;

$job = job_service::dispatch(new sync_inventory_command(7, 'erp'), [
    'subjectid'   => 7,
    'subjecttype' => 'warehouse',
    'extension'   => 'catalogo',
]);

Ciclo de vida do job

pending --> running --> succeeded
                  \--> failed --> (retry) --> running --> ...
                                         \--> cancelled
Estado Descricao
pending Job criado, aguardando execucao
running Handler em execucao (attempt ativo)
succeeded Ultima tentativa concluida com sucesso
failed Ultima tentativa falhou; pode haver retry agendado
cancelled Cancelado explicitamente antes de completar

Cada tentativa e registrada como job_attempt imutavel -- historico completo e preservado.

Correlacao

O job usa um identificador opaco de correlacao para vincular a execucao a operacao de origem, sem acoplamento direto ao aggregate:

$job = job_service::dispatch($command, [
    'correlation_id' => 'import-batch-2026-04',
    'subjectid'      => $company_id,
    'subjecttype'    => 'company',
    'extension'      => 'bigquery',
]);

Isso permite consultar: "quais jobs foram disparados para a empresa X no lote de abril?"


Tabela de decisao

Cenario Mecanismo
Acao imediata, resultado necessario agora command_bus::handle()
Background simples, sem necessidade de rastreio command_bus::dispatch_async()
Precisa de retry em caso de falha job_service::dispatch()
Precisa correlacionar multiplas execucoes job_service::dispatch()
Admin precisa ver status na UI job_service::dispatch()
Envio de email ou notificacao command_bus::dispatch_async()
Sincronizacao com API externa (pode falhar) job_service::dispatch()

Patterns

Fire-and-forget

Para trabalho que nao precisa de rastreio (notificacoes, invalidacao de cache):

command_bus::dispatch_async(new invalidate_cache_command($context_id));

Simples, leve, sem overhead de governanca.

Idempotencia no handler

Handlers devem suportar re-execucao segura. Se o job faz retry apos falha de rede, a segunda execucao nao deve duplicar o efeito:

public function __invoke(sync_inventory_command $command): void
{
    // Verifica se ja foi sincronizado antes de agir.
    if ($this->inventory->is_synced($command->warehouse_id)) {
        return;
    }
    $this->inventory->sync($command->warehouse_id, $command->source);
}

Payload simples

O payload do command deve conter apenas dados primitivos e IDs. Nao coloque objetos complexos, conexoes ou recursos:

// CORRETO: primitivos e IDs.
public function __construct(
    public readonly int $item_id,
    public readonly int $user_id,
    public readonly string $action,
) {}

// ERRADO: objeto complexo no payload.
public function __construct(
    public readonly \stdClass $item,   // Nao serializa de forma segura.
    public readonly \moodle_database $db, // Recurso, nao serializavel.
) {}

Separacao de responsabilidade

O command carrega dados. O handler executa logica. Nunca coloque logica de negocio no command:

// CORRETO: command e puro DTO.
final class send_webhook_command extends command
{
    public function __construct(
        public readonly string $url,
        public readonly array $data,
    ) {}
}

// ERRADO: logica no command.
final class send_webhook_command extends command
{
    public function execute(): void  // Nao faca isso.
    {
        file_get_contents($this->url);
    }
}

Anti-patterns

Dispatch sincrono pesado

// ERRADO: operacao demorada bloqueando o request.
command_bus::handle(new import_10k_records_command($file_id));

Operacoes pesadas devem ser assincronas. Use dispatch_async() ou job_service::dispatch().

Handler sem tratamento de erro

// ERRADO: excecao nao tratada, sem possibilidade de retry.
public function __invoke(sync_command $command): void
{
    $this->http->post($url, $data); // Se falhar, perde-se.
}

Para integracao com servicos externos, use job com retry. Se usar dispatch_async direto, trate excecoes no handler.

Command com estado mutavel

// ERRADO: propriedades mutaveis.
final class bad_command extends command
{
    public int $attempt_count = 0; // Estado mutavel no command.

    public function increment(): void
    {
        $this->attempt_count++;
    }
}

Commands sao value objects imutaveis. Estado de execucao pertence ao job, nao ao command.

Criar job para tudo

Nem toda operacao assincrona precisa de job. Jobs adicionam overhead de persistencia e governanca. Para trabalho simples sem necessidade de retry ou rastreio, dispatch_async e suficiente.


Ciclo de vida completo

sequenceDiagram
    participant S as Service/Controller
    participant B as command_bus
    participant J as job_service
    participant Q as adhoc_task (cron)
    participant H as Handler

    Note over S,H: Dispatch sincrono
    S ->> B: handle(command)
    B ->> H: __invoke(command)

    Note over S,H: Dispatch async (fire-and-forget)
    S ->> B: dispatch_async(command)
    B ->> Q: Serializa e enfileira
    Q ->> B: Reconstroi e handle()
    B ->> H: __invoke(command)

    Note over S,H: Dispatch com job (governanca)
    S ->> J: dispatch(command, options)
    J ->> J: Cria job (pending)
    J ->> Q: Enfileira adhoc task
    Q ->> J: Marca running, executa
    J ->> H: __invoke(command)
    H -->> J: Sucesso ou falha
    J ->> J: Registra attempt, atualiza estado

Referencias

  • Comandos -- guia detalhado de commands sync/async
  • Modelo de comando assincrono (ADR-705)
  • Job como aggregate (ADR-504)
  • Core Capabilities (ADR-607)