Part five gave the model a draft queue and kept it away from the Publish button. There is still one problem: the demonstration calls the model from a request I started manually. A long page can take several model calls. If I make a browser wait for all of them, I haven't built a pipeline. I've built a loading spinner with an anxiety disorder.
This last part gives the work to ColdFusion's scheduler. The database decides which field is ready, which field is already being worked on, and when a failed attempt may run again. One scheduled execution handles one field. It isn't the fastest possible design, but it is small enough to understand when something breaks at 2:00 in the morning.
I won't create a public /run-translations.cfm endpoint. ColdFusion's scheduler can call an event handler component directly. Adobe notes that a scheduled event handler does not invoke Application.cfc, so the component must construct what it needs explicitly. That is a useful constraint, not an invitation to borrow somebody's session. See Adobe's scheduler documentation and the cfschedule reference.
Let the inventory hold the work
I already have one translation_item row per page field and target language. A row marked missing or stale is work waiting to happen. I don't need to copy it into a second queue table and spend the rest of my life reconciling two opinions about the same sentence.
Add a small amount of processing state to that table:
ALTER TABLE translation_item
ADD COLUMN attempt_count integer NOT NULL DEFAULT 0
CHECK (attempt_count BETWEEN 0 AND 4),
ADD COLUMN next_attempt_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN lease_until timestamptz,
ADD COLUMN claimed_hash char(64),
ADD COLUMN claimed_revision bigint,
ADD COLUMN last_error_code varchar(50),
ADD COLUMN last_attempt_at timestamptz;
CREATE INDEX translation_item_ready_idx
ON translation_item (next_attempt_at, site_id, page_id)
WHERE is_active = true
AND status IN ('missing', 'stale');An attempt gets a thirty-minute lease. I do not leave a database transaction open while a model translates the text. The lease says, “Somebody is working on this for now.” If the worker dies, another scheduled run can reclaim the row after the lease expires. The four-attempt limit is intentional. A provider outage deserves a retry. An endlessly repeated request deserves an invoice, followed by a difficult conversation with myself.
When the source changes, the inventory scanner must reset the processing state. Add these assignments to the ON CONFLICT ... DO UPDATE SET clause in TranslationInventory.cfc, before updated_at = now():
attempt_count = CASE
WHEN translation_item.source_hash = EXCLUDED.source_hash
THEN translation_item.attempt_count
ELSE 0
END,
next_attempt_at = CASE
WHEN translation_item.source_hash = EXCLUDED.source_hash
THEN translation_item.next_attempt_at
ELSE now()
END,
lease_until = CASE
WHEN translation_item.source_hash = EXCLUDED.source_hash
THEN translation_item.lease_until
ELSE NULL
END,
claimed_hash = CASE
WHEN translation_item.source_hash = EXCLUDED.source_hash
THEN translation_item.claimed_hash
ELSE NULL
END,
claimed_revision = CASE
WHEN translation_item.source_hash = EXCLUDED.source_hash
THEN translation_item.claimed_revision
ELSE NULL
END,
last_error_code = CASE
WHEN translation_item.source_hash = EXCLUDED.source_hash
THEN translation_item.last_error_code
ELSE NULL
END,This assumes the source_revision assignments from part five are already in that clause. A new source version gets a fresh chance. An unchanged source keeps its retry history, so rescanning it every five minutes doesn't accidentally forgive a permanently failing request.
There is one more revision check to add. In part five, saveDrafts() compared a candidate's source fingerprint with the current page. A source can change and later return to identical text. The fingerprint matches again, but the candidate belongs to an older revision. Inside the first candidate-validation if in TranslationWorkflow.saveDrafts(), insert these tests just before the existing !isSimpleValue( candidate.translated_text ) test:
!structKeyExists( candidate, "source_revision" ) ||
!isNumeric( candidate.source_revision ) ||
val( candidate.source_revision ) < 1 ||Add this predicate to its UPDATE translation_item statement:
AND source_revision = :sourceRevisionAnd add the bound parameter beside sourceHash:
sourceRevision: { cfsqltype: "bigint", value: candidate.source_revision },The worker below attaches that revision before saving. If you're still using the manual saveDrafts() call from part five, update its candidates the same way or retire the diagnostic page. A draft from last Tuesday doesn't become current merely because this Tuesday happens to use the same words.
Claim one field without locking the model call
Create components/TranslationWorker.cfc:
component {
public TranslationWorker function init(
required string datasource,
required any model
) {
variables.datasource = arguments.datasource;
variables.model = arguments.model;
return this;
}
public struct function runOne() {
var claimed = this.claim();
if ( !arrayLen( claimed ) ) {
return { result: "idle" };
}
var job = claimed[ 1 ];
var plannedCalls = 0;
var sourceCharacters = 0;
try {
var inventory = new components.TranslationInventory(
variables.datasource
);
inventory.scanPage(
siteId=job.site_id,
pageId=job.page_id,
targetLocale=job.target_locale
);
var current = this.currentClaim( job );
if ( !arrayLen( current ) ) {
this.release( job );
return { result: "superseded" };
}
var item = current[ 1 ];
sourceCharacters = len( item.source_text );
var candidate = {};
if ( compare( item.field_name, "body" ) == 0 ) {
var longService = new components.LongTextTranslationService(
variables.model,
{ community: "communauté" }
);
plannedCalls = arrayLen(
longService.split( item.source_text )
);
candidate = longService.translateItem( item );
} else {
plannedCalls = 1;
var shortService = new components.TranslationBatchService(
variables.model
);
var shortCandidates = shortService.translatePending( [ item ] );
candidate = shortCandidates[ 1 ];
}
candidate.source_revision = item.source_revision;
var workflow = new components.TranslationWorkflow(
variables.datasource
);
workflow.saveDrafts(
siteId=job.site_id,
pageId=job.page_id,
candidates=[ candidate ]
);
} catch ( any error ) {
var code = this.safeCode( error );
this.recordFailure( job, code );
this.logResult(
job=job,
result="attempt_failed",
code=code,
plannedCalls=plannedCalls,
sourceCharacters=sourceCharacters
);
return { result: "attempt_failed", code: code };
}
this.release( job );
this.logResult(
job=job,
result="draft",
code="None",
plannedCalls=plannedCalls,
sourceCharacters=sourceCharacters
);
return { result: "draft" };
}
private array function claim() {
return queryExecute(
sql = "
WITH next_item AS (
SELECT
site_id,
page_id,
field_name,
target_locale
FROM
translation_item
WHERE
is_active = true
AND status IN ('missing', 'stale')
AND attempt_count < 4
AND next_attempt_at <= now()
AND (
lease_until IS NULL
OR lease_until <= now()
)
ORDER BY
next_attempt_at,
site_id,
page_id,
field_name
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE translation_item AS item
SET
attempt_count = item.attempt_count + 1,
lease_until = now() + INTERVAL '30 minutes',
claimed_hash = item.source_hash,
claimed_revision = item.source_revision,
last_error_code = NULL,
last_attempt_at = now()
FROM
next_item
WHERE
item.site_id = next_item.site_id
AND item.page_id = next_item.page_id
AND item.field_name = next_item.field_name
AND item.target_locale = next_item.target_locale
RETURNING
item.site_id::text AS site_id,
item.page_id::text AS page_id,
item.field_name,
item.target_locale,
item.source_hash,
item.source_revision,
item.attempt_count
",
options = { datasource: variables.datasource, returnType: "array" }
);
}
private array function currentClaim( required struct job ) {
return queryExecute(
sql = "
SELECT
field_name,
source_locale,
target_locale,
source_text,
source_hash,
source_revision,
status
FROM
translation_item
WHERE
site_id = CAST(:siteId AS uuid)
AND page_id = CAST(:pageId AS uuid)
AND field_name = :fieldName
AND target_locale = :targetLocale
AND source_hash = :sourceHash
AND source_revision = :sourceRevision
AND claimed_hash = :sourceHash
AND claimed_revision = :sourceRevision
AND attempt_count = :attemptCount
AND lease_until > now()
AND is_active = true
AND status IN ('missing', 'stale')
",
params = this.jobParams( arguments.job ),
options = { datasource: variables.datasource, returnType: "array" }
);
}
private void function release( required struct job ) {
queryExecute(
sql = "
UPDATE translation_item
SET
lease_until = NULL,
claimed_hash = NULL,
claimed_revision = NULL,
last_error_code = NULL
WHERE
site_id = CAST(:siteId AS uuid)
AND page_id = CAST(:pageId AS uuid)
AND field_name = :fieldName
AND target_locale = :targetLocale
AND source_hash = :sourceHash
AND source_revision = :sourceRevision
AND claimed_hash = :sourceHash
AND claimed_revision = :sourceRevision
AND attempt_count = :attemptCount
",
params = this.jobParams( arguments.job ),
options = { datasource: variables.datasource }
);
}
private void function recordFailure(
required struct job,
required string code
) {
var retryable = listFindNoCase(
"TranslationAI.Unavailable,TranslationAI.EmptyResponse," &
"Translation.InvalidResponse,Translation.InvalidText," &
"Translation.StaleSource,Translation.StaleDraft",
arguments.code
) > 0;
var delayMinutes = 1;
if ( arguments.job.attempt_count == 2 ) {
delayMinutes = 2;
} else if ( arguments.job.attempt_count >= 3 ) {
delayMinutes = 4;
}
var params = this.jobParams( arguments.job );
params.code = { cfsqltype: "varchar", value: arguments.code };
params.retryable = {
cfsqltype: "integer",
value: retryable ? 1 : 0
};
params.delayMinutes = {
cfsqltype: "integer",
value: delayMinutes
};
queryExecute(
sql = "
UPDATE translation_item
SET
attempt_count = CASE
WHEN CAST(:retryable AS integer) = 1
THEN attempt_count
ELSE 4
END,
next_attempt_at =
now() + (CAST(:delayMinutes AS integer) * INTERVAL '1 minute'),
lease_until = NULL,
claimed_hash = NULL,
claimed_revision = NULL,
last_error_code = :code
WHERE
site_id = CAST(:siteId AS uuid)
AND page_id = CAST(:pageId AS uuid)
AND field_name = :fieldName
AND target_locale = :targetLocale
AND source_hash = :sourceHash
AND source_revision = :sourceRevision
AND claimed_hash = :sourceHash
AND claimed_revision = :sourceRevision
AND attempt_count = :attemptCount
AND status IN ('missing', 'stale')
",
params = params,
options = { datasource: variables.datasource }
);
}
private struct function jobParams( required struct job ) {
return {
siteId: { cfsqltype: "varchar", value: arguments.job.site_id },
pageId: { cfsqltype: "varchar", value: arguments.job.page_id },
fieldName: { cfsqltype: "varchar", value: arguments.job.field_name },
targetLocale: { cfsqltype: "varchar", value: arguments.job.target_locale },
sourceHash: { cfsqltype: "varchar", value: arguments.job.source_hash },
sourceRevision: { cfsqltype: "bigint", value: arguments.job.source_revision },
attemptCount: { cfsqltype: "integer", value: arguments.job.attempt_count }
};
}
private string function safeCode( required any error ) {
var code = structKeyExists( arguments.error, "type" )
? toString( arguments.error.type ) : "";
var allowed =
"TranslationAI.Unavailable,TranslationAI.EmptyResponse," &
"TranslationAI.Disabled,TranslationAI.NotConfigured," &
"Translation.InvalidResponse,Translation.InvalidRequest," &
"Translation.InvalidText,Translation.StaleSource," &
"Translation.StaleDraft";
return listFindNoCase( allowed, code )
? code : "Translation.Unexpected";
}
private void function logResult(
required struct job,
required string result,
required string code,
required numeric plannedCalls,
required numeric sourceCharacters
) {
writeLog(
file="translation-worker",
type=arguments.result == "draft" ? "information" : "warning",
text="result=" & arguments.result &
" code=" & arguments.code &
" site=" & arguments.job.site_id &
" page=" & arguments.job.page_id &
" field=" & arguments.job.field_name &
" target=" & arguments.job.target_locale &
" attempt=" & arguments.job.attempt_count &
" planned_calls=" & arguments.plannedCalls &
" source_characters=" & arguments.sourceCharacters
);
}
}The claim is one PostgreSQL statement. FOR UPDATE SKIP LOCKED lets another worker skip an already claimed row instead of waiting on it. PostgreSQL explicitly calls out queue-like tables as a use for this feature. The transaction ends before any model call begins. See the PostgreSQL locking documentation.
Before translating, the worker runs the scanner again and verifies that its lease, fingerprint, and source revision still match. This avoids paying for text that changed between the earlier scan and the claim. There is still a race while the model is working, so saveDrafts() performs the final check. If a lease expires and two workers finish, only one can move a still-pending item to draft. The other fails safely, although the provider may have been paid twice. A lease is a recovery mechanism, not magic.
The short-field service from part three accepts a one-item array, so it works here. Processing one field at a time gives me precise retries. If the body fails on chunk seven, I don't need to regenerate the title too. The price is fewer batching opportunities. I can optimize that after I can explain my failure states without drawing on a napkin.
The community glossary entry is the demonstration entry from part four. In an application, load an approved glossary for the target language from trusted configuration. Don't let the page being translated write its own glossary and then call that a guardrail.
Give the scheduler one job
Create components/TranslationSchedule.cfc:
component implements="cfide.scheduler.ITaskEventHandler" {
public boolean function onTaskStart( struct context ) {
return true;
}
public void function execute( struct context ) {
new components.TranslationWorker(
"translationDemo",
new components.NativeTranslationModel()
).runOne();
}
public void function onTaskEnd( struct context ) {
}
public void function onMisfire( struct context ) {
}
public void function onError( struct context ) {
writeLog(
file="translation-worker",
type="error",
text="The translation scheduler could not run its worker."
);
}
}This handler doesn't touch application or session. ColdFusion won't build those for a scheduler event handler. It uses the same datasource name as the earlier examples, and NativeTranslationModel.cfc still reads its provider settings from the environment. Change the datasource and component path for your installation. If those component paths exist only through an Application.cfc mapping, give the scheduler a server-level mapping instead.
Run this once from a protected setup page, or configure the equivalent task in ColdFusion Administrator:
<cfscript>
firstRun = dateAdd( "n", 15, now() );
cfschedule(
action="update",
task="translation-draft-worker",
mode="application",
eventhandler="components.TranslationSchedule",
startDate=dateFormat( firstRun, "mm/dd/yyyy" ),
startTime=timeFormat( firstRun, "hh:mm tt" ),
interval="600"
);
</cfscript>The numeric interval is in seconds, so this starts with one field every ten minutes. That is deliberately conservative for the long-body example, which can make up to thirty sequential model calls. Do not also configure the scheduler's automatic REFIRE retry. The row already has a four-attempt budget and its own backoff; two independent retry systems are how a small outage turns into interpretive dance performed by your billing dashboard. Adobe documents scheduler-level retries separately in its scheduler guide.
Keep the existing scanPage() call on every authorised content-save path. A source edit changes the inventory row to stale or missing and resets its attempt state. The next scheduled run finds it. If an import or another write path bypasses the scanner, schedule a separate reconciliation scan for that path. The worker cannot detect a change in a page it was never told to inspect.
For a protected local test, change the title on the sample page from part two, scan it, and run one worker invocation:
<cfscript>
siteId = "11111111-1111-4111-8111-111111111111";
pageId = "22222222-2222-4222-8222-222222222222";
inventory = new components.TranslationInventory( "translationDemo" );
inventory.scanPage(
siteId=siteId,
pageId=pageId,
targetLocale="fr-CA"
);
worker = new components.TranslationWorker(
"translationDemo",
new components.NativeTranslationModel()
);
writeDump( worker.runOne() );
</cfscript>This needs the model configuration from part one. If that page has no pending fields, the result is idle. If it has one, a successful run returns draft. Check that the public reader from part five still refuses to treat that draft as published. Then remove or protect the diagnostic page. Please don't put a manual model-call button on the open internet. Somebody will find it, and I would prefer that person not have a better understanding of my provider bill than I do.
Know what failed without logging the content
The worker logs identifiers, an allowlisted error code, attempts, planned model calls, and a character count. It never logs the source text, translated text, model request, model response, or raw exception. Keep logRequests: false and logResponses: false in NativeTranslationModel.cfc from part three. A provider exception may contain echoed prompt text, which is why writeDump( error ) is not a troubleshooting strategy here.
planned_calls is not a bill. The long-text service may stop partway through its chunks, and the current model wrapper returns text rather than provider usage figures. Use the provider's usage reporting for actual charges. The numbers in this log tell me which jobs might be expensive and whether a retry wave is building.
This query gives me a content-free view of the backlog:
SELECT
CASE
WHEN lease_until > now() THEN 'running'
WHEN attempt_count >= 4 THEN 'failed'
WHEN next_attempt_at > now() THEN 'waiting_to_retry'
ELSE 'ready'
END AS work_state,
count(*) AS items
FROM
translation_item
WHERE
is_active = true
AND status IN ('missing', 'stale')
GROUP BY
work_state
ORDER BY
work_state;
And this tells me whether failures cluster around a particular field or error category:
SELECT
field_name,
target_locale,
coalesce(last_error_code, 'WorkerTimeout') AS error_code,
count(*) AS items
FROM
translation_item
WHERE
is_active = true
AND status IN ('missing', 'stale')
AND attempt_count >= 4
AND (
lease_until IS NULL
OR lease_until <= now()
)
GROUP BY
field_name,
target_locale,
coalesce(last_error_code, 'WorkerTimeout')
ORDER BY
items DESC;A failed item stays visible until an operator fixes the cause and deliberately resets its attempt state. I would expose that action only in an authenticated administration screen, with site-level authorisation and an audit record. The database operation is small:
UPDATE translation_item
SET
attempt_count = 0,
next_attempt_at = now(),
last_error_code = NULL
WHERE
site_id = '11111111-1111-4111-8111-111111111111'
AND page_id = '22222222-2222-4222-8222-222222222222'
AND field_name = 'body'
AND target_locale = 'fr-CA'
AND is_active = true
AND status IN ('missing', 'stale')
AND (
lease_until IS NULL
OR lease_until <= now()
);Notice that it doesn't clear an unexpired lease. An expired lease still doesn't prove the old worker is dead, so check the scheduler before manually replaying a field that has just timed out.
The complete path is now durable: content changes, the scanner marks a field eligible, the scheduler claims it, the model produces a candidate, application checks save a draft, a person reviews it, and a separate action publishes it. If any step fails, the public reader from part five still won't improvise a translation. My software is perfectly capable of embarrassing me without giving an unsupervised model that particular privilege.
