Transactions and Side Effects¶
Hubuum exposes a backend-neutral unit of work for application workflows that need to compose resource operations atomically. It does not expose a database connection, a Diesel transaction, or a general query interface.
This is a mandatory part of every selectable StorageBackend.
The normative six-part guarantee is defined in the
storage contract; this guide expands its transaction and event
semantics.
The Shape¶
TransactionStorage::with_transaction(EventContext, callback)
|
v
opaque StorageTransaction
/ / | \ \
collections() classes() objects() class_relations() object_relations()
\ \ | / /
v
one backend-native transaction
|
commit `Ok` / roll back `Err`
The accessors return crate-owned operation types with inherent methods. This
makes operations discoverable in an editor without creating a mirrored set of
TxCollectionStorage, TxClassStorage, and similar traits.
A callback must compose through the transaction passed to it. Starting another unit of work through the outer storage handle creates an independent native transaction and is unsupported; with a bounded pool it can also deadlock while waiting for a second connection.
The ordinary lifecycle traits remain the implementation contract underneath those types. An adapter therefore implements each resource semantic once and provides a connection-scoped execution path for its transaction object.
When to Compose and When to Add an Operation¶
Use a unit of work when application logic needs to combine already-safe resource primitives and the combination has no hidden backend invariant. For example, creating two objects and a relation between them is a natural composition.
Add or retain one operation-shaped trait method when the backend must own a state machine, lock protocol, specialized query, or consistency rule. Examples include:
- task claims, completion artifacts, and lease-checked transitions;
- restore application and recovery;
- strict and best-effort import execution;
- computed rebuilds guarded by a live task claim;
- permission mutation with owner-revision advancement; and
- event delivery, retention, and worker coordination.
The decision is semantic, not based on the number of SQL statements.
Can the use case be expressed entirely with existing safe resource operations?
| |
yes no
| |
use StorageTransaction add an operation-shaped capability
Does the backend need to keep a hidden invariant across the whole operation?
| |
no yes
| |
composition remains valid keep the invariant in one backend call
This keeps the trait boundary from growing for every orchestration while preventing application code from rebuilding backend state machines.
Audit and Event Guarantee¶
TransactionStorage::with_transaction requires one EventContext. Callers do
not pass an optional event context to individual transactional mutations.
TransactionalCollections, TransactionalClasses,
TransactionalClassRelations, TransactionalObjects, and
TransactionalObjectRelations forward the required context automatically.
For every successful transactional mutation:
- The adapter performs the state mutation.
- The adapter constructs the summary, snapshots, metadata, and schema version
through the backend-neutral
AuditDocumentAPI. - The adapter appends the mutation's durable audit event using the inherited context.
- Both changes are part of the same native transaction.
- The backend publishes follow-on transactional notifications, if any, only as part of that commit.
If the callback returns Err, state, audit events, and transactional
notifications roll back together. A backend that commits the state but loses
the audit event does not satisfy the contract.
External transport is not executed inside this transaction. After commit, the fan-out capability creates durable delivery rows, and delivery workers call the configured sinks with at-least-once semantics. Notifications reduce latency; polling the durable event and delivery state remains the recovery path.
The lower-level lifecycle traits also require an EventContext; there is no
optional-context escape hatch in the ordinary storage contract. A committed
resource mutation returns StorageMutationOutcome::Committed with an StorageAuditReceipt
identifying the durable event written in the same atomic operation. A genuine
no-op returns StorageMutationOutcome::Unchanged and appends no event.
Fixture compatibility helpers use explicit system attribution. Import and restore behavior belongs to explicit workflow capabilities because those operations preserve or reconstruct durable history rather than masquerading as ordinary user mutations.
Context-Free Mutation Inventory¶
The following review-maintained list is intended to cover every
StorageBackend method that may durably write without accepting an
EventContext. They are deliberately outside the ordinary audited-mutation
category. Every contract method now has exactly one effect classification, and
the guard requires this inventory to equal the union of observation and
workflow_mutation methods. Reviewers must classify and test a new
context-free writer, add it to the appropriate category here and in
semantic-coverage.toml, or the architecture suite fails. Source inspection
does not infer write effects, so choosing the correct effect remains a review
responsibility. An ordinary resource or configuration mutation is not eligible
for an exception.
- Best-effort observation:
AuthenticationStorage::authenticate_bearer_tokenmay throttle-write token use time. This does not change authorization or lifecycle state, and a valid authentication does not fail solely because the timestamp could not be refreshed. - Bootstrap and provider reconciliation:
LocalIdentityCredentialStorage::bootstrap_default_adminruns only while no actor can yet exist;IdentityScopeStorage::ensure_identity_scopeis idempotent provider configuration reconciliation;ExternalIdentityStorage::mark_external_sync_attemptedis synchronization telemetry; andExternalIdentityStorage::sync_external_useris an identity-provider reconciliation operation that emits a system-attributed audit event and returns itsStorageMutationOutcome. - Workflow state machines:
ComputedFieldStorage::request_computed_field_rebuild,ComputedFieldStorage::execute_computed_field_rebuild, andTaskQueueStorage::create_taskcreate or advance durable work whose task row, submitter, claim, and task-event stream carry its provenance.TaskExecutionStorage::request_task_cancellationrecords the authorized actor from its typed request and serializes intent with claim/finalization. Worker-only transitions areTaskExecutionStorage::admit_task_execution,TaskExecutionStorage::acknowledge_task_stop,TaskExecutionStorage::begin_remote_dispatch,TaskExecutionStorage::claim_next_task,TaskExecutionStorage::renew_task_lease,TaskExecutionStorage::recover_expired_task_leases,TaskExecutionStorage::append_task_event,TaskExecutionStorage::update_task_state,TaskExecutionStorage::complete_task,TaskExecutionStorage::fail_task,TaskExecutionStorage::purge_expired_export_outputs, andTaskExecutionStorage::purge_expired_backup_outputs. - Event coordination:
EventDeliveryAdministrationStorage::release_event_delivery_for_retryandEventDeliveryAdministrationStorage::mark_event_delivery_deadare explicit administrator interventions on derived delivery state. Worker protocols useEventDeliveryWorkerStorage::claim_event_delivery_batch,EventDeliveryWorkerStorage::begin_event_delivery,EventDeliveryWorkerStorage::finish_event_delivery,EventDeliveryWorkerStorage::mark_event_delivery_succeeded,EventDeliveryWorkerStorage::mark_event_delivery_failed,EventFanoutStorage::process_event_fanout_batch,EventRetentionStorage::claim_event_retention_batch, andEventRetentionStorage::complete_event_retention_batch. Claims and task or delivery state provide the concurrency evidence; none of these methods is an ordinary domain lifecycle shortcut. - Retention:
TokenRetentionStorage::purge_expired_tokensapplies configured expiry and emits the token-purge semantics owned by the retention worker. - Maintenance:
RestoreStorage::stage_restore,RestoreStorage::expire_restore_stage,RestoreStorage::start_restore_draining,RestoreStorage::apply_restore,RestoreStorage::fail_restore_and_resume,RestoreStorage::resume_maintenance_without_restore,RestoreStorage::resume_terminal_restore,RestoreStorage::tick_restore_coordinator, andRestoreStorage::remove_restore_instanceform the capability-authenticated restore and coordinator protocol.ImportStorage::apply_import_strict,ImportStorage::apply_import_best_effort,ImportStorage::apply_claimed_import_strict,ImportStorage::apply_claimed_import_best_effort,ImportStorage::record_import_results, andImportStorage::record_claimed_import_resultspreserve or reconstruct imported state and results under the typed import workflow.
BackupSnapshotStorage::capture_backup_snapshot is not listed because it only
reads a consistent projection. ImportStorage::preflight_import executes
against rollback-only state and does not durably write. ExecutionStorage
scope changes are native execution context, not durable storage mutations.
Personal and shared computed-field definition lifecycle, administrator local
password reset, and every other ordinary mutation require EventContext and
return StorageMutationOutcome.
Example¶
use hubuum_events_core::EventContext;
use hubuum_storage_core::{
StorageObjectCreate, StorageObjectRelationCreate,
StorageObjectRelationCreateSelector, TransactionStorage,
};
storage
.with_transaction(EventContext::system(), |transaction| {
Box::pin(async move {
let left = transaction
.objects()
.create(&left_class, left_command)
.await?
.into_value();
let right = transaction
.objects()
.create(&right_class, right_command)
.await?
.into_value();
let prepared = transaction
.object_relations()
.prepare(StorageObjectRelationCreateSelector::Explicit(
StorageObjectRelationCreate::new(
left.id(),
right.id(),
class_relation_id,
),
))
.await?;
transaction.object_relations().create(&prepared).await
})
})
.await?;
The boxed callback future is the only allocation introduced by the contract. It makes the callback lifetime expressible on the workspace MSRV while keeping the result strongly typed. Individual operations remain ordinary static async trait calls.
Backend Requirements¶
A complete adapter must:
- implement
TransactionStorageand every trait aggregated byStorageBackend; - provide one native atomic unit for the complete callback;
- implement every exposed transaction accessor;
- reuse the same validation, resolution, revision, relation-cardinality, and event semantics as calls outside a transaction;
- serialize access when its native transaction cannot execute concurrent operations safely;
- commit only when the callback returns
Ok; - roll back state and side effects when it returns
Error native commit fails; and - log native diagnostic context inside the adapter and convert the failure once
into a safe
StorageErrorat the adapter boundary.
An adapter may use a connection, session, batch, compare-and-swap log, or other native mechanism internally. Those types never cross the contract.
Cancellation¶
Dropping the future requests cancellation. An adapter must not report a successful commit after the callback returned an error. If its native system cannot cancel work immediately, it must still preserve the externally visible commit-or-rollback contract and document any background completion behavior.
The PostgreSQL adapter holds one checked-out connection for the unit of work. Dropping or failing the native transaction rolls it back through the adapter's runtime transaction mechanism.
Observability and Performance¶
The unit of work is one logical storage entrypoint with the bounded labels
transaction and with_transaction. The PostgreSQL runtime separately
measures pool checkout and native transaction duration. Constituent calls do
not create a second set of logical entrypoint metrics; query-level diagnostics
remain an adapter concern.
The transaction reuses one native connection. It does not add a database
round trip to each constituent operation. Compared with calling several
independent audited operations, it removes repeated pool checkouts and nested
BEGIN/COMMIT pairs. The contract adds one boxed callback future and a small
in-process serialization guard per constituent call.
Benchmarks and query-budget tests must protect both single-operation latency and composed transaction behavior. A backend should optimize its private execution without changing the contract.
Compatibility Evidence¶
The shared transaction scenario runs against PostgreSQL and the deterministic memory model. It:
- commits a collection, two classes, a class relation, two objects, and an object relation through one callback;
- verifies committed state through ordinary capability reads and confirms one durable audit trail for every resource operation;
- repeats all five mutation operations and returns an application error; and
- verifies that neither state nor any operation-specific audit event survives.
Backend-native tests remain responsible for isolation, connection loss, driver cancellation, commit failure, notification visibility, and native locking behavior.
Schema evolution¶
SchemaEvolutionStorage::save_schema_repair_report is a context-free artifact
write. It replaces the latest complete rendering for an existing impact task,
rechecks its class collection, and changes no source checkpoint, object, schema,
or domain audit history. Its paired read returns only the saved artifact; API
consumers must enforce the source analysis authorization on every access.
SchemaEvolutionStorage belongs to the workflow family. Staging, abandonment,
activation and work requests return durable audit receipts. The context-free
SchemaEvolutionStorage::process_schema_work operation uses its live task lease and captured initiator
to commit each bounded batch's evidence, new findings, checkpoint and audit/outbox together.
Expired schema-validation tasks resume that checkpoint; other task kinds keep
their existing recovery policy. Cancellation fences subsequent batch commits.
Impact compares both policies against each captured object revision and commits
its comparison counts with the bounded checkpoint while appending the batch's
redacted findings separately. record_impact returns each finding to persist;
adapters must commit it in the same fenced transaction as the updated checkpoint.
get_schema_work_report assembles the full groups from a consistent snapshot;
workers and activation proofs use get_schema_work without reading past findings.
Impact does not replace active evidence. Strict activation rechecks the captured baseline
and population epoch while holding the class boundary.