feat(render+admin): stop a render job (admin, any owner)
Build backend images / build content-svc (push) Failing after 1m0s
Build backend images / build file-svc (push) Failing after 1m3s
Build backend images / build gateway (push) Failing after 1m2s
Build backend images / build identity-svc (push) Failing after 1m20s
Build backend images / build notification-svc (push) Failing after 1m13s
Build backend images / build render-svc (push) Failing after 1m5s
Build backend images / build studio-svc (push) Failing after 1m0s

The render-queue cancel button used the owner-scoped /cancel (WHERE user_id=…),
so an admin couldn't stop another user's job. Added:
- render-svc: POST /v1/renders/:job_id/stop (admin-gated) → store.StopJob cancels
  any in-progress job regardless of owner and frees the assigned node
- admin: render-queue button now "توقف" → /api/admin/renders/{id}/stop (with confirm)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-03 01:39:33 +03:30
parent c7694a9bbf
commit 7f7feabb85
7 changed files with 53 additions and 6 deletions
+2 -1
View File
@@ -415,7 +415,8 @@
"colCreated": "Created", "colCreated": "Created",
"colActions": "Actions", "colActions": "Actions",
"actionRetry": "Retry", "actionRetry": "Retry",
"actionCancel": "Cancel" "actionCancel": "Cancel",
"actionStop": "Stop"
}, },
"componentsAuthAuthPageContent": { "componentsAuthAuthPageContent": {
"genericError": "Something went wrong. Please try again.", "genericError": "Something went wrong. Please try again.",
+2 -1
View File
@@ -415,7 +415,8 @@
"colCreated": "زمان ایجاد", "colCreated": "زمان ایجاد",
"colActions": "عملیات", "colActions": "عملیات",
"actionRetry": "تلاش مجدد", "actionRetry": "تلاش مجدد",
"actionCancel": "لغو" "actionCancel": "لغو",
"actionStop": "توقف"
}, },
"componentsAuthAuthPageContent": { "componentsAuthAuthPageContent": {
"genericError": "خطایی رخ داد. لطفاً دوباره تلاش کنید.", "genericError": "خطایی رخ داد. لطفاً دوباره تلاش کنید.",
+1
View File
@@ -90,6 +90,7 @@ func main() {
renders.POST("", renderH.Create) renders.POST("", renderH.Create)
renders.GET("/:job_id", renderH.Get) renders.GET("/:job_id", renderH.Get)
renders.POST("/:job_id/cancel", renderH.Cancel) renders.POST("/:job_id/cancel", renderH.Cancel)
renders.POST("/:job_id/stop", admin, renderH.Stop)
renders.POST("/:job_id/retry", renderH.Retry) renders.POST("/:job_id/retry", renderH.Retry)
renders.GET("/:job_id/progress", renderH.Progress) renders.GET("/:job_id/progress", renderH.Progress)
renders.GET("/:job_id/logs", renderH.Logs) renders.GET("/:job_id/logs", renderH.Logs)
+18
View File
@@ -597,6 +597,24 @@ func (s *Store) CancelJob(ctx context.Context, id, userID uuid.UUID) (bool, erro
return tag.RowsAffected() > 0, err return tag.RowsAffected() > 0, err
} }
// StopJob cancels any in-progress job regardless of owner (admin action). Also
// frees the assigned node so it can pick up new work.
func (s *Store) StopJob(ctx context.Context, id uuid.UUID) (bool, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE render.render_jobs
SET step = 'Cancelled'::render_step, completed_at = NOW(), updated_at = NOW()
WHERE id = $1 AND step NOT IN ('Done','Failed','Cancelled')`,
id)
if err != nil {
return false, err
}
// Release any node that was actively working this job.
_, _ = s.pool.Exec(ctx,
`UPDATE render.render_nodes SET status = 'Ready'::node_status, current_frame_job_id = NULL, updated_at = NOW()
WHERE current_frame_job_id IN (SELECT id FROM render.frame_jobs WHERE render_job_id = $1)`, id)
return tag.RowsAffected() > 0, err
}
func (s *Store) GetJobProgress(ctx context.Context, id, userID uuid.UUID) (*models.RenderJob, error) { func (s *Store) GetJobProgress(ctx context.Context, id, userID uuid.UUID) (*models.RenderJob, error) {
return s.GetJobByID(ctx, id, userID) return s.GetJobByID(ctx, id, userID)
} }
@@ -131,6 +131,21 @@ func (h *RenderHandler) Cancel(c *gin.Context) {
}) })
} }
// POST /v1/renders/:job_id/stop — admin: stop any user's in-progress job
func (h *RenderHandler) Stop(c *gin.Context) {
jobID, err := uuid.Parse(c.Param("job_id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid job_id"})
return
}
stopped, err := h.store.StopJob(c.Request.Context(), jobID)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"stopped": stopped})
}
// POST /v1/renders/:job_id/retry // POST /v1/renders/:job_id/retry
func (h *RenderHandler) Retry(c *gin.Context) { func (h *RenderHandler) Retry(c *gin.Context) {
userID := middleware.GetUserID(c) userID := middleware.GetUserID(c)
@@ -0,0 +1,9 @@
import { type NextRequest } from "next/server";
import { adminProxy } from "@/app/api/admin/_adminProxy";
export const runtime = "nodejs";
interface Ctx { params: { jobId: string } }
export async function POST(req: NextRequest, { params }: Ctx) {
return adminProxy(req, `/v1/renders/${params.jobId}/stop`);
}
+6 -4
View File
@@ -48,10 +48,12 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
} }
}; };
const cancelJob = async (jobId: string) => { const stopJob = async (jobId: string) => {
if (!confirm("این رندر متوقف شود؟")) return;
setLoading((p) => ({ ...p, [jobId]: true })); setLoading((p) => ({ ...p, [jobId]: true }));
try { try {
await apiFetch(`/api/admin/renders/${jobId}/cancel`, { method: "POST" }); // Admin stop — cancels any user's job (not just the caller's) and frees the node.
await apiFetch(`/api/admin/renders/${jobId}/stop`, { method: "POST" });
router.refresh(); router.refresh();
} finally { } finally {
setLoading((p) => ({ ...p, [jobId]: false })); setLoading((p) => ({ ...p, [jobId]: false }));
@@ -140,11 +142,11 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
)} )}
{canCancel && ( {canCancel && (
<button <button
onClick={() => cancelJob(job.id)} onClick={() => stopJob(job.id)}
disabled={loading[job.id]} disabled={loading[job.id]}
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
> >
{t("actionCancel")} {t("actionStop")}
</button> </button>
)} )}
</div> </div>