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 SCHEMAcrée d'office un journalQSQJRNdans 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, etSTRJRNPFdoit ê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 ?
- Exécuter une commande CL : exécuter une commande CL et gérer les erreurs
- Appeler un programme : appel de programme RPG avec paramètres d'entrée/sortie
- Procédure stockée : appel de procédure stockée SQL avec Dapper et DataReader