Skip to content
Talk to our solutions team

Materialized views and refresh

Two unrelated things in the Data API carry the word materialized, and only one of them has a refresh loop.

DeclarationLevelWhat it meansWired
materialization:entityPhysical shape of the entity — table, view, contract, virtual, remotetable and remote only
compute-strategy: materializedfieldThe derived value is stored in a real column instead of computed on readyes
compute.triggers:fieldMutations on another entity queue a recompute of that columnhook and drainer run; the enqueue insert is rejected on PostgreSQL
views:schemaA SQL view, optionally materialized, with a refresh scheduleno

The path that works end to end is the same-entity materialized computed field. Its cross-entity outbox is wired from the enqueue hook through to the admin endpoints, but the insert that would put a task in the queue fails on PostgreSQL — see What fires a refresh. The views: block and the non-table materialization modes are declarations the migration layer reads and then does nothing with.

materialization: is declared per entity and is documented in full on Entities. What matters here is what each value does to the physical schema.

ValueEffect on DDLEffect at runtime
tableA real table, created and diffed.Normal SQL execution.
viewExcluded from the migration plan. Nothing issues CREATE VIEW.Normal SQL execution against a relation nothing created.
contractExcluded from the migration plan.Normal SQL execution.
virtualExcluded from the migration plan.Normal SQL execution.
remoteExcluded from the migration plan.Every operation is proxied to the external service in remote:.

view, contract and virtual are the same behaviour under three names: the planner skips the entity, so no table is created, altered or dropped for it. Nothing else in the engine branches on them — reads and writes still compile to SQL against a relation of that name, which you have to create yourself.

Declaring a schema-level views: block parses cleanly, and the dialects carry view-creation, materialized-view and refresh methods — SQLite’s materialized-view method returns an error. No caller invokes any of them. There is no view DDL, no refresh schedule, and no scheduler; a refresh.schedule: cron expression is stored and never read.

A field with compute-strategy: materialized is stored. The strategy is the default for computed fields — see Fields for the declaration syntax, the script languages, and the virtual alternative. Two independent paths keep the column current:

PathWhen it runsMechanism
Same entityEvery create and update of the owning rowThe materialized hook at BeforeExec, priority 290. The value is written in the same statement.
Cross entityA mutation on a source entity named in compute.triggersAn outbox row per affected target, drained asynchronously.

The same-entity path is synchronous and needs no triggers. Everything below concerns the cross-entity path.

A tenant whose schema declares no compute.triggers anywhere gets no outbox machinery at all: no enqueue hook, no drainer, and the admin endpoints below answer 500.

The enqueue hook runs at AfterExec, priority 650 — after the source mutation has executed and after the event hook, so an event listener sees the source write before the refresh tasks land. For each trigger that names the mutated entity it applies these gates in order:

GateRule
onThe operation must be listed. An empty list matches create, update and delete.
when_columnsUpdate only. Skip when none of the listed columns differ between pre-image and post-image. Create and delete ignore it.
whenScript predicate. Create evaluates the post-image, delete the pre-image, update evaluates both and enqueues if either matches. An evaluation error skips the trigger and records materialized_refresh:when_error:<target entity>.<field> on the hook context, which the response never carries.
affected-row resolutionThe trigger’s affected_via column is read out of the source row to produce the target row id.

Resolution is image-dependent: create reads the post-image, delete the pre-image, and update reads both — so an update that reassigns the foreign key enqueues two tasks, one for the old parent and one for the new. When the two ids are equal, one task is enqueued. A source row whose key column is missing or null yields no task.

Enqueue is best-effort and is not part of the source transaction. If the insert into the outbox fails, the source mutation still succeeds and the error is recorded on the request’s hook context under materialized_refresh:enqueue_error, where a pointcut script can read it through ctx.metadata. The response does not carry it. Nothing re-derives the lost task — the stored value stays stale until another qualifying mutation enqueues a new one.

Refresh tasks live in the engine-managed system table data_materialized_refresh_tasks, created by bootstrap alongside the other data_ tables.

ColumnContents
idTask id, primary key.
target_entityEntity owning the materialized field.
target_fieldField to recompute.
target_row_idStringified primary-key value of the row to refresh.
source_entityEntity whose mutation triggered the task.
source_opcreate, update or delete.
statuspending, claimed, done or failed.
enqueued_atWhen the hook wrote the row.
claimed_byWorker that claimed it. Null while pending.
claimed_atWhen the claim happened. Null while pending.
done_atWhen status became done or failed.
attemptsIncremented on every claim.
last_errorMessage from the most recent failure. Empty otherwise.

