Skip to main content

Building a Guardrailed AI Translation Pipeline with ColdFusion’s Native AI APIs - Part 6: Take the Translation Job Out of the Browser

Run translation in the background, retry it without a stampede, and find failures without putting page content in a log.

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 = :sourceRevision

And 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.