Aller au contenu principal
Version: 1.0.0

Effets externes et idempotence bornée

Une déduplication de webhook empêche de consommer deux fois le même événement. Elle ne prouve pas qu'une écriture externe n'a eu lieu qu'une fois. Si le service distant applique l'écriture puis que le handler ou le processus tombe avant de répondre, l'issue locale reste inconnue.

Touba distingue donc quatre identités :

  • l'event_id de l'événement reçu ;
  • le run_id de l'exécution ;
  • le call_id d'une tentative de handler ;
  • l'effect_id stable de l'écriture métier logique.

Cette capacité ne change pas la syntaxe .tb ni l'AIR. Le contrat appartient au manifest, au handler et à l'hôte qui conserve le journal.

Déclarer la vérité du binding​

Chaque binding du manifest V1 doit déclarer un effect_contract conforme au comportement réel du handler :

touba.manifest.json
{
"manifest_version": "touba-manifest-v1",
"bindings": {
"tool:examples.orders/CreateOrder": {
"handler": {
"kind": "process",
"command": "node",
"args": ["handlers/create_order.handler.mjs"],
"cwd": "."
},
"grants": ["network"],
"timeout_ms": 5000,
"max_payload_bytes": 1048576,
"effect_contract": {
"kind": "external_write",
"retry_mode": "idempotency_key"
}
},
"tool:examples.orders/ReadOrder": {
"handler": {
"kind": "process",
"command": "node",
"args": ["handlers/read_order.handler.mjs"],
"cwd": "."
},
"grants": ["network"],
"timeout_ms": 5000,
"max_payload_bytes": 1048576,
"effect_contract": {
"kind": "none"
}
}
}
}

Les modes d'écriture expriment une capacité réelle du service externe :

ModeAprès une issue inconnue
idempotency_keyTouba peut répéter la même requête avec le même effect_id. Le package doit réellement transmettre cette clé au service.
reconcileTouba bloque avec EffectReconciliationRequired jusqu'à un rapprochement explicite.
noneTouba bloque avec EffectOutcomeUnknown et ne présente pas le rejeu comme sûr.

Un contrat absent ou encore à null, un mode inconnu ou un champ inconnu est refusé avant le handler. prepare ne choisit pas ce contrat à votre place.

Produire un reçu lié à la requête​

Un binding external_write utilise le protocole touba-tool-handler-v1. Le SDK expose l'effet reçu en lecture seule et un helper qui lie le reçu à son identité et à son empreinte :

handlers/create_order.handler.mjs
import { ToubaHandlerError, createToolResult, defineTool } from "@touba/sdk";

await defineTool({
toolRef: "tool:examples.orders/CreateOrder",
inputSignature: [
{ name: "request", type_ref: "type:examples.orders/CreateOrderInput" },
],
outputType: "type:examples.orders/Order",
requiredGrants: ["network"],
run: async ({ request, context }) => {
if (!context.effect || !context.createEffectReceipt) {
throw new ToubaHandlerError(
"EffectContextRequired",
"CreateOrder requires touba-tool-handler-v1",
);
}

const order = await createOrder(request, {
idempotencyKey: context.effect.effect_id,
});

return createToolResult(order, {
effectReceipt: context.createEffectReceipt({
outcome: "applied",
receipt_ref: `order:${order.id}`,
}),
});
},
});

Cet extrait illustre le contrat SDK : createOrder est l'adaptateur métier à implémenter pour votre fournisseur, pas une fonction fournie par Touba.

Le helper ne rend pas le service idempotent. Il évite seulement qu'un handler retourne un reçu pour une autre identité ou une autre empreinte. Les issues possibles sont applied, not_applied et unknown.

Fournir une portée et un journal durables​

La CLI, le SDK JavaScript, le service de triggers et le serveur projet raccordent cette capacité à un journal durable. Le service de triggers utilise le queue_id comme portée stable. Pour une écriture externe, fournissez un journal explicite :

Terminal
touba trigger serve program.air.json \
--manifest touba.manifest.json \
--state touba.triggers.sqlite \
--queue touba.trigger-queue.sqlite \
--effect-journal touba.effect-journal.sqlite

Le journal local suit touba-effect-journal-v1. Touba écrit prepared avant le lancement, dispatched juste avant l'envoi, puis l'une des issues applied, not_applied ou unknown. Une reprise qui retrouve dispatched le transforme en unknown au lieu de supposer un succès ou un échec.

