Skip to content

[Platform] Introduce asynchronous jobs and move MiniMax polling out of the result converter - #2408

Open
wachterjohannes wants to merge 11 commits into
symfony:mainfrom
wachterjohannes:feature/platform-async-jobs
Open

wachterjohannes wants to merge 11 commits into
symfony:mainfrom
wachterjohannes:feature/platform-async-jobs

Conversation

@wachterjohannes

Copy link
Copy Markdown
Member
Q A
Bug fix? no
New feature? yes
Docs? yes
Issues -
License MIT

Asynchronous work already exists in this component — hand-rolled, and in the wrong layer:

  • Bridge\MiniMax\MiniMaxResultConverter::handleAsyncTask() polls a task with clock->sleep() up to 600 times, holding an HttpClientInterface, an API key and a ClockInterface to do so — inside a class whose contract is a pure RawResult → Result mapping.
  • Bridge\Replicate\Client::request() does the same in the model client.
  • Capability::TEXT_TO_SPEECH_ASYNC is already in the enum, used by exactly one bridge.

So the question is not whether the component should support async work, but that it already does, twice, inconsistently. Today one invoke() call blocks for minutes, the caller cannot observe the job or stop waiting, the operation does not survive the process that started it, and the state machine is not testable in isolation.

This PR introduces the job as a platform primitive and moves MiniMax onto it. Replicate and batch endpoints are deliberately left for follow-ups.

Why "job" and not "batch"

Video generation, asynchronous speech, OpenAI/Anthropic message batches, Gemini long-running operations and Replicate predictions all share one mechanic: submit → identifier → poll → fetch. And the body you eventually get back is the same one a synchronous call would have returned, which is why ResultConverterInterface is untouched here — an asynchronous result is not a new result type, only a different delivery.

Batch is then "a job with N items" rather than a second abstraction: JobHandle::$data carries the per-item keys when that lands.

What it looks like

$handle = $platform->invoke('MiniMax-Hailuo-02', new Text('A cat playing the piano'), [
    'duration' => 6,
])->asJob();

The handle holds no client and no connection, only what is needed to ask the provider about the job again — so it can be stored and picked up elsewhere:

// process one
$repository->save($id, json_encode($handle));

// a worker, possibly much later
$handle = JobHandle::fromArray(json_decode($repository->load($id), true));
$jobClient = $platform->getJobClient($handle);

if ($jobClient->getStatus($handle)->is(JobStateCase::SUCCEEDED)) {
    $jobClient->getResult($handle)->asFile('video.mp4');
}

To simply block, hand it to a runner — which owns the one polling loop left in the component and takes its budget from the caller, seconds for speech and minutes for video, instead of a constant inside a bridge:

$result = (new JobRunner(pollInterval: 1.0, maxPolls: 600))->wait($platform->getJobClient($handle), $handle);

Pieces

Class Role
Job\JobHandle Serializable reference: id, provider, provider-specific data. No client, no closure
Job\JobStateCase / Job\JobStatus Normalized state plus the provider's own wording, mirroring FinishReason/FinishReasonCase. An unknown state maps to UNKNOWN and counts as non-terminal, so a state a provider adds later does not abort a running job
Job\JobClientInterface getStatus() / getResult(), exactly one request per call, never sleeps
Job\JobRunner The blocking loop, with ClockInterface, poll interval and budget
Result\JobResult ResultInterface whose content is the handle; DeferredResult::asJob() reads it
Job\JobProviderInterface Optional capability, so ProviderInterface stays untouched

Provider takes an optional JobClientInterface as its last constructor argument, and stamps the provider name onto the handle after conversion — a converter cannot know the name its provider was registered under, since that is a Provider argument and Factory::createProvider() lets it be overridden.

Exception\JobFailedException carries the status; Exception\JobTimeoutException carries the handle, so a job that outlived its budget can be handed on rather than lost.

Verified against the live API

Not only against mocks — every path was run against api.minimax.io:

Result
Async speech submit 1.0s, finished after 8.5s, mp3 written
Video finished after 79.5s, 668 KB, ISO Media, MP4 Base Media v1
Resume started in one process, Processing in a second, Success + download in a third — resolved purely from the serialized handle

Running it also turned up two things mocks could not have shown, both fixed here:

