[Platform] Introduce asynchronous jobs and move MiniMax polling out of the result converter - #2408
Open
wachterjohannes wants to merge 11 commits into
Open
wachterjohannes wants to merge 11 commits into
wachterjohannes wants to merge 11 commits into
Conversation
wachterjohannes
requested review from
OskarStark and
chr-hertel
as code owners
August 15, 2026 20:06
wachterjohannes
force-pushed
the
feature/platform-async-jobs
branch
from
August 15, 2026 20:25
c7ba9f6 to
3fae510
Compare
wachterjohannes
commented
Aug 15, 2026
| * | ||
| * @internal | ||
| */ | ||
| final class TarArchive |
Member
Author
There was a problem hiding this comment.
the API delivery a tar file - to avoid the dependency to a tar lib i have implemented this little class
chr-hertel
reviewed
Aug 16, 2026
chr-hertel
reviewed
Aug 16, 2026
wachterjohannes
force-pushed
the
feature/platform-async-jobs
branch
from
August 24, 2026 21:21
2ad2088 to
7d3bcd1
Compare
…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
force-pushed
the
feature/platform-async-jobs
branch
from
September 1, 2026 19:44
7d3bcd1 to
5bfd3b2
Compare
chr-hertel
requested changes
Sep 11, 2026
chr-hertel
left a comment
Member
There was a problem hiding this comment.
We have an isolation problem here that we should resolve still.
Symptoms:
$result instanceof JobResultinTypedResultTrait::as()$result instanceof JobResultinProvider::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 🤔
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Asynchronous work already exists in this component — hand-rolled, and in the wrong layer:
Bridge\MiniMax\MiniMaxResultConverter::handleAsyncTask()polls a task withclock->sleep()up to 600 times, holding anHttpClientInterface, an API key and aClockInterfaceto do so — inside a class whose contract is a pureRawResult → Resultmapping.Bridge\Replicate\Client::request()does the same in the model client.Capability::TEXT_TO_SPEECH_ASYNCis 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
ResultConverterInterfaceis 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::$datacarries the per-item keys when that lands.What it looks like
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:
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:
Pieces
Job\JobHandledata. No client, no closureJob\JobStateCase/Job\JobStatusFinishReason/FinishReasonCase. An unknown state maps toUNKNOWNand counts as non-terminal, so a state a provider adds later does not abort a running jobJob\JobClientInterfacegetStatus()/getResult(), exactly one request per call, never sleepsJob\JobRunnerClockInterface, poll interval and budgetResult\JobResultResultInterfacewhose content is the handle;DeferredResult::asJob()reads itJob\JobProviderInterfaceProviderInterfacestays untouchedProvidertakes an optionalJobClientInterfaceas 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 aProviderargument andFactory::createProvider()lets it be overridden.Exception\JobFailedExceptioncarries the status;Exception\JobTimeoutExceptioncarries 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:
ISO Media, MP4 Base Media v1Processingin a second,Success+ download in a third — resolved purely from the serialized handleRunning 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_respand the payload keys are simply absent — an unknown voice ont2a_v2gives2054 "voice id not exist", an unsupported model onimage_generationgives2013 "invalid params, ...", an empty account gives1008 "insufficient balance", all with HTTP 200, while every success carriesstatus_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: 0is handed out as a placeholder) until the budget ran out. The converter now checksbase_respfor every endpoint. Chat is the exception: a bad model there does return a real HTTP 400, already covered byHttpStatusErrorHandlingTrait.The asynchronous speech endpoint does not return audio. It returns a tar bundling the mp3 with a
.titlesand an.extrafile, which the bridge previously labelledaudio/mpeg— soasFile('out.mp3')wrote an archive. The job client now unpacks the audio, making async and sync speech produce the same thing.TarArchiveis a small ustar reader: the archive uses the header'sprefixfield because the paths exceed the 100-byte name field, and the mp3 is not the first member, so both had to be handled.PharDatawould have meant a temp file plusext-pharas a new bridge dependency.The test fixture is a real archive produced by
tar --format ustarrather than hand-written, so the reader is not being tested against its own writer.Not in this PR
Client.php:60) — same shape, follow-up now that the primitive exists.custom_idcorrelation).Capability::ASYNC_JOB— theJobResulttype is already the signal, a capability would be a second truth.ai-bundlevia Messenger/Scheduler/symfony/webhook; Platform only provides submit/status/fetch.base_respcode throws aRuntimeExceptioncarrying the code and the provider's message. Mapping1004toAuthenticationExceptionand1002toRateLimitExceededExceptionwould be plausible, but only1008,2013and2054were 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
JobResultinstead of binary data, so reading them throughasBinary()/asFile()throws. Waiting became explicit.MiniMaxResultConverteralso lost its HTTP client, API key, endpoint and clock arguments. Both are documented inUPGRADE.md; code building the bridge throughBridge\MiniMax\Factoryis unaffected.