requests = $requests; $this->providers = $providers; $this->inputs = $inputs; $this->files = $files; $this->scheduler = $scheduler; $this->clock = $clock; $this->notify = $notify; } /** * Claims a queued request and sends it to the provider. Does nothing when it is not queued. * * @param string $id Request UUID. */ public function run( string $id ): void { $row = $this->requests->find( $id ); if ( null === $row ) { return; } $token = Uuid::v4(); $now = $this->now(); if ( ! $this->requests->claim( $id, $token, $this->time( $now + self::LEASE_SECONDS ), $this->time( $now ) ) ) { return; } $provider = $this->provider( $row ); if ( null === $provider ) { $this->fail( $id, JobError::NO_PROVIDER ); return; } try { $input = $this->inputs->build( $row ); } catch ( InputUnavailable $e ) { $this->fail( $id, $e->reason() ); return; } catch ( \Throwable $e ) { $this->fail( $id, JobError::INTERNAL ); return; } if ( ! $this->requests->mark_sent( $id, $token ) ) { return; } try { $submission = $provider->submit( $input ); } catch ( \Throwable $e ) { // The message may contain request details or a key fragment; only the code is kept. $this->fail( $id, JobError::INTERNAL ); return; } $this->settle( $id, $token, $submission ); } /** * Asks the provider about an asynchronous request. Does nothing unless it is waiting on one. * * @param string $id Request UUID. */ public function poll( string $id ): void { $row = $this->requests->find( $id ); if ( null === $row || Status::RUNNING !== $row['status'] || '' === (string) ( $row['remote_id'] ?? '' ) ) { return; } $now = $this->now(); if ( $now - LeasePolicy::timestamp( $row['created_at'] ?? null, $now ) > LeasePolicy::MAX_AGE ) { $this->fail( $id, JobError::TIMEOUT ); return; } $token = (string) $row['claim_token']; if ( ! $this->requests->extend_lease( $id, $token, $this->time( $now + self::LEASE_SECONDS ) ) ) { return; } $provider = $this->provider( $row ); if ( null === $provider ) { $this->fail( $id, JobError::NO_PROVIDER ); return; } try { $submission = $provider->poll( (string) $row['remote_id'] ); } catch ( \Throwable $e ) { $this->fail( $id, JobError::INTERNAL ); return; } $this->settle( $id, $token, $submission ); } /** * Recovers requests whose worker died, using LeasePolicy. * * @param int $limit Requests to look at. */ public function reap( int $limit = self::REAP_BATCH ): void { $now = $this->now(); foreach ( $this->requests->unfinished( $limit ) as $row ) { $id = (string) $row['id']; switch ( LeasePolicy::decide( $row, $now ) ) { case LeasePolicy::REQUEUE: if ( $this->requests->requeue( $id ) ) { $this->scheduler->run_now( $id ); } break; case LeasePolicy::REPOLL: if ( $this->requests->extend_lease( $id, (string) $row['claim_token'], $this->time( $now + self::LEASE_SECONDS ) ) ) { $this->scheduler->poll_later( $id, 0 ); } break; case LeasePolicy::FAIL_TIMEOUT: $this->fail( $id, JobError::TIMEOUT ); break; case LeasePolicy::FAIL_NOT_STARTED: $this->fail( $id, JobError::NOT_STARTED ); break; } } } /** * Acts on what the provider answered. * * @param string $id Request UUID. * @param string $token Claim token. * @param Submission $submission Provider answer. */ private function settle( string $id, string $token, Submission $submission ): void { if ( $submission->is_pending() ) { $lease = $this->time( $this->now() + self::LEASE_SECONDS ); if ( $this->requests->set_pending( $id, $token, (string) $submission->remote_id(), $lease ) ) { $this->scheduler->poll_later( $id, self::POLL_INTERVAL ); } return; } if ( ! $submission->is_done() ) { $this->fail( $id, $submission->error()->code() ); return; } try { $path = $this->files->put( FileStore::RESULTS, (string) $submission->image() ); } catch ( \Throwable $e ) { $this->requests->record_cost( $id, $submission->model(), $submission->cost() ); $this->fail( $id, JobError::INTERNAL ); return; } $completed = $this->requests->complete( $id, $token, $path, $submission->model(), $submission->cost(), $this->time( $this->now() ) ); if ( ! $completed ) { // Cancelled or reaped meanwhile: nobody will ever be shown this file, but the provider still charged. $this->files->delete( $path ); $this->requests->record_cost( $id, $submission->model(), $submission->cost() ); return; } $this->emit( 'completed', $id, '' ); } /** * Fails a request and reports it when the change applied. * * @param string $id Request UUID. * @param string $code Error code. */ private function fail( string $id, string $code ): void { if ( $this->requests->fail( $id, $code, $this->time( $this->now() ) ) ) { $this->emit( 'failed', $id, $code ); } } /** * Provider for a row, or null. * * @param array $row Request row. */ private function provider( array $row ): ?Provider { $provider = ( $this->providers )( (string) ( $row['provider'] ?? '' ) ); return $provider instanceof Provider ? $provider : null; } /** * Reports an event. * * @param string $event 'completed' or 'failed'. * @param string $id Request UUID. * @param string $code Error code, empty when completed. */ private function emit( string $event, string $id, string $code ): void { if ( null !== $this->notify ) { ( $this->notify )( $event, $id, $code ); } } /** * Current Unix time. */ private function now(): int { return (int) ( $this->clock )(); } /** * UTC MySQL datetime for a Unix time. * * @param int $timestamp Unix time. */ private function time( int $timestamp ): string { return gmdate( 'Y-m-d H:i:s', $timestamp ); } }