fix(09): WR-19 expose the write transaction to hooks and scopes through TxFromContext

This commit is contained in:
Jakub Zych
2026-10-01 21:37:13 +02:00
parent bbdc7a6b7e
commit 4ae272e3cc
6 changed files with 80 additions and 0 deletions

View File

@@ -26,6 +26,9 @@ type hookController struct {
fail string
queries atomic.Int32
allocs atomic.Int32
// txHooks counts the Before hooks that found the write's transaction on
// their context through TxFromContext.
txHooks atomic.Int32
}
func (c *hookController) ID() string { return "acme.demo.records" }
@@ -58,6 +61,7 @@ func (c *hookController) FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm
}
func (c *hookController) FormBeforeCreate(ctx context.Context, model any) error {
recordHook(ctx, "form_before_create")
c.noteTx(ctx)
if c.trackScope {
if row, ok := model.(*crudRow); ok {
if scope, ok := ctx.Value(scopeKey{}).(uint); ok {
@@ -73,6 +77,7 @@ func (c *hookController) FormAfterCreate(ctx context.Context, model any) error {
}
func (c *hookController) FormBeforeUpdate(ctx context.Context, model any) error {
recordHook(ctx, "form_before_update")
c.noteTx(ctx)
return c.hookErr("form_before_update")
}
func (c *hookController) FormAfterUpdate(ctx context.Context, model any) error {
@@ -81,12 +86,18 @@ func (c *hookController) FormAfterUpdate(ctx context.Context, model any) error {
}
func (c *hookController) FormBeforeDelete(ctx context.Context, model any) error {
recordHook(ctx, "form_before_delete")
c.noteTx(ctx)
return c.hookErr("form_before_delete")
}
func (c *hookController) FormAfterDelete(ctx context.Context, model any) error {
recordHook(ctx, "form_after_delete")
return c.hookErr("form_after_delete")
}
func (c *hookController) noteTx(ctx context.Context) {
if _, ok := TxFromContext(ctx); ok {
c.txHooks.Add(1)
}
}
func (c *hookController) hookErr(name string) error {
if c != nil && c.fail == name {
return errHookBoom
@@ -558,3 +569,31 @@ func TestCRUDOperationsFollowDeclarations(t *testing.T) {
t.Fatalf("declared bulk delete=%d %s", got.Code, got.Body.String())
}
}
// TestHooksReceiveTheWriteTransaction pins WR-19: lifecycle hooks can reach the
// transaction their write runs in through TxFromContext, so a hook's own reads
// share the write's snapshot and connection instead of using the app pool.
func TestHooksReceiveTheWriteTransaction(t *testing.T) {
_, httpSvc, _, _, hooks := hookFixture(t)
ctx := principalCtx(hooks, superUser())
created := crudCall(httpSvc, http.MethodPost, "", []byte(`{"name":"Ada"}`), ctx)
if created.Code != http.StatusCreated {
t.Fatalf("create=%d %s", created.Code, created.Body.String())
}
id := uintString(decodeData(t, created.Body.Bytes())["id"])
if updated := crudCall(httpSvc, http.MethodPut, id, []byte(`{"name":"Bea"}`), ctx); updated.Code != http.StatusOK {
t.Fatalf("update=%d %s", updated.Code, updated.Body.String())
}
if deleted := crudCall(httpSvc, http.MethodDelete, id, nil, ctx); deleted.Code != http.StatusOK {
t.Fatalf("delete=%d %s", deleted.Code, deleted.Body.String())
}
if got := hooks.txHooks.Load(); got != 3 {
t.Fatalf("Before hooks that saw the transaction = %d, want 3 (create, update, delete)", got)
}
if _, ok := TxFromContext(context.Background()); ok {
t.Fatal("a bare context reported a transaction")
}
if _, ok := TxFromContext(nil); ok { //nolint:staticcheck // a nil context must not panic
t.Fatal("a nil context reported a transaction")
}
}