Plus the four audit columns. Three indexes ship: (status, enqueued_at) for the drain, (target_entity, target_field, target_row_id) for coalescing, and (source_entity, status) for triage.

State machine:

pending ──claim──> claimed ──ok────> done
└──error────> failed

failed is terminal — see Failure and retry.

One drainer runs per (tenant, datastore) inside the service process, spawned when the engine for that pair is built and stopped when it is dropped.

SettingDefault
Batch size50 tasks per pass
Tick interval30s
Max attempts5
Worker id<service>:<tenant>

A pass runs on any of four triggers: an enqueue notification (immediate, coalesced one deep, so a burst of enqueues collapses into a single pass), the periodic tick, an explicit force-drain, and one best-effort final pass on shutdown.

Each pass claims, then processes:

  1. Claim. Select pending tasks with attempts below the maximum, ordered oldest first, over-fetching four times the batch size. Coalesce to at most one task per (target_entity, target_field, target_row_id) triple, keeping the oldest and marking the superseded duplicates done in one statement. Claim the keepers with a status-conditional update that also increments attempts, so a race with another worker degrades to fewer claims rather than double processing.
  2. Process. Tasks in the batch are processed concurrently. For each: resolve the target field, load the whole target row by primary key, evaluate the compute script, and write the result back with a single-column update. A target row that no longer exists marks the task done, not failed. When the new value compares equal to the current one, the update is skipped.

Script bindings on the refresh path are the row as both self and row, plus tenant, entity and field, plus every column of the target row hoisted to the top level. They differ from the write-time bindings on the same-entity path, which carry user_id and the payload rather than the stored row.

The outbox store emits $N-placeholder SQL and is installed for any datastore whose pool exposes a database/sql handle — there is no backend gate, and PostgreSQL is the case the SQL is written for. On PostgreSQL the store qualifies both the tasks table and the target entity tables with the tenant namespace — that qualification is the tenant isolation for this path, because it takes a raw pool handle rather than a tenant-scoped checkout.

OutcomeTask statusValue
Script evaluates, value changeddoneWritten
Script evaluates, value unchangeddoneUntouched
Target row deleted since enqueuedonen/a
Target field is no longer a materialized computed fieldfailedStale
Script error, row load error, or update errorfailedStale

Completed and failed rows are never pruned. Trim them on your own schedule:

DELETE FROM data_materialized_refresh_tasks
WHERE status = 'done'
AND done_at < NOW() - INTERVAL '7 days';

A queue-depth dashboard that counts all rows rather than status = 'pending' will therefore grow without bound and tell you nothing.

The admin surface mounts at /admin/data/*; behind the gateway it sits under /data/. Every route takes an optional ?datastore=<name> and requires a bearer token plus the admin policy gate for its class.

RouteClassPurpose
GET /admin/data/materialize/pendingreadPending and failed tasks, 100 per page, ?after=<task id>
GET /admin/data/materialize/statsreadQueue depth by status plus the drainer’s counters
POST /admin/data/materialize/drainmaintenanceRun one drain pass now
{
"tenant": "acme:shop:dev:main",
"datastore": "primary",
"counts": { "Pending": 12, "Claimed": 0, "Done": 8134, "Failed": 3 },
"runner": { "Claimed": 8149, "Done": 8146, "Failed": 3 }
}

The runner block is omitted when no drainer exists for the tenant, and its counters are cumulative since process start — they reset on restart. The durable view is the table. drain answers with tenant, datastore and dispatched, the last counting tasks dispatched regardless of outcome.

All three return 403 with {"error": "tenant key missing from request context"} when the request carries no tenant identity, and 500 when the tenant has no refresh machinery — its schema declares no cross-entity triggers, so there is nothing to report on.

The same three operations are on the service CLI:

Terminal window
data.svc data materialize pending --tenant acme:shop:dev:main -f bootstrap.yaml
data.svc data materialize stats --tenant acme:shop:dev:main -f bootstrap.yaml
data.svc data materialize drain --tenant acme:shop:dev:main -f bootstrap.yaml

Add --datastore <name> to target a datastore other than the default and --output json for machine-readable output. drain before an assertion that depends on a materialized value — after a bulk import, for example — rather than waiting out the 30-second tick.