MiniMax reports rejections with HTTP 200. The reason sits in base_resp and the payload keys are simply absent — an unknown voice on t2a_v2 gives 2054 "voice id not exist", an unsupported model on image_generation gives 2013 "invalid params, ...", an empty account gives 1008 "insufficient balance", all with HTTP 200, while every success carries status_code: 0. Previously this surfaced as an error about a missing array key — or, for an asynchronous task, as polling a task that never existed (task_id: 0 is handed out as a placeholder) until the budget ran out. The converter now checks base_resp for every endpoint. Chat is the exception: a bad model there does return a real HTTP 400, already covered by HttpStatusErrorHandlingTrait.

The asynchronous speech endpoint does not return audio. It returns a tar bundling the mp3 with a .titles and an .extra file, which the bridge previously labelled audio/mpeg — so asFile('out.mp3') wrote an archive. The job client now unpacks the audio, making async and sync speech produce the same thing. TarArchive is a small ustar reader: the archive uses the header's prefix field because the paths exceed the 100-byte name field, and the mp3 is not the first member, so both had to be handled. PharData would have meant a temp file plus ext-phar as a new bridge dependency.

The test fixture is a real archive produced by tar --format ustar rather than hand-written, so the reader is not being tested against its own writer.

Not in this PR

  • Replicate (Client.php:60) — same shape, follow-up now that the primitive exists.
  • Batch (N items, custom_id correlation).
  • No Capability::ASYNC_JOB — the JobResult type is already the signal, a capability would be a second truth.
  • No scheduler or webhook handling in the component. Polling belongs in ai-bundle via Messenger/Scheduler/symfony/webhook; Platform only provides submit/status/fetch.
  • No MiniMax status-code → exception mapping. Every non-zero base_resp code throws a RuntimeException carrying the code and the provider's message. Mapping 1004 to AuthenticationException and 1002 to RateLimitExceededException would be plausible, but only 1008, 2013 and 2054 were actually observed and a half-map is worse than none. Happy to add it if wanted.

BC break

Video generation and asynchronous speech return a JobResult instead of binary data, so reading them through asBinary()/asFile() throws. Waiting became explicit. MiniMaxResultConverter also lost its HTTP client, API key, endpoint and clock arguments. Both are documented in UPGRADE.md; code building the bridge through Bridge\MiniMax\Factory is unaffected.

@carsonbot carsonbot added Status: Needs Review Feature New feature Platform Issues & PRs about the AI Platform component labels Aug 15, 2026
@wachterjohannes
wachterjohannes force-pushed the feature/platform-async-jobs branch from c7ba9f6 to 3fae510 Compare August 15, 2026 20:25
*
* @internal
*/
final class TarArchive

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the API delivery a tar file - to avoid the dependency to a tar lib i have implemented this little class

Comment thread docs/components/platform.rst Outdated
Comment thread src/platform/src/Platform.php
…f the result converter

Async work already existed in the component, hand-rolled and in the wrong
layer: MiniMaxResultConverter polled a task with clock->sleep() up to 600
times, holding an HTTP client, an API key and a clock to do so, while the
ResultConverter contract is a pure RawResult -> Result mapping. A caller
could not observe the job, could not stop waiting, and the operation did
not survive the process that started it.

Adds Job as a platform primitive: a provider that answers with a job
identifier now produces a Result\JobResult carrying a serializable
Job\JobHandle. The handle holds no client and no connection, so it can be
stored in a database row or a Messenger message and resolved elsewhere via
Platform::getJobClient(). Bridges implement Job\JobClientInterface, which
performs exactly one request per call and never sleeps; Job\JobRunner is
the single blocking loop, and takes its budget from the caller instead of
from a constant in the bridge.

Provider gained an optional JobClientInterface as its last constructor
argument and stamps the provider name onto the handle after conversion,
since a converter cannot know the name it was registered under.

MiniMax video generation and asynchronous speech synthesis now return a
handle resolved by the new MiniMaxJobClient. This is a BC break for those
two endpoints, documented in UPGRADE.md.
Keeping getJobClient() off PlatformInterface meant every decorator around
a platform silently dropped it: TraceablePlatform, CachePlatform and
FailoverPlatform all implement PlatformInterface and forward only invoke()
and getModelCatalog(). Since the bundle wraps every platform in a
TraceablePlatform whenever the profiler is on, the documented way of
resolving a job handle was a fatal error in dev while it kept working in
prod.

Adds Job\JobPlatformInterface as the optional capability - mirroring
Job\JobProviderInterface - implemented by Platform and forwarded by the
three decorators. Failover walks its platforms instead of failing over: a
handle belongs to the platform that issued it, so asking another one would
query a provider about a job it never started.

