diff --git a/services/completion_requirements.service.js b/services/completion_requirements.service.js index 3192085..b28bfb5 100644 --- a/services/completion_requirements.service.js +++ b/services/completion_requirements.service.js @@ -32,6 +32,7 @@ 'use strict'; const sequelize = require('../config/db.config'); +const { withTransactionRetry } = require('../utils/withTransactionRetry.util'); const { evaluateEntity } = require('../utils/courses/completion_requirements.registry'); const CompletionRequirement = require('../models/courses/completion_requirement.mdl'); const CompletionRequirementProgress = require('../models/courses/completion_requirement_progress.mdl'); @@ -298,8 +299,7 @@ async function recordWatchProgress(userId, { : null; const durationSeconds = blockId ? getBlockDuration(page, blockId) : 0; - const t = await sequelize.transaction(); - try { + const { anyCompleted, aggregatePercent, cascade } = await withTransactionRetry(sequelize, async (t) => { let anyCompleted = false; let aggregatePercent = null; const now = new Date(); @@ -382,19 +382,16 @@ async function recordWatchProgress(userId, { lessonStatus: anyCompleted ? 'completed' : 'in_progress', }, t); - await t.commit(); + return { anyCompleted, aggregatePercent, cascade }; + }); - // recomputeCascade skipped its own task-sync since it ran under our externalTransaction - // (would've read pre-commit state) — run it now that everything is durable. Runs - // regardless of courseId — a standalone (no parent course) lesson can satisfy a - // read_lesson task requirement via watch_video/listen_audio/watch_percent too. - const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid }); + // recomputeCascade skipped its own task-sync since it ran under our externalTransaction + // (would've read pre-commit state) — run it now that everything is durable. Runs + // regardless of courseId — a standalone (no parent course) lesson can satisfy a + // read_lesson task requirement via watch_video/listen_audio/watch_percent too. + const completedTasks = await syncCompletedEntitiesToTaskProgress(userId, { lessonUuid, unitUuid, courseUuid }); - return { progress_percent: aggregatePercent, completed: anyCompleted, cascade: { ...cascade, completed_tasks: completedTasks } }; - } catch (err) { - await t.rollback(); - throw err; - } + return { progress_percent: aggregatePercent, completed: anyCompleted, cascade: { ...cascade, completed_tasks: completedTasks } }; } /** @@ -408,8 +405,7 @@ async function recordManualComplete(userId, { entityType, entityId, lessonId = n }); if (!requirement) return null; - const t = await sequelize.transaction(); - try { + const result = await withTransactionRetry(sequelize, async (t) => { await CompletionRequirementProgress.upsert({ requirement_id: requirement.requirement_id, user_id: userId, @@ -437,24 +433,21 @@ async function recordManualComplete(userId, { entityType, entityId, lessonId = n result = { course: await recomputeAndPersist({ entityType: 'course', entityId, userId, courseId: entityId, referenceId: courseUuid }, t) }; } - await t.commit(); - - // Same reasoning as recordWatchProgress — recomputeCascade (lesson branch) skipped its - // own sync under our externalTransaction; the unit/course branches never called it at - // all. Runs regardless of courseId — a standalone (no parent course) lesson/unit - // manual_complete can satisfy a read_lesson/read_unit task requirement too. - const syncUuids = entityType === 'lesson' - ? { lessonUuid, unitUuid, courseUuid } - : entityType === 'unit' - ? { unitUuid, courseUuid } - : { courseUuid }; - result.completed_tasks = await syncCompletedEntitiesToTaskProgress(userId, syncUuids); - return result; - } catch (err) { - await t.rollback(); - throw err; - } + }); + + // Same reasoning as recordWatchProgress — recomputeCascade (lesson branch) skipped its + // own sync under our externalTransaction; the unit/course branches never called it at + // all. Runs regardless of courseId — a standalone (no parent course) lesson/unit + // manual_complete can satisfy a read_lesson/read_unit task requirement too. + const syncUuids = entityType === 'lesson' + ? { lessonUuid, unitUuid, courseUuid } + : entityType === 'unit' + ? { unitUuid, courseUuid } + : { courseUuid }; + result.completed_tasks = await syncCompletedEntitiesToTaskProgress(userId, syncUuids); + + return result; } module.exports = { diff --git a/utils/withTransactionRetry.util.js b/utils/withTransactionRetry.util.js new file mode 100644 index 0000000..402a006 --- /dev/null +++ b/utils/withTransactionRetry.util.js @@ -0,0 +1,48 @@ +/*********************************************************************************************************************************************************************** + * File Name: withTransactionRetry.util.js + * Type of Program: Utility + * Description: Runs a Sequelize transaction, automatically retrying on serialization_failure + * (SQLSTATE 40001) — the transient "restart transaction" error CockroachDB (and + * Postgres SERIALIZABLE) throw when concurrent transactions can't be safely + * ordered. Without a retry, these surface as unhandled 500s on otherwise-correct + * code the moment two requests touch the same row close together. + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Jul. 17, 2026 + *********************************************************************************************************************************************************************** + * HOW TO USE: + * const { withTransactionRetry } = require('../utils/withTransactionRetry.util'); + * const result = await withTransactionRetry(sequelize, async (t) => { + * await Model.upsert({...}, { transaction: t }); + * return something; + * }); + ***********************************************************************************************************************************************************************/ +'use strict'; + +const SERIALIZATION_FAILURE = '40001'; +const MAX_RETRIES = 5; +const BASE_DELAY_MS = 50; + +function isSerializationFailure(err) { + return err?.parent?.code === SERIALIZATION_FAILURE || err?.original?.code === SERIALIZATION_FAILURE; +} + +async function withTransactionRetry(sequelize, fn, { maxRetries = MAX_RETRIES } = {}) { + for (let attempt = 0; ; attempt++) { + const t = await sequelize.transaction(); + try { + const result = await fn(t); + await t.commit(); + return result; + } catch (err) { + await t.rollback(); + if (isSerializationFailure(err) && attempt < maxRetries) { + const delay = BASE_DELAY_MS * 2 ** attempt + Math.random() * BASE_DELAY_MS; + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + throw err; + } + } +} + +module.exports = { withTransactionRetry, isSerializationFailure };