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_idde l'événement reçu ; - le
run_idde l'exécution ; - le
call_idd'une tentative de handler ; - l'
effect_idstable 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 :
{
"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 :
| Mode | Après une issue inconnue |
|---|---|
idempotency_key | Touba 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. |
reconcile | Touba bloque avec EffectReconciliationRequired jusqu'à un rapprochement explicite. |
none | Touba 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 :
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 :
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é
appliedavec un résultat validé : le résultat est relu du journal, sans redispatch ;appliedsans 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
- 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.