La cinquième partie a donné au modèle une file d'attente de brouillons et l'a tenu à l'écart du bouton Publier. Il reste encore un problème - la démonstration appelle le modèle à partir d'une requête que j'ai lancée manuellement. Une page longue peut nécessiter plusieurs appels au modèle. Si je fais attendre un navigateur pour tous, je n'ai pas construit un pipeline. J'ai construit une roue de chargement qui souffre d'un trouble anxieux.
Cette dernière partie confie le travail au planificateur de ColdFusion. La base de données décide quel champ est prêt, quel champ est déjà en cours de traitement, et quand une tentative échouée peut être relancée. Une exécution planifiée traite un champ. Ce n'est pas la conception la plus rapide possible, mais elle est assez petite pour être comprise quand quelque chose casse à 2 h du matin.
Je ne vais pas créer un point de terminaison public /run-translations.cfm. Le planificateur de ColdFusion peut appeler directement un composant gestionnaire d'événements. Adobe note qu'un gestionnaire d'événements planifié n'invoque pas Application.cfc, donc le composant doit construire explicitement ce dont il a besoin. C'est une contrainte utile, pas une invitation à emprunter la session de quelqu'un d'autre. Consultez la documentation du planificateur d'Adobe et la référence cfschedule.
Laisser l'inventaire conserver le travail
J'ai déjà une ligne translation_item par champ de page et langue cible. Une ligne marquée missing ou stale correspond à du travail en attente. Je n'ai pas besoin de la copier dans une deuxième table de file d'attente et de passer le reste de ma vie à réconcilier deux opinions sur la même phrase.
Ajoutez une petite quantité d'état de traitement à cette 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');Une tentative obtient une licence d'utilisation de trente minutes. Je ne laisse pas une transaction de base de données ouverte pendant qu'un modèle traduit le texte. La licence dit : « Quelqu'un travaille là-dessus pour l'instant. » Si le travailleur meurt, une autre exécution planifiée peut récupérer la ligne après l'expiration de la licence. La limite de quatre tentatives est intentionnelle. Une panne du fournisseur mérite une nouvelle tentative. Une demande répétée sans fin mérite une facture, suivie d'une conversation difficile avec moi-même.
Lorsque la source change, le scanner d'inventaire doit réinitialiser l'état de traitement. Ajoutez ces affectations à la clause ON CONFLICT ... DO UPDATE SET dans TranslationInventory.cfc, avant 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,Cela suppose que les affectations de source_revision de la cinquième partie sont déjà dans cette clause. Une nouvelle version de source obtient une nouvelle chance. Une source inchangée conserve son historique de nouvelles tentatives, de sorte que sa rescannérisation toutes les cinq minutes ne pardonne pas accidentellement une requête qui échoue de façon permanente.
Il y a une autre vérification de révision à ajouter. Dans la cinquième partie, saveDrafts() comparait l'empreinte source d'un candidat avec la page actuelle. Une source peut changer puis revenir plus tard à un texte identique. L'empreinte correspond de nouveau, mais le candidat appartient à une révision plus ancienne. À l'intérieur du premier if de validation du candidat dans TranslationWorkflow.saveDrafts(), insérez ces tests juste avant le test !isSimpleValue( candidate.translated_text ) existant :
!structKeyExists( candidate, "source_revision" ) ||
!isNumeric( candidate.source_revision ) ||
val( candidate.source_revision ) < 1 ||Ajoutez ce prédicat à son instruction UPDATE translation_item :
AND source_revision = :sourceRevisionEt ajoutez le paramètre lié à côté de sourceHash :
sourceRevision: { cfsqltype: "bigint", value: candidate.source_revision },Le worker ci-dessous joint cette révision avant de sauvegarder. Si vous utilisez encore l'appel manuel saveDrafts() de la partie cinq, mettez à jour ses candidats de la même façon ou retirez la page de diagnostic. Un brouillon de mardi dernier ne devient pas actuel simplement parce que ce mardi-là utilise par hasard les mêmes mots.
Réclamez un champ sans verrouiller l'appel au modèle
Créez 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
);
}
}La réclamation est une seule instruction PostgreSQL. FOR UPDATE SKIP LOCKED permet à un autre travailleur d'ignorer une ligne déjà réclamée au lieu d'attendre dessus. PostgreSQL mentionne explicitement les tables de type file d'attente comme cas d'utilisation de cette fonctionnalité. La transaction se termine avant le début de tout appel au modèle. Voir la documentation sur le verrouillage PostgreSQL.
Avant de traduire, le travailleur relance le scanner et vérifie que son bail, son empreinte et la révision source correspondent toujours. Cela évite de payer pour un texte qui a changé entre le balayage précédent et la réclamation. Il y a toujours une course pendant que le modèle travaille, donc saveDrafts() effectue la vérification finale. Si un bail expire et que deux travailleurs terminent, un seul peut déplacer un élément encore en attente vers draft. L'autre échoue sans danger, même si le fournisseur a peut-être été payé deux fois. Un bail est un mécanisme de récupération, pas de la magie.
Le service de champ court de la partie trois accepte un tableau d'un seul élément, donc il fonctionne ici. Le traitement d'un champ à la fois me donne des reprises précises. Si le corps échoue à la partie sept, je n'ai pas besoin de régénérer aussi le titre. Le prix à payer est moins d'occasions de traitement par lots. Je peux optimiser cela après être capable d'expliquer mes états d'échec sans dessiner sur une serviette en papier.
L'entrée de glossaire community est l'entrée de démonstration de la partie quatre. Dans une application, chargez un glossaire approuvé pour la langue cible à partir d'une configuration de confiance. Ne laissez pas la page en cours de traduction écrire son propre glossaire puis appeler cela une mesure de protection.
Donnez une seule tâche au planificateur
Créez 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."
);
}
}Ce gestionnaire ne touche pas à application ni à session. ColdFusion ne les créera pas pour un gestionnaire d'événements de planificateur. Il utilise le même nom de source de données que les exemples précédents, et NativeTranslationModel.cfc lit toujours ses paramètres de fournisseur à partir de l'environnement. Modifiez la source de données et le chemin du composant pour votre installation. Si ces chemins de composant n'existent que via un mappage Application.cfc, donnez plutôt au planificateur un mappage au niveau du serveur.
Exécutez ceci une fois à partir d'une page de configuration protégée, ou configurez la tâche équivalente dans 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>L'intervalle numérique est en secondes, donc cela commence avec un champ toutes les dix minutes. C'est volontairement conservateur pour l'exemple de long texte, qui peut effectuer jusqu'à trente appels séquentiels au modèle. Ne configurez pas non plus la reprise automatique REFIRE du planificateur. La ligne a déjà un budget de quatre tentatives et son propre mécanisme d'attente progressive; deux systèmes de reprise indépendants, c'est ainsi qu'une petite panne se transforme en danse interprétative exécutée par votre tableau de facturation. Adobe documente les reprises au niveau du planificateur séparément dans son guide du planificateur.
Conservez l'appel scanPage() existant sur chaque chemin de sauvegarde de contenu autorisé. Une modification de la source change la ligne d'inventaire en stale ou missing et réinitialise son état de tentative. La prochaine exécution planifiée la trouvera. Si une importation ou un autre chemin d'écriture contourne le scanner, planifiez une analyse de réconciliation distincte pour ce chemin. Le travailleur ne peut pas détecter un changement dans une page qu'on ne lui a jamais demandé d'inspecter.
Pour un test local protégé, modifiez le titre de la page d'exemple de la partie deux, scannez-la et exécutez une invocation du travailleur :
<cfscript>
siteId = "[redacted][redacted]";
pageId = "[redacted][redacted]";
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>Cela nécessite la configuration du modèle de la partie un. Si cette page n'a aucun champ en attente, le résultat est idle. Si elle en a un, une exécution réussie retourne draft. Vérifiez que le lecteur public de la partie cinq refuse toujours de traiter ce brouillon comme publié. Ensuite, supprimez ou protégez la page de diagnostic. S'il vous plaît, ne mettez pas un bouton manuel d'appel au modèle sur l'internet ouvert. Quelqu'un le trouvera, et je préférerais que cette personne ne comprenne pas mieux que moi ma facture de fournisseur.
Sachez ce qui a échoué sans consigner le contenu
Le travailleur consigne les identifiants, un code d'erreur sur liste d'autorisation, les tentatives, les appels de modèle prévus et un nombre de caractères. Il ne consigne jamais le texte source, le texte traduit, la requête au modèle, la réponse du modèle ou l'exception brute. Gardez logRequests: false et logResponses: false dans NativeTranslationModel.cfc de la partie trois. Une exception du fournisseur peut contenir le texte d'invite reproduit, c'est pourquoi writeDump( error ) n'est pas une stratégie de dépannage ici.
planned_calls n'est pas une facture. Le service de longs textes peut s'arrêter au milieu de ses segments, et l'enveloppe de modèle actuelle retourne du texte plutôt que des chiffres d'utilisation du fournisseur. Utilisez le rapport d'utilisation du fournisseur pour connaître les frais réels. Les chiffres dans ce journal me disent quels travaux pourraient être coûteux et si une vague de nouvelles tentatives se prépare.
Cette requête me donne une vue du backlog sans contenu :
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;
Et ceci me dit si les échecs se regroupent autour d'un champ particulier ou d'une catégorie d'erreur :
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;Un élément en échec reste visible jusqu'à ce qu'un opérateur corrige la cause et réinitialise délibérément son état de tentative. Je n'exposerais cette action que dans un écran d'administration authentifié, avec une autorisation au niveau du site et un enregistrement d'audit. L'opération de base de données est petite :
UPDATE translation_item
SET
attempt_count = 0,
next_attempt_at = now(),
last_error_code = NULL
WHERE
site_id = '[redacted][redacted]'
AND page_id = '[redacted][redacted]'
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()
);Remarquez qu'elle ne réinitialise pas un bail non expiré. Un bail expiré ne prouve toujours pas que l'ancien travailleur est mort, alors vérifiez l'ordonnanceur avant de rejouer manuellement un champ qui vient tout juste d'expirer.
Le chemin complet est maintenant durable : les changements de contenu, le scanner marque un champ admissible, l'ordonnanceur le prend en charge, le modèle produit une candidate, les vérifications de l'application enregistrent un brouillon, une personne le révise, et une action distincte le publie. Si une étape échoue, le lecteur public de la partie cinq n'improvisera toujours pas de traduction. Mon logiciel est tout à fait capable de me mettre dans l'embarras sans accorder à un modèle non supervisé ce privilège particulier.
