Compare commits
2 Commits
feature/cl
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e5fb3221a | |||
| 34aa17a74a |
@@ -4,6 +4,7 @@ SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
JOB_SECRET=
|
||||
CRON_SECRET=
|
||||
|
||||
ENCRYPTION_KEY=
|
||||
PLESK_CRED_ENC_KEY=
|
||||
|
||||
37
README.md
37
README.md
@@ -63,7 +63,9 @@ values ('<agency_uuid>', '<auth_user_uuid>', 'owner');
|
||||
|
||||
- `POST /api/plesk/instances` — create + validate Plesk connection
|
||||
- `POST /api/plesk/instances/:id/sync` — pull/sync subscriptions + domains from one Plesk instance
|
||||
- `POST /api/plesk/instances/:id/autosync` — update auto-sync enabled/frequency for one Plesk instance
|
||||
- `POST /api/jobs/plesk-sync-all` — background sync job for all connected instances (requires `X-JOB-SECRET`)
|
||||
- `POST /api/cron/autosync` — scheduled auto-sync tick (requires `X-CRON-SECRET`)
|
||||
- `POST /api/plesk/subscriptions/:id/action` — suspend or unsuspend
|
||||
- `POST /api/stripe/checkout` — create Stripe Checkout session
|
||||
- `POST /api/stripe/portal` — create Stripe Customer Portal session
|
||||
@@ -74,6 +76,7 @@ values ('<agency_uuid>', '<auth_user_uuid>', 'owner');
|
||||
- Set a strong `ENCRYPTION_KEY`; it encrypts stored Plesk auth secrets.
|
||||
- Set a strong `PLESK_CRED_ENC_KEY` (32-byte base64 or 64-char hex) for Plesk credential encryption.
|
||||
- Set `JOB_SECRET` for internal cron-triggered job auth.
|
||||
- Set `CRON_SECRET` for `/api/cron/autosync` auth.
|
||||
- `/api/stripe/webhook` is excluded from auth middleware for Stripe signature verification.
|
||||
- Current implementation is intentionally MVP-focused; add stronger validation, retries, idempotency keys, and richer error observability for production.
|
||||
|
||||
@@ -97,3 +100,37 @@ Notes:
|
||||
- Job uses DB lock (`job_locks`) to avoid overlapping runs.
|
||||
- Job processes at most 10 connected instances per run.
|
||||
- Instances synced in the last 3 minutes are skipped.
|
||||
|
||||
## 6) Auto Sync Worker (CL6)
|
||||
|
||||
New scheduler fields live on `plesk_instances`:
|
||||
|
||||
- `auto_sync_enabled`
|
||||
- `auto_sync_frequency_minutes`
|
||||
- `last_auto_sync_at`
|
||||
- `next_auto_sync_at`
|
||||
- `auto_sync_lock_until`
|
||||
- `auto_sync_lock_token`
|
||||
|
||||
Endpoint for periodic worker ticks:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://localhost:3000/api/cron/autosync \
|
||||
-H "X-CRON-SECRET: <CRON_SECRET>"
|
||||
```
|
||||
|
||||
Recommended system cron (every 5 minutes):
|
||||
|
||||
```cron
|
||||
*/5 * * * * curl -sS -X POST https://<your-host>/api/cron/autosync -H "X-CRON-SECRET: ${CRON_SECRET}"
|
||||
```
|
||||
|
||||
Or schedule the same call from Jenkins.
|
||||
|
||||
Worker safety behavior:
|
||||
|
||||
- processes at most 5 instances per tick
|
||||
- concurrency limited to 2 at a time
|
||||
- per-instance lock lease 15 minutes
|
||||
- per-instance timeout guard 14 minutes
|
||||
- per-instance failures are isolated and logged to `actions_log`
|
||||
|
||||
38
app/api/cron/autosync/route.ts
Normal file
38
app/api/cron/autosync/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { env } from "@/lib/env";
|
||||
import { runAutoSyncTick } from "@/lib/plesk/auto-sync";
|
||||
import { createSupabaseAdminClient } from "@/lib/supabase/admin";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const secret = req.headers.get("x-cron-secret");
|
||||
if (!env.cronSecret || !secret || secret !== env.cronSecret) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabase = createSupabaseAdminClient() as any;
|
||||
const summary = await runAutoSyncTick(supabase);
|
||||
|
||||
return NextResponse.json({
|
||||
...summary,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[autosync cron] failed", error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Auto-sync job failed",
|
||||
instances_considered: 0,
|
||||
locks_acquired: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
87
app/api/plesk/instances/[id]/autosync/route.ts
Normal file
87
app/api/plesk/instances/[id]/autosync/route.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { assertAgencyAdmin, getRouteAgencyContextOrThrow } from "@/lib/agency";
|
||||
import { assertRateLimit } from "@/lib/api/rate-limit";
|
||||
import { assertSameOrigin } from "@/lib/api/security";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/route";
|
||||
|
||||
const autosyncSchema = z.object({
|
||||
auto_sync_enabled: z.boolean(),
|
||||
auto_sync_frequency_minutes: z.number().int().min(5).max(10080).optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: { id: string } },
|
||||
) {
|
||||
try {
|
||||
assertSameOrigin(req);
|
||||
|
||||
const context = await getRouteAgencyContextOrThrow();
|
||||
assertAgencyAdmin(context);
|
||||
assertRateLimit(`plesk-autosync-settings:${context.userId}`, 20, 60_000);
|
||||
|
||||
const input = autosyncSchema.parse(await req.json());
|
||||
const frequency = input.auto_sync_frequency_minutes ?? 60;
|
||||
const nowIso = new Date().toISOString();
|
||||
const nextAtIso = new Date(
|
||||
Date.now() + frequency * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
const supabase = createSupabaseRouteClient() as any;
|
||||
|
||||
const updates = {
|
||||
auto_sync_enabled: input.auto_sync_enabled,
|
||||
auto_sync_frequency_minutes: frequency,
|
||||
next_auto_sync_at: input.auto_sync_enabled ? nextAtIso : null,
|
||||
auto_sync_lock_until: null,
|
||||
auto_sync_lock_token: null,
|
||||
last_auto_sync_at: input.auto_sync_enabled ? undefined : null,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
const { data: instance, error } = await supabase
|
||||
.from("plesk_instances")
|
||||
.update(updates)
|
||||
.eq("id", params.id)
|
||||
.eq("agency_id", context.agencyId)
|
||||
.select(
|
||||
"id, auto_sync_enabled, auto_sync_frequency_minutes, last_auto_sync_at, next_auto_sync_at, auto_sync_lock_until",
|
||||
)
|
||||
.single();
|
||||
|
||||
if (error || !instance) {
|
||||
throw new Error(error?.message ?? "Plesk instance not found");
|
||||
}
|
||||
|
||||
try {
|
||||
await supabase.from("actions_log").insert({
|
||||
agency_id: context.agencyId,
|
||||
actor_user_id: context.userId,
|
||||
target_type: "plesk_instance",
|
||||
target_id: params.id,
|
||||
action: "autosync_settings_updated",
|
||||
status: "success",
|
||||
metadata: {
|
||||
auto_sync_enabled: input.auto_sync_enabled,
|
||||
auto_sync_frequency_minutes: frequency,
|
||||
updated_at: nowIso,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// best effort logging
|
||||
}
|
||||
|
||||
return NextResponse.json({ instance });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to update auto-sync settings",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export default async function DashboardPage() {
|
||||
supabase
|
||||
.from("plesk_instances")
|
||||
.select(
|
||||
"id, name, base_url, auth_type, status, error_message, last_connected_at, last_sync_at, last_sync_status, last_sync_error, last_sync_subscriptions, last_sync_domains",
|
||||
"id, name, base_url, auth_type, status, error_message, last_connected_at, last_sync_at, last_sync_status, last_sync_error, last_sync_subscriptions, last_sync_domains, auto_sync_enabled, auto_sync_frequency_minutes, last_auto_sync_at, next_auto_sync_at, auto_sync_lock_until",
|
||||
)
|
||||
.eq("agency_id", context.agencyId)
|
||||
.order("created_at", { ascending: false }),
|
||||
@@ -104,6 +104,11 @@ export default async function DashboardPage() {
|
||||
last_sync_error: string | null;
|
||||
last_sync_subscriptions: number;
|
||||
last_sync_domains: number;
|
||||
auto_sync_enabled: boolean;
|
||||
auto_sync_frequency_minutes: number;
|
||||
last_auto_sync_at: string | null;
|
||||
next_auto_sync_at: string | null;
|
||||
auto_sync_lock_until: string | null;
|
||||
}> | null;
|
||||
error: { message: string } | null;
|
||||
},
|
||||
|
||||
@@ -18,6 +18,11 @@ type Instance = {
|
||||
last_sync_error: string | null;
|
||||
last_sync_subscriptions: number;
|
||||
last_sync_domains: number;
|
||||
auto_sync_enabled: boolean;
|
||||
auto_sync_frequency_minutes: number;
|
||||
last_auto_sync_at: string | null;
|
||||
next_auto_sync_at: string | null;
|
||||
auto_sync_lock_until: string | null;
|
||||
};
|
||||
|
||||
type Subscription = {
|
||||
@@ -116,6 +121,7 @@ export function DashboardControls({
|
||||
domains,
|
||||
autoSyncByInstance,
|
||||
}: Props) {
|
||||
const autoSyncFrequencyOptions = [15, 30, 60, 180, 360, 1440];
|
||||
const [instanceName, setInstanceName] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [authType, setAuthType] = useState<"api_key" | "basic">("api_key");
|
||||
@@ -239,6 +245,25 @@ export function DashboardControls({
|
||||
}
|
||||
}
|
||||
|
||||
async function onUpdateAutoSync(
|
||||
instanceId: string,
|
||||
input: { auto_sync_enabled: boolean; auto_sync_frequency_minutes: number },
|
||||
) {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await runRequest(`/api/plesk/instances/${instanceId}/autosync`, input);
|
||||
setMessage("Auto-sync settings updated.");
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
setMessage(
|
||||
error instanceof Error ? error.message : "Auto-sync update failed",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubscriptionAction() {
|
||||
if (!actionSubId) return;
|
||||
setLoading(true);
|
||||
@@ -470,6 +495,30 @@ export function DashboardControls({
|
||||
? formatDateTime(instance.last_connected_at)
|
||||
: "never"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Auto-sync:{" "}
|
||||
{instance.auto_sync_enabled ? "Enabled" : "Disabled"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Auto-sync frequency:{" "}
|
||||
{instance.auto_sync_frequency_minutes ?? 60} min
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Last auto-sync:{" "}
|
||||
{formatDateTime(instance.last_auto_sync_at)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Next auto-sync:{" "}
|
||||
{formatDateTime(instance.next_auto_sync_at)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
Lock status:{" "}
|
||||
{instance.auto_sync_lock_until &&
|
||||
new Date(instance.auto_sync_lock_until).getTime() >
|
||||
Date.now()
|
||||
? "Sync in progress"
|
||||
: "Idle"}
|
||||
</p>
|
||||
{autoSyncByInstance[instance.id] ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
Last auto-sync:{" "}
|
||||
@@ -496,6 +545,42 @@ export function DashboardControls({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-slate-600">
|
||||
Auto-sync
|
||||
</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={instance.auto_sync_enabled}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
onUpdateAutoSync(instance.id, {
|
||||
auto_sync_enabled: e.target.checked,
|
||||
auto_sync_frequency_minutes:
|
||||
instance.auto_sync_frequency_minutes ?? 60,
|
||||
})
|
||||
}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={String(
|
||||
instance.auto_sync_frequency_minutes ?? 60,
|
||||
)}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
onUpdateAutoSync(instance.id, {
|
||||
auto_sync_enabled: instance.auto_sync_enabled,
|
||||
auto_sync_frequency_minutes: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
disabled={loading}
|
||||
className="rounded border px-2 py-1 text-xs"
|
||||
>
|
||||
{autoSyncFrequencyOptions.map((minutes) => (
|
||||
<option key={minutes} value={minutes}>
|
||||
Every {minutes} min
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => onTestConnection(instance.id)}
|
||||
disabled={loading}
|
||||
|
||||
@@ -14,6 +14,7 @@ for (const key of requiredEnvs) {
|
||||
export const env = {
|
||||
appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
|
||||
jobSecret: process.env.JOB_SECRET ?? "",
|
||||
cronSecret: process.env.CRON_SECRET ?? "",
|
||||
encryptionKey: process.env.ENCRYPTION_KEY ?? "",
|
||||
pleskCredEncKey: process.env.PLESK_CRED_ENC_KEY ?? "",
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",
|
||||
|
||||
232
lib/plesk/auto-sync.ts
Normal file
232
lib/plesk/auto-sync.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import { syncPleskInstance } from "@/lib/plesk/sync";
|
||||
|
||||
type SupabaseLike = any;
|
||||
|
||||
type AutoSyncInstance = {
|
||||
id: string;
|
||||
agency_id: string;
|
||||
base_url: string;
|
||||
auth_type: "api_key" | "basic";
|
||||
encrypted_secret: string;
|
||||
last_connected_at: string | null;
|
||||
auto_sync_enabled: boolean;
|
||||
auto_sync_frequency_minutes: number | null;
|
||||
next_auto_sync_at: string | null;
|
||||
auto_sync_lock_until: string | null;
|
||||
};
|
||||
|
||||
type AutoSyncSummary = {
|
||||
instances_considered: number;
|
||||
locks_acquired: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
duration_ms: number;
|
||||
};
|
||||
|
||||
const MAX_INSTANCES_PER_TICK = 5;
|
||||
const MAX_CONCURRENCY = 2;
|
||||
const LOCK_LEASE_SECONDS = 15 * 60;
|
||||
const INSTANCE_TIMEOUT_MS = 14 * 60 * 1000;
|
||||
|
||||
function isDue(instance: AutoSyncInstance, now: number) {
|
||||
if (!instance.next_auto_sync_at) return true;
|
||||
const dueAt = new Date(instance.next_auto_sync_at).getTime();
|
||||
return Number.isFinite(dueAt) && dueAt <= now;
|
||||
}
|
||||
|
||||
function isUnlocked(instance: AutoSyncInstance, now: number) {
|
||||
if (!instance.auto_sync_lock_until) return true;
|
||||
const lockUntil = new Date(instance.auto_sync_lock_until).getTime();
|
||||
return !Number.isFinite(lockUntil) || lockUntil <= now;
|
||||
}
|
||||
|
||||
async function insertActionLogBestEffort(
|
||||
supabase: SupabaseLike,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
try {
|
||||
const { error } = await supabase.from("actions_log").insert(payload);
|
||||
if (error) {
|
||||
console.error("[autosync] actions_log insert failed", error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[autosync] actions_log insert threw", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
reject(new Error("Auto-sync timed out"));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAutoSyncTick(
|
||||
supabase: SupabaseLike,
|
||||
): Promise<AutoSyncSummary> {
|
||||
const startedAt = Date.now();
|
||||
const now = Date.now();
|
||||
|
||||
const { data: rows, error } = await supabase
|
||||
.from("plesk_instances")
|
||||
.select(
|
||||
"id, agency_id, base_url, auth_type, encrypted_secret, last_connected_at, auto_sync_enabled, auto_sync_frequency_minutes, next_auto_sync_at, auto_sync_lock_until",
|
||||
)
|
||||
.eq("status", "connected")
|
||||
.eq("auto_sync_enabled", true)
|
||||
.order("next_auto_sync_at", { ascending: true, nullsFirst: true })
|
||||
.limit(50);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
const eligible = ((rows ?? []) as AutoSyncInstance[])
|
||||
.filter((instance) => isDue(instance, now) && isUnlocked(instance, now))
|
||||
.slice(0, MAX_INSTANCES_PER_TICK);
|
||||
|
||||
let locksAcquired = 0;
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
const queue = [...eligible];
|
||||
|
||||
async function processInstance(instance: AutoSyncInstance) {
|
||||
const lockToken = randomUUID();
|
||||
const { data: lockAcquired, error: lockError } = await supabase.rpc(
|
||||
"acquire_instance_auto_sync_lock",
|
||||
{
|
||||
p_instance_id: instance.id,
|
||||
p_lock_token: lockToken,
|
||||
p_ttl_seconds: LOCK_LEASE_SECONDS,
|
||||
},
|
||||
);
|
||||
|
||||
if (lockError || !lockAcquired) return;
|
||||
locksAcquired += 1;
|
||||
|
||||
const perInstanceStartedAt = Date.now();
|
||||
const frequencyMinutes = Math.max(
|
||||
instance.auto_sync_frequency_minutes ?? 60,
|
||||
5,
|
||||
);
|
||||
|
||||
await insertActionLogBestEffort(supabase, {
|
||||
agency_id: instance.agency_id,
|
||||
actor_user_id: null,
|
||||
target_type: "plesk_instance",
|
||||
target_id: instance.id,
|
||||
action: "autosync_started",
|
||||
status: "pending",
|
||||
metadata: {
|
||||
instance_id: instance.id,
|
||||
lock_token: lockToken,
|
||||
frequency_minutes: frequencyMinutes,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await withTimeout(
|
||||
syncPleskInstance(supabase, instance, null, "autosync"),
|
||||
INSTANCE_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
succeeded += 1;
|
||||
|
||||
await insertActionLogBestEffort(supabase, {
|
||||
agency_id: instance.agency_id,
|
||||
actor_user_id: null,
|
||||
target_type: "plesk_instance",
|
||||
target_id: instance.id,
|
||||
action: "autosync_completed",
|
||||
status: "success",
|
||||
metadata: {
|
||||
instance_id: instance.id,
|
||||
lock_token: lockToken,
|
||||
duration_ms: Date.now() - perInstanceStartedAt,
|
||||
},
|
||||
});
|
||||
} catch (syncError) {
|
||||
failed += 1;
|
||||
|
||||
const errorMessage =
|
||||
syncError instanceof Error
|
||||
? syncError.message.slice(0, 500)
|
||||
: "autosync_failed";
|
||||
|
||||
await insertActionLogBestEffort(supabase, {
|
||||
agency_id: instance.agency_id,
|
||||
actor_user_id: null,
|
||||
target_type: "plesk_instance",
|
||||
target_id: instance.id,
|
||||
action: "autosync_failed",
|
||||
status: "failed",
|
||||
error_message: errorMessage,
|
||||
metadata: {
|
||||
instance_id: instance.id,
|
||||
lock_token: lockToken,
|
||||
duration_ms: Date.now() - perInstanceStartedAt,
|
||||
error_summary: errorMessage,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
const nowIso = new Date().toISOString();
|
||||
const nextAutoSyncAtIso = new Date(
|
||||
Date.now() + frequencyMinutes * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
const { error: scheduleError } = await supabase
|
||||
.from("plesk_instances")
|
||||
.update({
|
||||
last_auto_sync_at: nowIso,
|
||||
next_auto_sync_at: nextAutoSyncAtIso,
|
||||
auto_sync_lock_until: null,
|
||||
auto_sync_lock_token: null,
|
||||
})
|
||||
.eq("id", instance.id)
|
||||
.eq("auto_sync_lock_token", lockToken);
|
||||
|
||||
if (scheduleError) {
|
||||
console.error(
|
||||
"[autosync] schedule update failed",
|
||||
scheduleError.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(MAX_CONCURRENCY, queue.length) },
|
||||
async () => {
|
||||
while (queue.length > 0) {
|
||||
const instance = queue.shift();
|
||||
if (!instance) break;
|
||||
await processInstance(instance);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.all(workers);
|
||||
|
||||
return {
|
||||
instances_considered: eligible.length,
|
||||
locks_acquired: locksAcquired,
|
||||
succeeded,
|
||||
failed,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
18
lib/types.ts
18
lib/types.ts
@@ -83,6 +83,12 @@ export type Database = {
|
||||
last_sync_error: string | null;
|
||||
last_sync_subscriptions: number;
|
||||
last_sync_domains: number;
|
||||
auto_sync_enabled: boolean;
|
||||
auto_sync_frequency_minutes: number;
|
||||
last_auto_sync_at: string | null;
|
||||
next_auto_sync_at: string | null;
|
||||
auto_sync_lock_until: string | null;
|
||||
auto_sync_lock_token: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
@@ -101,6 +107,12 @@ export type Database = {
|
||||
last_sync_error?: string | null;
|
||||
last_sync_subscriptions?: number;
|
||||
last_sync_domains?: number;
|
||||
auto_sync_enabled?: boolean;
|
||||
auto_sync_frequency_minutes?: number;
|
||||
last_auto_sync_at?: string | null;
|
||||
next_auto_sync_at?: string | null;
|
||||
auto_sync_lock_until?: string | null;
|
||||
auto_sync_lock_token?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
@@ -119,6 +131,12 @@ export type Database = {
|
||||
last_sync_error?: string | null;
|
||||
last_sync_subscriptions?: number;
|
||||
last_sync_domains?: number;
|
||||
auto_sync_enabled?: boolean;
|
||||
auto_sync_frequency_minutes?: number;
|
||||
last_auto_sync_at?: string | null;
|
||||
next_auto_sync_at?: string | null;
|
||||
auto_sync_lock_until?: string | null;
|
||||
auto_sync_lock_token?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
};
|
||||
|
||||
62
supabase/migrations/CL6_auto_sync_worker.sql
Normal file
62
supabase/migrations/CL6_auto_sync_worker.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- CL6: auto-sync worker scheduling fields + per-instance lock helpers
|
||||
|
||||
alter table public.plesk_instances
|
||||
add column if not exists auto_sync_enabled boolean not null default false,
|
||||
add column if not exists auto_sync_frequency_minutes integer not null default 60,
|
||||
add column if not exists last_auto_sync_at timestamptz,
|
||||
add column if not exists next_auto_sync_at timestamptz,
|
||||
add column if not exists auto_sync_lock_until timestamptz,
|
||||
add column if not exists auto_sync_lock_token text;
|
||||
|
||||
alter table public.plesk_instances
|
||||
drop constraint if exists plesk_instances_auto_sync_frequency_minutes_check;
|
||||
|
||||
alter table public.plesk_instances
|
||||
add constraint plesk_instances_auto_sync_frequency_minutes_check
|
||||
check (auto_sync_frequency_minutes between 5 and 10080);
|
||||
|
||||
create index if not exists idx_plesk_instances_auto_sync_due
|
||||
on public.plesk_instances (auto_sync_enabled, next_auto_sync_at);
|
||||
|
||||
create or replace function public.acquire_instance_auto_sync_lock(
|
||||
p_instance_id uuid,
|
||||
p_lock_token text,
|
||||
p_ttl_seconds integer default 900
|
||||
)
|
||||
returns boolean
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_acquired boolean := false;
|
||||
begin
|
||||
update public.plesk_instances
|
||||
set
|
||||
auto_sync_lock_until = now() + make_interval(secs => p_ttl_seconds),
|
||||
auto_sync_lock_token = p_lock_token
|
||||
where id = p_instance_id
|
||||
and auto_sync_enabled = true
|
||||
and (next_auto_sync_at is null or next_auto_sync_at <= now())
|
||||
and (auto_sync_lock_until is null or auto_sync_lock_until < now());
|
||||
|
||||
get diagnostics v_acquired = row_count;
|
||||
return v_acquired;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.release_instance_auto_sync_lock(
|
||||
p_instance_id uuid,
|
||||
p_lock_token text
|
||||
)
|
||||
returns void
|
||||
language sql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
update public.plesk_instances
|
||||
set auto_sync_lock_until = null,
|
||||
auto_sync_lock_token = null
|
||||
where id = p_instance_id
|
||||
and auto_sync_lock_token = p_lock_token;
|
||||
$$;
|
||||
Reference in New Issue
Block a user