Pour la CLI directe, ajoutez --effect-journal touba.effect-journal.sqlite à touba run. Avec ToubaEngine.run, passez effectJournal avec ce chemin. Sans journal, external_write est refusé avant le handler. Chaque nouveau run CLI reçoit une portée distincte ; une reprise humaine conserve celle du checkpoint. Relancer la commande n'est donc pas une reprise idempotente.

La CLI acquiert un verrou exclusif <journal>.lock pendant le run. Un verrou laissé par un crash exige une inspection de l'hôte avant retrait ; Touba ne le vole pas automatiquement. Ne partagez pas ce journal avec un autre hôte ou le serveur projet. Le journal ne remplace pas un checkpoint de continuation.

Pour un parcours séquentiel éligible à la reprise durable, le serveur projet utilise l'operation_id comme portée stable et conserve aussi un checkpoint. Un binding kind: none reste exécutable sans journal. Tous les handlers utilisent le même protocole V1, avec operation: invoke pour un appel normal et operation: compensate pour une compensation explicite.

Pour les webhooks, fournissez aussi un x-touba-event-id stable. Le service lie (trigger_ref, event_id) au digest SHA-256 du corps : une réception identique réutilise la même entrée et le même queue_id, tandis qu'un contenu différent sous la même identité reçoit TriggerEventPayloadConflict avant enqueue. Une redelivery identique d'une entrée déjà consommée reçoit TriggerReplayRejected avec le même queue_id. Sans identifiant explicite, chaque requête reçoit une nouvelle identité et cette déduplication n'est pas possible. Un en-tête présent mais invalide est refusé avec TriggerEventIdInvalid avant toute création d'entrée.

Ce qui est rejoué​

  • applied avec un résultat validé : le résultat est relu du journal, sans redispatch ;
  • applied sans résultat restaurable : EffectResultUnavailable, sans redispatch ;
  • not_applied : une nouvelle tentative peut être autorisée par les limites de l'hôte ;
  • unknown + idempotency_key : même identité, même empreinte, tentative suivante ;
  • unknown + reconcile : rapprochement obligatoire ;
  • unknown + none : arrêt explicite.

La même identité avec un contenu différent produit EffectRequestConflict avant le handler.

Un effet applied n'est jamais réécrit comme annulé. Lorsqu'un package déclare une opération inverse précise dans un manifest V1, l'hôte peut créer une compensation distincte. Voir Compensations explicites et bornées.

Limites​

info
  • La garantie est bornée à un hôte local mono-processus et à un journal atomiquement remplacé.
  • Un même fichier de queue, d'état ou de journal ne doit pas être partagé entre plusieurs processus ou machines.
  • Le journal et le service externe ne forment pas une transaction distribuée.
  • Touba ne promet pas exactly-once.
  • Un package qui déclare une clé idempotente sans la transmettre réellement au fournisseur peut encore dupliquer l'effet.
  • Les preuves HTTP conservent l'état fournisseur dans la mémoire d'un processus de test indépendant. Elles couvrent des arrêts réels de l'hôte et du handler, pas le redémarrage ni la durabilité réelle du fournisseur.
  • Les identités, reçus, résultats persistés et erreurs refusent les valeurs de secrets résolues, mais le code du handler reste du code hôte à auditer.

Erreur du handler et état de l’effet​

Une erreur métier conserve son code public, y compris lorsqu’un reçu manque. Le diagnostic distinct receipt_error_code décrit le reçu absent ou invalide. L’issue de l’effet reste inconnue tant qu’un reçu valide ou un rapprochement ne l’établit pas.

Après un rollback confirmé, un handler Python peut répondre ainsi :

from touba_sdk import ToubaHandlerError, create_effect_receipt

raise ToubaHandlerError(
"ExternalWriteRefused",
"The database refused the transaction.",
{"effect_receipt": create_effect_receipt(
context["effect"], {"outcome": "not_applied"}
)},
)

Équivalent JavaScript, après un rollback confirmé :

throw new ToubaHandlerError("ExternalWriteRefused", "The database refused the transaction.", {
effect_receipt: context.createEffectReceipt({ outcome: "not_applied" }),
});

Si le commit a pu réussir, utilisez unknown. Le message public décrit le problème sans y inclure la connexion, la requête SQL ni des données privées.