*/ public const TRANSPORT_FAILURE_REASONS = [ 124 => 'The import timed out.', 255 => 'The connection to the server was lost.', ]; public const CLAIM_TTL_SECONDS = 7 * 24 * 3600; /** * The only keys that are stored on the activity and sent to the cleanup listener. */ private const KEYS = [ 'container', 'containerTmpPath', 'scriptPath', 'serverId', 'operationUuid', 'containerName', 'serverTmpPath', 'credentialTmpPath', ]; /** * @param array $data * @return array */ public static function storable(array $data): array { return array_filter( Arr::only($data, self::KEYS), fn ($value): bool => is_string($value) || is_int($value), ); } /** * The cleanup data stored on an import activity, or null if the activity has no usable data. * * @return array|null */ public static function stored(Activity $activity): ?array { $data = data_get($activity, 'properties.'.self::PROPERTY); if (! is_array($data)) { return null; } $data = self::storable($data); if (! is_int($data['serverId'] ?? null) || ! Str::isUuid($data['operationUuid'] ?? null)) { return null; } return $data; } public static function stopRequested(Activity $activity): bool { return filled(data_get($activity, 'properties.'.self::STOP_REQUESTED_PROPERTY)); } /** * Queue the stop of the remote restore and the cleanup. It does not run SSH commands * inline, so it is safe during boot. Errors are logged and do not stop the caller. * * @param array $data */ public static function queueStop(array $data): void { try { event(new DatabaseImportFinished([...$data, 'stopRestore' => true])); } catch (Throwable $exception) { Log::warning('Could not queue the stop of an interrupted database import', [ 'operationUuid' => $data['operationUuid'] ?? null, 'serverId' => $data['serverId'] ?? null, 'error' => $exception->getMessage(), ]); } } /** * Stop a database import whose CoolifyTask failed (for example it timed out) before the import * reported its end, because the restore can still run in the database container. * * The activity stays in progress, so the import still blocks a start, restart, import or backup * of the database. The cleanup listener marks it as failed after the stop has run. * * @return bool True when the import is stopped this way; the caller must not mark the activity as failed. */ public static function stopAfterTaskFailure(Activity $activity, string $reason): bool { if (data_get($activity, 'properties.operation') !== ResourceStartActivity::DATABASE_IMPORT_OPERATION) { return false; } $current = Activity::query()->find($activity->getKey()); $status = data_get($current, 'properties.status'); if (! $current || ! in_array($status, [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value], true)) { return false; } if (self::stopRequested($current)) { return true; } $cleanup = self::stored($current); if ($cleanup === null) { return false; } // Save the stop flag before the stop is queued, so the listener and a retried CoolifyTask see it. $current->properties = $current->properties->merge([ self::STOP_REQUESTED_PROPERTY => now()->toIso8601String(), self::STOP_REASON_PROPERTY => $reason, ]); $current->save(); $activity->properties = $current->properties; self::queueStop($cleanup); return true; } /** * Stop a database import whose SSH command ended without the result of the restore (the * connection was lost or the local timeout ended it). The restore runs with `docker exec` * and does not stop with the SSH session, so it is handled like a failed task. * * @return bool True when the import is stopped this way; the caller must keep it in progress. */ public static function stopAfterTransportFailure(Activity $activity, ?int $exitCode): bool { $reason = self::TRANSPORT_FAILURE_REASONS[$exitCode] ?? null; return $reason !== null && self::stopAfterTaskFailure($activity, $reason); } /** * Mark the imports of an operation as failed that wait for the stop of their failed task. * Called by the cleanup listener after the stop has run. */ public static function finishStop(string $operationUuid): void { Activity::query() ->where('properties->operation_uuid', $operationUuid) ->where('properties->operation', ResourceStartActivity::DATABASE_IMPORT_OPERATION) ->whereIn('properties->status', [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value]) ->get() ->filter(fn (Activity $activity): bool => filled(data_get($activity, 'properties.'.self::STOP_REASON_PROPERTY))) ->each(fn (Activity $activity) => ResourceStartActivity::markFailed( $activity, data_get($activity, 'properties.'.self::STOP_REASON_PROPERTY).' '.ResourceStartActivity::IMPORT_STOPPED_MESSAGE, )); } public static function claimKey(string $operationUuid): string { return "database-import-cleanup:{$operationUuid}"; } /** * Only the first caller for an operation gets true, so the cleanup runs at most once per import. */ public static function claim(string $operationUuid): bool { return Cache::add(self::claimKey($operationUuid), true, self::CLAIM_TTL_SECONDS); } /** * Release the claim after a failed attempt, so a retry of the cleanup can run. */ public static function release(string $operationUuid): void { Cache::forget(self::claimKey($operationUuid)); } }