The profiler now renders a JobResult as the handle it carries rather than
dumping it as if it were text: a job has no payload yet, and the handle is
what a developer needs to follow it up.
Waiting for a job needs a clock, so building the runner by hand in an
application means it silently uses a real one and cannot be controlled from
a test. Registers ai.platform.job_runner with the application clock,
autowired through Job\JobRunner.

Deliberately one service with the default budget rather than a configurable
one: how long to wait belongs to the job, not to the container - speech
finishes in seconds, video runs for minutes - so a longer-running job gets
its own runner instead of stretching the shared one for everybody.
Moving the polling out of MiniMaxResultConverter dropped what it knew:
MAX_AUDIO_POLLS was 120 and MAX_VIDEO_POLLS was 600, so the bridge was
aware that video generation runs an order of magnitude longer than speech
synthesis. Making the budget a JobRunner argument handed that decision to
the caller, who generally has no idea about a provider's timings - and the
runner's default of 120 polls is right at the edge of what video needs
(a 6 second 768p clip took 79s in practice), so it would have failed
intermittently, as a timeout that names neither cause nor remedy.

JobHandle now carries an optional maxDuration in seconds, stated by the
bridge and surviving serialization, and JobRunner derives its budget from
it. An explicit maxPolls still overrules it, for a caller who would rather
give up early - inside a web request, say.

So the two kinds of knowledge end up where they belong: how long the work
takes is the provider's, how long we are willing to wait is the caller's.
Two problems with expressing the budget as maxPolls on the runner:

The unit was wrong. A developer decides "at most five minutes", not "at
most 150 polls" - a number that only means something together with the
poll interval, so two coupled values expressed one decision.

And the budget sat on the wrong object. How long we are willing to wait is
usually a property of the call, not of the runner: the same video job may
be given ten minutes in a worker and five seconds inside a web request.
With the budget baked into the instance, anyone injecting the bundle's
shared ai.platform.job_runner had no way to bound a wait at all - they
would have had to build their own runner and lose the injected clock.

wait() now takes an optional maxDuration in seconds, and the constructor
argument becomes maxDuration too. Precedence, from strongest: the call,
the runner, what the job says it needs, the default. The timeout message
speaks in seconds accordingly and names the argument that would allow more
time.
CI caught what the platform's own PHPStan run did not: examples and bridge
tests calling asFile()/getMimeType() on what wait() and getResult() hand
back, because both returned a bare ResultInterface. Narrowing that at every
call site would have made the asynchronous path worse to use than the
synchronous one, for no reason - the result is a BinaryResult either way.

JobRunner::wait() now returns a DeferredResult, carrying the already
fetched result through PlainConverter the same way TraceablePlatform and
CachePlatform do. Finishing a job therefore reads like any other
invocation: ->asFile(), ->asBinary(), ->asText(), with the error messages
those accessors give. JobClientInterface::getResult() stays on
ResultInterface; it is the low-level primitive.

Also quotes the placeholders in the MiniMax rejection message, per fabbot.
Drops JobPlatformInterface and the four implementations it required, in
Platform, TraceablePlatform, CachePlatform and FailoverPlatform. None of
the decorators did anything with the call: they forwarded it to reach the
provider registry underneath, and every future decorator would have had to
do the same or turn the documented usage into a fatal error under the
profiler.

A job client belongs to the provider that issued the job, so the provider
hands it out through the JobProviderInterface it already implements, and
MiniMax\Factory::createJobClient() builds one for a worker that only
resolves jobs. In Symfony each job-capable platform now registers its
client as ai.platform.job_client.<name>, tagged so an application holding
handles of several providers can pick one by the name the handle carries.
@wachterjohannes
wachterjohannes force-pushed the feature/platform-async-jobs branch from 7d3bcd1 to 5bfd3b2 Compare September 1, 2026 19:44

@chr-hertel chr-hertel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have an isolation problem here that we should resolve still.

Symptoms:

  • $result instanceof JobResult in TypedResultTrait::as()
  • $result instanceof JobResult in Provider::invoke()

I think this should be handled already down below in the ModelClient/ResultConverter layer while constructing the JobResult. We don't have the provider name there, but strictly speaking that is irrelevant. from a logic point of view that layer "knows" where it is and how to move on 🤔

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BC Break Breaking the Backwards Compatibility Promise Feature New feature Platform Issues & PRs about the AI Platform component Status: Needs Work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants