import { supabase } from './client'; import { logger } from '../utils/logger'; export interface TurnitinJob { id: string; user_id: string; identity_id: string | null; assignment_target_id: string; mode: string; status: string; input_file_path: string; input_file_name: string; input_file_size: number | null; input_file_sha256: string | null; submission_request_id: string | null; output_pdf_path: string | null; output_pdf_expires_at: string | null; receipt_pdf_path: string | null; receipt_pdf_expires_at: string | null; ticket_refunded_at: string | null; ticket_refund_reason: string | null; viewer_url: string | null; similarity_percent: number | null; last_completed_step: string | null; submission_details: Record | null; filters: Record; attempt_count: number; max_attempts: number; next_retry_at: string | null; error_message: string | null; worker_id: string | null; started_at: string | null; finished_at: string | null; created_at: string; updated_at: string; } /** * Atomically claim a pending job using the database RPC. * Returns the claimed job row, or null if no jobs are available. */ export async function claimPendingJob(workerId: string): Promise { const { data, error } = await supabase.rpc('claim_turnitin_job', { p_worker_id: workerId, }); if (error) { logger.error('Failed to claim pending job', { error: error.message }); throw error; } if (!data || (Array.isArray(data) && data.length === 0)) { return null; } return Array.isArray(data) ? data[0] : data; } /** * Update a job's status and optionally merge additional column values. */ export async function updateJobStatus( jobId: string, status: string, extra?: Partial, ): Promise { const update: Record = { status, updated_at: new Date().toISOString(), ...extra, }; const { error } = await supabase .from('turnitin_jobs') .update(update) .eq('id', jobId); if (error) { logger.error('Failed to update job status', { jobId, status, error: error.message }); throw error; } } /** * Mark a job completed without reviving a job that an administrator or user * already moved to a terminal failed/cancelled state while Playwright was * finishing in the background. */ export async function completeJobIfActive( jobId: string, fields: Partial, ): Promise { const update: Record = { ...fields, status: 'completed', updated_at: new Date().toISOString(), }; const { data, error } = await supabase .from('turnitin_jobs') .update(update) .eq('id', jobId) .not('status', 'in', '(failed,cancelled)') .select('id') .maybeSingle(); if (error) { logger.error('Failed to complete active job', { jobId, error: error.message }); throw error; } return Boolean(data); } /** * Patch arbitrary job fields without changing status. * Used for incremental progress checkpoints so retries can resume safely. */ export async function updateJobFields( jobId: string, fields: Partial, ): Promise { if (Object.keys(fields).length === 0) return; const update: Record = { ...fields, updated_at: new Date().toISOString(), }; const { error } = await supabase .from('turnitin_jobs') .update(update) .eq('id', jobId); if (error) { logger.error('Failed to update job fields', { jobId, fields: Object.keys(fields), error: error.message, }); throw error; } } /** * Fetch a single job by its ID. */ export async function getJobById(jobId: string): Promise { const { data, error } = await supabase .from('turnitin_jobs') .select('*') .eq('id', jobId) .single(); if (error) { if (error.code === 'PGRST116') return null; // Row not found logger.error('Failed to get job by id', { jobId, error: error.message }); throw error; } return data as TurnitinJob; } /** * Find jobs whose report PDF has expired and should be cleaned up. */ export async function getExpiredReportJobs(): Promise { const now = new Date().toISOString(); const { data, error } = await supabase .from('turnitin_jobs') .select('*') .or( `and(output_pdf_path.not.is.null,output_pdf_expires_at.lte.${now}),` + `and(receipt_pdf_path.not.is.null,receipt_pdf_expires_at.lte.${now})`, ); if (error) { logger.error('Failed to get expired report jobs', { error: error.message }); throw error; } return (data as TurnitinJob[]) || []; } /** * Atomically increment the attempt_count for a job and return the new value. * * BUG-1 FIX: The previous Read-Modify-Write approach was a race condition — * two Space workers could both read attempt_count=0, compute 1, and both * write 1, meaning the limit was never properly enforced. * * Fix: Use optimistic locking. We UPDATE with a WHERE attempt_count = expected. * If the row was modified by another worker concurrently, the update matches * 0 rows and we re-read the current value (which already contains the * concurrent increment) and return that. */ export async function incrementJobAttempt(jobId: string): Promise { const job = await getJobById(jobId); if (!job) { throw new Error(`Job not found: ${jobId}`); } const expectedCount = (job.attempt_count as number) || 0; const newCount = expectedCount + 1; const { data, error } = await supabase .from('turnitin_jobs') .update({ attempt_count: newCount, updated_at: new Date().toISOString() }) .eq('id', jobId) .eq('attempt_count', expectedCount) // optimistic lock .select('attempt_count'); if (error) { logger.error('Failed to increment job attempt', { jobId, error: error.message }); throw error; } // If no row was updated another worker already incremented; re-read true value. if (!data || data.length === 0) { logger.warn('incrementJobAttempt: concurrent update detected; re-reading actual count', { jobId }); const fresh = await getJobById(jobId); return (fresh?.attempt_count as number) || newCount; } return (data[0] as { attempt_count: number }).attempt_count; } /** * Find jobs that are stuck in running-like states for longer than the given threshold. */ export async function getStaleJobs(staleSinceMinutes: number): Promise { const cutoff = new Date(Date.now() - staleSinceMinutes * 60 * 1000).toISOString(); // In our scheme, jobs in running states have started_at or updated_at, we can check updated_at const { data, error } = await supabase .from('turnitin_jobs') .select('*') .in('status', [ 'claiming_account', 'waiting_account', 'running', 'uploading', 'submitted', 'waiting_similarity', 'opening_viewer', 'applying_filters', 'downloading', ]) .lt('updated_at', cutoff); if (error) { logger.error('Failed to get stale jobs', { error: error.message }); throw error; } return (data as TurnitinJob[]) || []; }