dbx

Tranzakciók

Kereszt-repository tranzakciók a contextben, plumbing nélkül.

A dbx mögötti kemény követelmény: egy service-nek képesnek kell lennie több repositoryt hívni egyetlen adatbázis-tranzakcióban, anélkül hogy bármelyik repository tudna a tranzakciókról. Ez az oldal a mintát tárgyalja mélységében: a mechanika pgx-specifikus részletei a pgx oldalon, a bun-változat a bun oldalon.

A három építőelem

// 1. A kontrakt, amit a service fog (dbx csomag, driver-független):
type Transactor interface {
    WithinTransaction(ctx context.Context, fn func(ctx context.Context) error) error
}

// 2. A query-felület, amit a repositoryk fognak (itt a pgx-változat):
type DBTX interface {
    Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
    Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
    QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}

// 3. Az executor, ami a kettőt összeköti: minden hívás előbb tranzakciót
//    keres a contextben; ha nincs, a poolon fut.
db := pg.NewDB(pool)
tx := pg.NewTransactor(pool)

A WithinTransaction tranzakciót nyit, a contextbe injektálja, és a módosított contexttel hívja az fn-t. Az fn-en belül minden repository-hívás ugyanazt a contextet kapja tovább. A pg.DB (vagy bunon a bunx.From/bunx.Conn resolver) kihalássza belőle a tranzakciót, és azon futtatja a query-t. A repositoryk szignatúrája nem változik; a tranzakció létezéséről csak az executor tud.

A PlaceOrder service

A minta legjobb példája a shop rendelésfeladása. Három írásnak kell atomikusan megtörténnie: a rendelés beszúrásának, a készlet csökkentésének és az orderPlaced event outbox-sorának. Ha bármelyik hibázik, egyiknek sem szabad megmaradnia: nem létezhet rendelés levont készlet nélkül, és nem mehet ki visszaigazoló e-mail egy soha nem rögzített rendelésről.

A teljes metódus a modul közös core/ csomagjában él (internal/modules/shop/core/), az api surface service-varrata innen hívja:

type OrderService struct {
    orders     *repository.OrderRepository
    products   *repository.ProductRepository
    tx         dbx.Transactor
    dispatcher events.Dispatcher
}

func (s *OrderService) PlaceOrder(ctx context.Context, in PlaceOrderInput) (*Order, error) {
    var order *Order
    err := s.tx.WithinTransaction(ctx, func(ctx context.Context) error {
        var err error
        order, err = s.orders.Insert(ctx, in)                 // 1. rendelés
        if err != nil {
            return err
        }
        if err := s.products.DecrementStock(ctx, in.ProductID, in.Qty); err != nil {
            return err                                        // 2. készlet
        }
        return s.dispatcher.Dispatch(ctx, shopevents.OrderPlaced{ // 3. outbox-sor
            OrderID: order.ID,
            Email:   in.Email,
        })
    })
    if err != nil {
        return nil, err
    }
    return order, nil
}

(A shopevents a modul saját event-csomagjának aliasa: internal/modules/shop/events, az add event generálja.) Mind a három hívás ugyanazt a pgx.Tx-et oldja fel a contextből:

  • az orders.Insert és a products.DecrementStock a pg.DB-n keresztül;
  • a dispatcher egy outbox.NewDispatcher, amelynek store-ja szintén a pg.DB-n ír. Az event ugyanabban a tranzakcióban kerül az outbox_events táblába, mint az üzleti írások. Commitkor együtt válnak láthatóvá; rollbackkor az event sem létezik. Ez a tranzakciós outbox lényege.

Ha a DecrementStock insufficient_stock hibával tér vissza, a már lefutott Insert is visszagördül, és az event sosem kerül kézbesítésre. A hiba az errs láncon át problem+json válasszá képződik le.

Szemantika

