Transactions IBM i (AS/400) en C# (.NET) : Commit, Rollback et savepoints avec NTi

Introduction

Une transaction garantit qu'un ensemble d'opérations en base de données s'exécute de manière atomique : soit toutes les opérations sont validées (Commit), soit aucune (Rollback).

Le cas typique est le virement bancaire : débiter un compte et en créditer un autre sont deux opérations distinctes. Si le débit passe mais que le crédit échoue, les données sont corrompues.

Sur IBM i, cette mécanique repose sur le contrôle d'engagement DB2 for i (commitment control). Lorsqu'une transaction démarre, l'IBM i initialise automatiquement l'environnement de contrôle d'engagement. C'est lui qui garantit qu'une transaction soit intégralement validée ou intégralement annulée, y compris en cas de terminaison anormale du programme.

Le contrôle d'engagement ne s'applique qu'aux tables journalisées. Deux cas de figure :

  • Schéma créé en SQL avec CREATE SCHEMA : rien à faire. CREATE SCHEMA crée d'office un journal QSQJRN dans le schéma, et chaque table qui y est créée est journalisée automatiquement.
  • Bibliothèque créée en CL avec CRTLIB : aucune journalisation automatique. Il faut alors créer un récepteur de journal (CRTJRNRCV), un journal (CRTJRN), puis démarrer la journalisation de chaque table concernée (STRJRNPF).

Ce tutoriel utilise la voie CREATE SCHEMA, la plus simple, et montre la variante CRTLIB pour les bibliothèques existantes.


Étape 1 - Préparer l'environnement IBM i

Scénario : un virement entre deux comptes. La table ACCOUNTS contient deux titulaires, Alice (1000) et Bob (500). Les tests consistent à débiter l'un et créditer l'autre dans une même transaction.

Exécutez ce script complet dans ACS :

-- Créer le schéma : CREATE SCHEMA journalise d'office
-- (journal QSQJRN créé dans le schéma, tables journalisées automatiquement)
CREATE SCHEMA BANKTEST;

SET CURRENT SCHEMA = BANKTEST;

-- Créer la table
CREATE TABLE accounts (
    account_id  INTEGER NOT NULL GENERATED ALWAYS AS IDENTITY,
    owner       VARCHAR(50) NOT NULL,
    balance     DECIMAL(11,2) NOT NULL DEFAULT 0,
    CONSTRAINT pk_accounts PRIMARY KEY (account_id)
);

-- Données initiales
INSERT INTO accounts (owner, balance) VALUES ('Alice', 1000.00);
INSERT INTO accounts (owner, balance) VALUES ('Bob',    500.00);

Aucun CRTJRNRCV, CRTJRN ni STRJRNPF ici : la journalisation est déjà en place grâce à CREATE SCHEMA.

💡 Variante CRTLIB : pour une bibliothèque créée en CL (ou une bibliothèque existante non journalisée), la journalisation est à votre charge, et STRJRNPF doit être exécuté sur chaque table qui participe à des transactions :

-- Bibliothèque CL : pas de journalisation automatique
CL: CRTLIB LIB(BANKTEST);
CL: CRTJRNRCV JRNRCV(BANKTEST/BANKRCV);
CL: CRTJRN JRN(BANKTEST/BANKJRN) JRNRCV(BANKTEST/BANKRCV);

-- ... création de la table comme ci-dessus, puis :
CL: STRJRNPF FILE(BANKTEST/ACCOUNTS) JRN(BANKTEST/BANKJRN);

Pour vérifier la présence du journal (nommé QSQJRN avec la voie CREATE SCHEMA) :

