mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 01:54:37 +08:00
An import task can now run until the SSH command timeout (capped below the Horizon worker timeout). When the CoolifyTask fails, times out, or its SSH command ends with 124/255, Coolify stops the restore in the database container and keeps the import in progress until the stop has run, so the database stays blocked. The restore reports its own 124/255 as 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
7.7 KiB
PHP
218 lines
7.7 KiB
PHP
<?php
|
|
|
|
namespace App\Support\DatabaseImport;
|
|
|
|
use App\Enums\ProcessStatus;
|
|
use App\Events\DatabaseImportFinished;
|
|
use App\Support\ResourceStartActivity;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
use Spatie\Activitylog\Models\Activity;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Cleanup of a database import: helper containers and temporary files on the server
|
|
* and in the database container.
|
|
*
|
|
* The normal path sends the cleanup data through the encrypted CoolifyTask payload.
|
|
* The import also stores the same data on its activity, so Coolify can stop and clean up
|
|
* an import that it marks as failed after a restart or because it is stale.
|
|
* The data has only ids, container names and paths. It must never have credentials.
|
|
*/
|
|
class DatabaseImportCleanup
|
|
{
|
|
public const PROPERTY = 'cleanup';
|
|
|
|
/**
|
|
* Set on the activity when Coolify stopped the import. A retried CoolifyTask does not run it again.
|
|
*/
|
|
public const STOP_REQUESTED_PROPERTY = 'stop_requested_at';
|
|
|
|
/**
|
|
* Why a failed import task was stopped. The activity gets this error when the stop has run.
|
|
*/
|
|
public const STOP_REASON_PROPERTY = 'stop_reason';
|
|
|
|
/**
|
|
* Exit codes of the local SSH command that do not come from the restore: ssh exits with 255
|
|
* when the connection fails and the local `timeout` wrapper exits with 124. The restore
|
|
* command in the database container reports its own 124 and 255 as 1.
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
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<string, mixed> $data
|
|
* @return array<string, int|string>
|
|
*/
|
|
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<string, int|string>|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<string, int|string> $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));
|
|
}
|
|
}
|