Este experimento evalúa el impacto de reiniciar un proceso tras un fallo en tres estrategias distintas de gestión de efectos y operaciones lógicas. Los datos provienen de una ejecución real realizada el 6 de octubre de 2026, utilizando Python 3.13.7, SQLite 3.50.4 y macOS arm64. No se usaron LLM, LangGraph, APIs externas, datos de clientes ni cobros reales. El ejemplo es sintético, con 100 trabajos por estrategia, mismas 100 entradas, y 3 estrategias en total, lo que suma 300 trabajos.

La tabla effects representa una acción de negocio: admite varias filas para el mismo trabajo, de modo que podemos detectar duplicados. La tabla ledger, con una clave primaria por trabajo, conserva la marca de operación procesada. Cada estrategia recibe 25 casos sin fallo y 75 con fallo: 25 antes del efecto, 25 después del insert y25después del commit pero antes de confirmar al proceso padre. Los fallos solo se inyectan en el primer intento. Los procesos hijos finalizan abruptamente con os._exit(73), y el padre reintenta una vez, usando el mismo jobid tras el fallo, y luego sin fallo. Se abren nuevas conexiones y el ledger sobrevive al proceso, lo que no es meramente una excepción capturada.

Tres maneras de recuperar la operación

Sin protección: insertar la acción, confirmarla y volver a intentarlo si el proceso no respondió. Si la acción ya se había guardado, el reintento crea otra.

Marcar antes: registrar y confirmar la marca antes de hacer la acción. El reintento consulta esa marca y termina, aunque el proceso anterior hubiera muerto sin hacer el trabajo.

Transacción compartida: guardar acción y marca dentro de una misma transacción. O se confirman ambas o se descartan juntas. En el reintento se consulta la marca persistente.

Hay una diferencia importante en el escenario después del insert: las dos primeras estrategias ya han confirmado la acción; la tercera todavía no. Es precisamente la agrupación de las escrituras lo que estamos poniendo a prueba. Un registro de estado separado no resuelve por sí solo esa ventana.

Resultados medidos

Los 75 reintentos por estrategia se llevaron a cabo, y los resultados no miden probabilidad de producción ni rendimiento. La duplicación de trabajos vs. efectos extra coinciden en 50 aquí porque cada trabajo fallido recibió un único reintento. Con más reintentos, esos dos contadores podrían diferir.

Los resultados de 0 errores solo se obtuvieron bajo condiciones específicas: secuencial, determinista con 100 casos por estrategia, sin concurrencia ni corte eléctrico. Esto no demuestra ejecución exactamente una vez en cualquier sistema distribuido. Tampoco es una auditoría de SQLite ni una comparación de rendimiento.

Cada estrategia partía de 100 operaciones deseadas y recibió 75 reintentos. “Duplicados” cuenta trabajos con más de una acción; “perdidos”, trabajos sin acción.

Estrategia Efectos Duplicados Perdidos
Ingenua 150 50 0
Marcar antes 75 0 25
Atómica 100 0 0

Qué significa para un agente

  • Idempotencia: Permite repetir una misma solicitud lógica sin producir un efecto adicional después del primero. Por ejemplo, si se envía dos veces la misma solicitud, el resultado será el mismo que si se envió una vez.

La frontera con una API externa

Este experimento muestra que la estrategia atómica es la más segura en este contexto, ya que evita duplicados y pérdidas. Sin embargo, no se garantiza un comportamiento universal. La estrategia "marcar antes" puede perder trabajos si ocurre un fallo entre la marca y el efecto. La estrategia ingenua es la más propensa a duplicados. Si la acción ocurre en una API externa, nuestra transacción SQLite no incluye lo que haga ese servicio. No basta con envolver la llamada en un BEGIN local. Cuando el receptor admite claves de idempotencia, conserva una clave estable por operación lógica durante sus reintentos y respeta las condiciones y el tiempo de retención de esa API.

Registra estados pendientes y completados, y reconcilia una respuesta incierta antes de repetir una acción sensible. Comprueba que identidad, importe y contenido corresponden a la misma operación. Una operación nueva necesita su propia clave. Un patrón outbox puede ayudar con la entrega de eventos, pero el receptor seguirá necesitando un tratamiento adecuado de repeticiones.

Un checkpoint permite recuperar estado del flujo; no convierte automáticamente los efectos externos en una transacción. Este experimento no ejecuta LangGraph: su documentación de persistencia sirve como contexto arquitectónico. Para elegir el flujo, consulta workflow o agente y el contrato de las herramientas.