A garanciák, amiket a dbx saját integrációs tesztjei rögzítenek (go test -tags=integration ./dbx/pg/ és ./dbx/bunx/, valós PostgreSQL ellen):

  • Commit csak akkor, ha az fn nil-t ad vissza. Bármilyen hiba rollback, a hiba változatlanul propagál a hívó felé.
  • Panic rollbackel és újradobódik. Egy handler-pánik nem hagyhat félbe tranzakciót.
  • A rollback a lemondott contextet is túléli: a cleanup context.WithoutCancel-lel fut, így egy kérés-timeout nem akadályozza meg a tranzakció rendezett visszagörgetését.
  • A beágyazott WithinTransaction csatlakozik a külső tranzakcióhoz: lapos nesting, savepoint nélkül. A commitot/rollbacket mindig a legkülső hívás birtokolja: ha a belső blokk sikeres, de a külső utána hibázik, a belső írások is visszagördülnek.

A lapos nesting gyakorlati következménye: egy service-metódus szabadon hívhat másik service-metódust, ami maga is WithinTransaction-t használ, a belső hívás észrevétlenül a külső tranzakció része lesz. Nem kell „tranzakción belül vagyunk-e már?" logikát írni.

Savepoint nincs: a belső blokk hibája a teljes tranzakciót görgeti vissza, nem csak a belső írásokat. Ha részleges visszagörgetésre van szükséged, az külön tranzakció: futtasd a WithinTransaction-ön kívül.

Miért a context, és nem paraméter?

A klasszikus alternatíva a tranzakció kézi továbbadása: minden repository-metódus kap egy tx-paramétert, vagy két változatban létezik (Insert / InsertTx). Ez három helyen fáj:

  1. Minden szignatúra megfertőződik. A ListProducts(ctx, params) helyett ListProducts(ctx, tx, params), akkor is, ha az adott hívásnak sosem lesz tranzakciója.
  2. A rétegek összecsúsznak. A service-nek driver-típust (pgx.Tx) kellene importálnia és továbbadnia, pedig a service-réteg dolga az üzleti logika, nem a kapcsolat-menedzsment.
  3. A keresztvágó kód kimarad. Az outbox dispatcher a kit kódja: nem tudhat a te repositoryjaid tranzakció-paraméteréről. A contextből viszont ugyanúgy kiolvassa a tranzakciót, mint a saját repóid.

A context-hordozás pontosan az a fajta implicit állapot, amire a context.Context való: kérés-szkópú, a hívási láncon lefelé utazó, keresztvágó adat. A Go standard library ugyanígy hordozza a trace-eket, a deadline-okat és a cancel-jelet.

Implementáció-függetlenség

A fenti service-kód egyetlen sora sem pgx-specifikus: a dbx.Transactor-tól és a saját repository-interfészeitől függ. Egy --db bun projektben ugyanez a metódus változatlanul fordul és fut. A main.go bunx.NewTransactor(bunDB)-t injektál, a repositoryk belseje pedig a bun resolvereket használja. A tranzakció-határ kijelölése a service-rétegé; hogy alatta mi hordozza a tranzakciót, az bekötési részlet.

Egy Transactor kapcsolatonként

Egy Transactor mindig egyetlen kapcsolatra érvényes: a WithinTransaction az általa becsomagolt poolon nyit tranzakciót, és a saját implementáció-specifikus kulcsa alatt injektálja a contextbe. Egy nevesített kapcsolathoz kötött repository más Transactor-t kap (a Dependencies <Név>Transactor mezőjét), mint a default: ha a default Transactor.WithinTransaction-jét egy nevesített kapcsolat repository-hívása köré teszed, az nem kerül ugyanabba a tranzakcióba; a saját kapcsolatán fut, összehangolás nélkül. Ez az is az oka, hogy egy kapcsolat connectora (pgx vagy bun) hívásonként nem változhat: a pgx.Tx és a bun.Tx két különböző típus, két különböző context-kulcs alatt, ezek keverése egy kapcsolaton belül épp azt tenné tönkre, amiért a Transactor létezik. Ha két repositorynak együtt kell commit-olnia vagy rollbackelnie, mindkettőnek ugyanazon kapcsolat Transactor-ja mögött kell állnia.

Használt patternek

Ez az oldal a Design patternek katalógus Unit of Work / tranzakció a contextben bejegyzésének teljes kifejtése: a dbx.Transactor interfész és a panic-safe rollback-logika ott, kódrészlettel.

Copyright © 2026