Transactions and errors
Save related documents together, send notifications after commit, and handle failed hook operations.
Use hooks inside the transaction for database changes that must succeed together, such as
saving a post and its audit entry. Use AfterCommit for email or webhooks once that save
has succeeded, and AfterError to report a failed operation.
Choose the hook for the work
| Hook or option | Use it for | Failure behavior |
|---|---|---|
AfterChange |
Related Ridu writes that must commit with the document. | An error rolls the transaction back. |
AfterOperation |
Final database work for any operation before response processing and commit. | An error can still roll a write back. |
AfterCommit |
Email, webhooks, external search, and effects that require a committed document. | An error is reported but cannot undo the commit. |
Config.AfterCommit |
Central scheduling or instrumentation of after-commit effects. | The dispatcher owns when effect.Run executes; it is not automatically durable. |
| A registered task | Retryable background work with serializable input. | The selected store owns durable queue state and retries. |
AfterError |
Logging or observing the original operation failure. | Returning nil does not make the failed operation succeed. |
ctx.Local with ctx.Context |
Nested reads/writes through the normal engine and active transaction. | Access, validation, hooks, and cancellation still apply. |
Save related documents together
Use a collection or global hook’s ctx.Local to write a related document. Pass ctx.Context
so the related write shares the transaction: either both writes succeed or neither is saved.
The related operation still runs its own access rules, validation, and hooks.
This example adds an audit record after a post changes. Add AuditLog to Config.Collections,
and choose its access rules as you would for any other collection:
package content
import (
"github.com/riducms/ridu"
"github.com/riducms/ridu/field"
"github.com/riducms/ridu/store"
)
var AuditLog = ridu.Collection{
Slug: "audit-log",
Fields: field.Fields{
field.Text("document").Required(),
field.Text("operation").Required(),
},
}
func writeAuditEntry(ctx ridu.HookContext) error {
if ctx.Document == nil {
return nil
}
// Reuse the transaction so the post and audit entry save together.
_, err := ctx.Local.CreateWithOptions(
ctx.Context,
"audit-log",
store.Values{
"document": store.String(ctx.Document.ID),
"operation": store.String(string(ctx.Operation)),
},
// Local API calls do not inherit the user or locale.
ridu.MutationOptions{
Actor: ctx.Actor,
ActorCollection: ctx.ActorCollection,
Locale: ctx.Locale,
},
)
// An audit failure must also fail the post's save.
return err
}Register writeAuditEntry on the posts collection:
Hooks: ridu.CollectionHooks{
// Save the audit entry in the same transaction as the post.
AfterChange: []ridu.Hook{writeAuditEntry},
},After a successful save, there is an audit record with the post ID and operation. If the audit write is rejected, the post change is rolled back too. A failed nested operation marks the shared transaction as failed; swallowing its error does not make the outer save succeed.
The options pass the signed-in user, auth collection, and locale to the related write. See Hook context for how to carry those values through local API calls.
Keep related writes out of a standalone read’s hooks: that transaction is read-only. Reads made while preparing a write response may share a writable transaction, which is another reason to choose a write hook for this work.
Register this hook on Posts, not AuditLog: logging the audit collection’s own changes would create a loop. See Avoid repeated hook calls before writing back to a document from its hooks.
Send notifications after a successful save
Use AfterCommit for email, webhooks, and other work outside the database. AfterChange still
runs inside the transaction: a later failure could roll back the document after you had already
sent an email. AfterCommit runs only once that transaction has committed.
This helper sends a JSON webhook with the saved post ID and operation. It ignores reads and deletes and gives the HTTP request a five-second timeout:
package content
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/riducms/ridu"
"github.com/riducms/ridu/operation"
)
func notifyPosts(webhookURL string) ridu.Hook {
// Ridu waits for this hook by default, so limit the delivery delay.
client := &http.Client{Timeout: 5 * time.Second}
return func(ctx ridu.HookContext) error {
// AfterCommit runs on reads too; notify only on saves.
switch ctx.Operation {
case operation.Create, operation.Duplicate, operation.Update,
operation.Publish, operation.Unpublish:
default:
return nil
}
if ctx.Document == nil {
return nil
}
body, err := json.Marshal(struct {
ID string `json:"id"`
Operation operation.Kind `json:"operation"`
}{ID: ctx.Document.ID, Operation: ctx.Operation})
if err != nil {
return err
}
request, err := http.NewRequestWithContext(
ctx.Context, http.MethodPost, webhookURL, bytes.NewReader(body),
)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
// The post stays saved even if delivery fails from this point.
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("post webhook returned %s", response.Status)
}
return nil
}
}Configure POST_WEBHOOK_URL in your server environment, import os in the config file, and
register the helper on the posts collection:
Hooks: ridu.CollectionHooks{
// Start delivery only after the database commit succeeds.
AfterCommit: []ridu.Hook{notifyPosts(os.Getenv("POST_WEBHOOK_URL"))},
},Saving a post sends a request such as {"id":"post_123","operation":"update"}. Reading it sends
nothing. A timeout or non-2xx response returns an error from the hook, but the post remains saved.
What happens if the notification fails?
By default, Ridu waits for after-commit hooks before finishing the request. An error is reported
as hook_failed with Committed: true on the Go ridu.OperationError. Other queued
post-commit hooks are still attempted. The database change cannot be rolled back at this point.
Retry the notification, not the whole create request, which could create a second document.
For background delivery or retries, configure Config.AfterCommit with an
AfterCommitDispatcher. A dispatcher can arrange how
to run an effect after commit; it does not make an external service part of the database
transaction. Design retries so the receiver can recognize an event it has already processed.
There is also a possible gap between committing a document and successfully queuing external
work; plan how you will recover missed work when delivery matters.
Calls to ctx.Local from AfterCommit start new transactions. Updating the same document there
can trigger its hooks again, so keep the notification helper focused on delivery.
Log a failed operation
Use AfterError to log a failure or record a metric. It receives the original error in
ctx.Error. Returning nil does not make the failed request succeed, and returning another
error does not replace the original failure.
package content
import (
"log"
"github.com/riducms/ridu"
)
func logFailure(ctx ridu.HookContext) error {
log.Printf(
"Ridu %s failed (collection=%s global=%s): %v",
ctx.Operation, ctx.CollectionID, ctx.GlobalID, ctx.Error,
)
// Finish logging; the original failure still reaches the caller.
return nil
}Attach it to a collection or global with:
Hooks: ridu.CollectionHooks{
AfterError: []ridu.Hook{logFailure},
},For failures anywhere in the application, also add logFailure to
Config.Hooks.AfterError. If a collection or global is known, its error hooks run before the
application’s error hooks. Avoid attaching the same logger in both places if you want one entry
per failure.
For a top-level write that fails before commit, error hooks run after rollback. If an after-commit hook fails, the write is already committed. A failure in a nested local API operation can run error hooks while the outer transaction is marked for rollback. Keep this hook focused on logging; do not try to repair the failed write by starting more writes from it.
Return validation messages from a validator
A non-nil error from an ordinary hook stops the operation and normally becomes hook_failed.
Before commit, that failure rolls back the write and related transactional writes. Even an
AfterChange, AfterOperation, or AfterRead failure can still trigger a rollback.
Use custom validation to tell an author that a field value is invalid.
Its operation.Issue messages appear beside the appropriate inputs. Return an ordinary error
when your code could not finish its work, such as a failed related lookup or unavailable service.
Keep hooks predictable and fast
Read hooks can run for every document in a list, and a field hook can run for every array or block row. Avoid repeated external requests in those hooks. Compute a value when saving if it can be stored. When several fields need the same lookup, fetch once and update those fields together in a collection hook.
Use ctx.Context for local API and network calls so cancellation and deadlines propagate. Keep
long-running work after commit, and make any retried delivery safe to run more than once. Test
both successful saves and failures: check the saved values, the response, related records, and
whether a notification ran after a rollback.
Keep per-row and per-block callbacks local to their current value; scanning the complete list from
every embedded occurrence turns N callbacks into roughly N² reads. Return operation.Keep when a
field does not change, and use immutable Get, Lookup, Entries, and Elements reads rather
than copying containers just to inspect them.
The hook and value performance guide explains embedded callback scaling, explicit mutable copies, operation-local reuse, and when to move work into one resource hook.
See ridu.CollectionHooks for all available hooks and
ridu.Hook for the function signature.