Reproducir la prueba

Copia este código en experiment.py y ejecuta python3 experiment.py en una carpeta vacía. Usa solo la biblioteca estándar; crea un dataset y resultados JSON, y borra las bases temporales al terminar. Las aserciones comprueban los resultados esperados en este diseño. No ejecutes varias copias sobre la misma carpeta.

El entorno de la ejecución publicada fue Python 3.13.7, SQLite 3.50.4 y macOS arm64. Conservamos código, dataset, log y hashes de los resultados. Los registros individuales permiten comprobar que las pérdidas de “marcar antes” corresponden a fallos antes del efecto. La prueba no evalúa un LLM, concurrencia, cortes eléctricos ni la política de un proveedor remoto.

"""Synthetic process-crash experiment; Python standard library only."""
import json
import os
from pathlib import Path
import platform
import sqlite3
import subprocess
import sys
import tempfile


def worker(db, strategy, job, failure):
    conn=sqlite3.connect(db)
    if strategy!='naive' and conn.execute('SELECT 1 FROM ledger WHERE id=?',(job,)).fetchone():
        return
    if strategy=='mark_first':
        conn.execute('INSERT INTO ledger VALUES (?)',(job,));conn.commit()
    if strategy=='atomic':conn.execute('BEGIN IMMEDIATE')
    if failure=='before_effect':os._exit(73)
    conn.execute('INSERT INTO effects VALUES (?)',(job,))
    if strategy!='atomic':conn.commit()
    if failure=='after_effect':os._exit(73)
    if strategy=='atomic':
        conn.execute('INSERT INTO ledger VALUES (?)',(job,));conn.commit()
    if failure=='before_ack':os._exit(73)
    conn.close()


def experiment():
    scenarios=['none','before_effect','after_effect','before_ack']
    fixture=[{'id':f'job-{i:03d}','failure':scenarios[i%4]} for i in range(100)]
    Path('dataset.json').write_text(json.dumps(fixture,indent=2)+'\n')
    summary=[];details=[]
    with tempfile.TemporaryDirectory() as folder:
        for strategy in ['naive','mark_first','atomic']:
            db=str(Path(folder)/f'{strategy}.db')
            conn=sqlite3.connect(db)
            conn.executescript('CREATE TABLE effects(id TEXT); CREATE TABLE ledger(id TEXT PRIMARY KEY);');conn.close()
            retries=0
            for item in fixture:
                args=[sys.executable,str(Path(__file__).resolve()),'--worker',db,strategy,item['id']]
                first=subprocess.run(args+[item['failure']],check=False)
                if first.returncode==73:
                    retries+=1
                    subprocess.run(args+['none'],check=True)
                elif first.returncode!=0:raise RuntimeError('Unexpected worker error')
            conn=sqlite3.connect(db)
            counts=dict(conn.execute('SELECT id,COUNT(*) FROM effects GROUP BY id'));conn.close()
            rows=[dict(strategy=strategy,**item,effects=counts.get(item['id'],0)) for item in fixture]
            details.extend(rows)
            summary.append(dict(strategy=strategy,jobs=len(fixture),retries=retries,effects=sum(counts.values()),missing=sum(r['effects']==0 for r in rows),duplicated_jobs=sum(r['effects']>1 for r in rows),extra_effects=sum(max(0,r['effects']-1) for r in rows)))
    expected={'naive':(0,50),'mark_first':(25,0),'atomic':(0,0)}
    for row in summary:assert (row['missing'],row['extra_effects'])==expected[row['strategy']],row
    result={'date':'2026-10-06','environment':{'python':platform.python_version(),'sqlite':sqlite3.sqlite_version,'os':platform.system(),'machine':platform.machine()},'jobs_per_strategy':100,'failures':'os._exit(73), first attempt only; one retry per failed job','summary':summary,'cases':details,'limits':['Synthetic local SQLite effects, no external API or LLM','Sequential workers, no concurrency or hardware power loss','Zero observed errors is not an exactly-once guarantee for distributed systems']}
    Path('results.json').write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n')
    print(json.dumps({'environment':result['environment'],'summary':summary},ensure_ascii=False))


if __name__=='__main__':
    if len(sys.argv)>1 and sys.argv[1]=='--worker':worker(*sys.argv[2:])
    else:experiment()

Fuentes

Sigue explorando