WRKOBJ OBJ(BANKTEST/*ALL) OBJTYPE(*JRN)

Étape 2 - Créer le projet .NET

Créez un projet Console App et ajoutez le package NTi :

dotnet new console -n NtiTransacDemo
cd NtiTransacDemo
dotnet add package Aumerial.Data.Nti

Étape 3 - Ouvrir la connexion

Déclarez une instance de NTiConnection et ouvrez-la. Avec NTi, l'asynchrone est la voie normale (async réel de bout en bout, de OpenAsync à DisposeAsync). La voie synchrone Open() reste disponible.

using System.Data;
using Aumerial.Data.Nti;

await using var conn = new NTiConnection(
    "server=MY_SYSTEM;user=MY_USER;password=MY_PASSWORD;schema=BANKTEST");
await conn.OpenAsync();
Console.WriteLine("Connexion ouverte");

Étape 4 - Niveaux d'isolation

BeginTransaction et BeginTransactionAsync acceptent les quatre niveaux d'isolation ADO.NET standard (voir IsolationLevel) et les mappent sur les niveaux DB2 for i :

IsolationLevel (.NET) Niveau DB2 for i Comportement
ReadUncommitted Uncommitted Read (UR) lit les modifications non validées
ReadCommitted Cursor Stability (CS) ne lit que les données validées, défaut NTi
RepeatableRead Read Stability (RS) les lignes lues restent stables jusqu'à la fin de la transaction
Serializable Repeatable Read (RR) isolation maximale, verrouillage le plus large

BeginTransaction() et BeginTransactionAsync() sans argument démarrent en ReadCommitted.

💡 La terminologie diffère entre les deux mondes : le niveau « Repeatable Read » de DB2 correspond au niveau ANSI Serializable.


Étape 5 - Test 1 : Commit

Virement de 200 d'Alice vers Bob. Les deux UPDATE sont exécutés dans la même transaction et validés ensemble par un CommitAsync.

await using var transaction = (NTiTransaction)await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted);
try
{
    await using var debit = conn.CreateCommand();
    debit.Transaction = transaction;
    debit.CommandText = "UPDATE BANKTEST.ACCOUNTS SET BALANCE = BALANCE - 200 WHERE OWNER = 'Alice'";
    await debit.ExecuteNonQueryAsync();

    await using var credit = conn.CreateCommand();
    credit.Transaction = transaction;
    credit.CommandText = "UPDATE BANKTEST.ACCOUNTS SET BALANCE = BALANCE + 200 WHERE OWNER = 'Bob'";
    await credit.ExecuteNonQueryAsync();

    await transaction.CommitAsync();
    Console.WriteLine("Commit OK");
}
catch (Exception ex)
{
    await transaction.RollbackAsync();
    Console.WriteLine($"Rollback : {ex.Message}");
}

En synchrone : conn.BeginTransaction(IsolationLevel.ReadCommitted), transaction.Commit(), transaction.Rollback().

Une fois le Commit appliqué, les modifications sont écrites définitivement en base et ne peuvent plus être annulées. Sans Commit, un Rollback ou une terminaison anormale du programme annule toutes les modifications et remet les soldes dans leur état initial.

Vérifiez dans ACS :

SELECT OWNER, BALANCE FROM BANKTEST.ACCOUNTS;
-- Résultat attendu : Alice 800.00 / Bob 700.00

Étape 6 - Test 2 : Rollback sur erreur

Tentative de débit de 9999 sur le compte de Bob : cet exemple simule un virement refusé pour solde insuffisant. Le contrôle du solde lève une exception, le RollbackAsync est déclenché dans le catch et aucune modification n'est appliquée côté base de données.

await using var transaction2 = (NTiTransaction)await conn.BeginTransactionAsync();
try
{
    await using var debit = conn.CreateCommand();
    debit.Transaction = transaction2;
    debit.CommandText = "UPDATE BANKTEST.ACCOUNTS SET BALANCE = BALANCE - 9999 WHERE OWNER = 'Bob'";
    await debit.ExecuteNonQueryAsync();

    // Contrôle du solde après débit : négatif = virement refusé
    await using var check = conn.CreateCommand();
    check.Transaction = transaction2;
    check.CommandText = "SELECT BALANCE FROM BANKTEST.ACCOUNTS WHERE OWNER = 'Bob'";
    var balance = Convert.ToDecimal(await check.ExecuteScalarAsync());

    if (balance < 0)
    {
        throw new InvalidOperationException("Solde insuffisant");
    }

    await transaction2.CommitAsync();
}
catch (Exception ex)
{
    await transaction2.RollbackAsync();
    Console.WriteLine($"Rollback : {ex.Message}");
}

Vérifiez dans ACS :

SELECT OWNER, BALANCE FROM BANKTEST.ACCOUNTS;
-- Résultat attendu : Alice 800.00 / Bob 700.00 (inchangé)

Étape 7 - Savepoints : rollback partiel

Un savepoint marque un point intermédiaire dans la transaction : Rollback(name) annule uniquement ce qui suit le savepoint, sans perdre le début de la transaction, qui peut ensuite être validée normalement. NTiTransaction expose Save(name), Rollback(name) et Release(name), ainsi que leurs variantes asynchrones SaveAsync, RollbackAsync(name) et ReleaseAsync (contrat standard DbTransaction).

Exemple : le virement est validé même si l'attribution d'un bonus échoue.

await using var transaction3 = (NTiTransaction)await conn.BeginTransactionAsync();

// Virement : partie ferme de la transaction
await using var transfer = conn.CreateCommand();
transfer.Transaction = transaction3;
transfer.CommandText = "UPDATE BANKTEST.ACCOUNTS SET BALANCE = BALANCE - 100 WHERE OWNER = 'Alice'";
await transfer.ExecuteNonQueryAsync();

await using var credit3 = conn.CreateCommand();
credit3.Transaction = transaction3;
credit3.CommandText = "UPDATE BANKTEST.ACCOUNTS SET BALANCE = BALANCE + 100 WHERE OWNER = 'Bob'";
await credit3.ExecuteNonQueryAsync();

// Savepoint avant l'étape optionnelle
await transaction3.SaveAsync("BEFORE_BONUS");

try
{
    await using var bonus = conn.CreateCommand();
    bonus.Transaction = transaction3;
    // Table inexistante : l'étape bonus échoue
    bonus.CommandText = "UPDATE BANKTEST.BONUS SET AMOUNT = AMOUNT + 50 WHERE OWNER = 'Bob'";
    await bonus.ExecuteNonQueryAsync();
}
catch (NTiSqlException ex)
{
    // Annule UNIQUEMENT ce qui suit le savepoint : le virement reste acquis
    await transaction3.RollbackAsync("BEFORE_BONUS");
    Console.WriteLine($"Étape bonus annulée : {ex.Message}");
}

// Valide le virement (et le bonus s'il a réussi)
await transaction3.CommitAsync();

Release(name) / ReleaseAsync(name) libère un savepoint devenu inutile, sans rien annuler.


Annulation : une connexion cassée par contrat

Annuler un jeton (CancellationToken) pendant une opération NTi casse la connexion, par contrat : la trame protocolaire en cours est perdue, et l'opération lève une OperationCanceledException portant le jeton de l'appelant. La transaction non validée est annulée côté serveur, et la connexion doit être rouverte (avec le pool activé, OpenAsync fournit immédiatement une session saine).

Ne traitez donc jamais une annulation comme une erreur récupérable sur la même connexion : c'est une sortie de secours, pas un flux nominal.


Et maintenant ?

Reconnexion au serveur...

La connexion au serveur a été perdue. La page va se recharger.