Skip to content

Database triggers

Several Genie capabilities are enforced in the database, not the app — because a row can be mutated through many non-converging paths (form submit, row delete, LINQ, import, raw SQL) and the database is the only place that sees them all. Each capability installs a per-entity trigger, created only when the entity opts into it. This page maps the four trigger kinds, how they’re created, how they avoid stepping on each other, and what they cost.

Each declaration on an entity independently creates its own trigger, wired through the same migration pipeline. An entity carries 0 to 4 triggers depending on what it declares.

<!-- middle band: the creation pipeline -->
<g filter="url(#trg-sh)"><rect x="358" y="20" width="150" height="260" rx="16" fill="currentColor" fill-opacity="0.04" stroke="currentColor" stroke-opacity="0.25" /></g>
<text x="433" y="140" text-anchor="middle" font-size="12.5" font-weight="700" fill="currentColor">EntityMigration</text>
<text x="433" y="158" text-anchor="middle" font-size="12.5" font-weight="700" fill="currentColor">Strategy</text>
<text x="433" y="178" text-anchor="middle" font-size="10.5" fill="currentColor" opacity="0.7">Ensure*TriggerAsync</text>
<g font-size="12" text-anchor="middle">
<!-- lane 1: Sequence -->
<g filter="url(#trg-sh)"><rect x="20" y="30" width="220" height="44" rx="12" fill="url(#trg-amber)" stroke="#f59e0b" stroke-opacity="0.4" /></g>
<rect x="20" y="30" width="220" height="4" rx="2" fill="#f59e0b" /><text x="130" y="57" fill="currentColor">&lt;Sequence&gt; field</text>
<g filter="url(#trg-sh)"><rect x="620" y="30" width="220" height="44" rx="12" fill="url(#trg-amber)" stroke="#f59e0b" stroke-opacity="0.4" /></g>
<rect x="620" y="30" width="220" height="4" rx="2" fill="#f59e0b" /><text x="730" y="57" fill="#f59e0b" font-weight="700" font-family="ui-monospace, monospace" font-size="11">trg_…_GenerateSequence</text>
<!-- lane 2: Syncable (Search or Warehousing) -->
<g filter="url(#trg-sh)"><rect x="20" y="98" width="220" height="44" rx="12" fill="url(#trg-indigo)" stroke="#6366f1" stroke-opacity="0.4" /></g>
<rect x="20" y="98" width="220" height="4" rx="2" fill="#6366f1" /><text x="130" y="125" fill="currentColor">&lt;Search&gt; or Warehousing</text>
<g filter="url(#trg-sh)"><rect x="620" y="98" width="220" height="44" rx="12" fill="url(#trg-indigo)" stroke="#6366f1" stroke-opacity="0.4" /></g>
<rect x="620" y="98" width="220" height="4" rx="2" fill="#6366f1" /><text x="730" y="125" fill="#6366f1" font-weight="700" font-family="ui-monospace, monospace" font-size="11">trg_…_Syncable</text>
<!-- lane 3: Logging -->
<g filter="url(#trg-sh)"><rect x="20" y="166" width="220" height="44" rx="12" fill="url(#trg-violet)" stroke="#8b5cf6" stroke-opacity="0.4" /></g>
<rect x="20" y="166" width="220" height="4" rx="2" fill="#8b5cf6" /><text x="130" y="193" fill="currentColor" font-family="ui-monospace, monospace" font-size="11.5">Logging="Enabled"</text>
<g filter="url(#trg-sh)"><rect x="620" y="166" width="220" height="44" rx="12" fill="url(#trg-violet)" stroke="#8b5cf6" stroke-opacity="0.4" /></g>
<rect x="620" y="166" width="220" height="4" rx="2" fill="#8b5cf6" /><text x="730" y="193" fill="#8b5cf6" font-weight="700" font-family="ui-monospace, monospace" font-size="11">trg_…_AuditLog</text>
<!-- lane 4: Concurrency -->
<g filter="url(#trg-sh)"><rect x="20" y="234" width="220" height="44" rx="12" fill="url(#trg-emerald)" stroke="#10b981" stroke-opacity="0.4" /></g>
<rect x="20" y="234" width="220" height="4" rx="2" fill="#10b981" /><text x="130" y="261" fill="currentColor" font-family="ui-monospace, monospace" font-size="11.5">Concurrency="Enabled"</text>
<g filter="url(#trg-sh)"><rect x="620" y="234" width="220" height="44" rx="12" fill="url(#trg-emerald)" stroke="#10b981" stroke-opacity="0.4" /></g>
<rect x="620" y="234" width="220" height="4" rx="2" fill="#10b981" /><text x="730" y="261" fill="#10b981" font-weight="700" font-family="ui-monospace, monospace" font-size="11">trg_…_RowVersion</text>
</g>
<g stroke="currentColor" stroke-width="2" opacity="0.5" fill="none">
<line x1="240" y1="52" x2="354" y2="52" marker-end="url(#trg-ah)" />
<line x1="240" y1="120" x2="354" y2="120" marker-end="url(#trg-ah)" />
<line x1="240" y1="188" x2="354" y2="188" marker-end="url(#trg-ah)" />
<line x1="240" y1="256" x2="354" y2="256" marker-end="url(#trg-ah)" />
<line x1="512" y1="52" x2="616" y2="52" marker-end="url(#trg-ah)" />
<line x1="512" y1="120" x2="616" y2="120" marker-end="url(#trg-ah)" />
<line x1="512" y1="188" x2="616" y2="188" marker-end="url(#trg-ah)" />
<line x1="512" y1="256" x2="616" y2="256" marker-end="url(#trg-ah)" />
</g>

All are per-entity, dual-dialect (SQL Server and PostgreSQL), and idempotent — re-created on each entity migration and dropped cleanly when the capability is turned off.

Trigger Created when SQL Server PostgreSQL What it does
trg_{t}_GenerateSequence entity has a <Sequence> field AFTER INSERT (statement-level, set-based) AFTER INSERT … FOR EACH STATEMENT Expands the cooked sequence token into the real auto-number (batch reserve + ROW_NUMBER())
trg_{t}_Syncable entity declares <Search> or Warehousing="Polling" AFTER INSERT, UPDATE BEFORE INSERT OR UPDATE … FOR EACH ROW (inline NEW) Flags the row IsSyncable so the warehousing worker ships it to every configured target
trg_{t}_AuditLog Logging="Enabled" AFTER INSERT, UPDATE, DELETE (statement-level) AFTER INSERT OR UPDATE OR DELETE … FOR EACH ROW Writes before/after JSON directly into [Audit].[AuditLog]
trg_{t}_RowVersion Concurrency="Enabled" AFTER UPDATE (self-UPDATE +1, TRIGGER_NESTLEVEL guard) BEFORE UPDATE … FOR EACH ROW (inline NEW.RowVersion = OLD+1) Advances the optimistic-concurrency stamp on every update

The Concurrency trigger also ensures a DEFAULT 0 constraint on the RowVersion column at migration time (that’s DDL, not a trigger — EF can’t set a default on a [ConcurrencyCheck] token).

The triggers are not part of the host’s EF migration. They’re (re)installed by the engine’s model migration whenever an entity’s schema is applied — EntityMigrationStrategy calls, in this fixed order:

  1. EnsureSequenceTriggerAsync
  2. EnsureSyncableTriggerAsync
  3. EnsureAuditTriggerAsync
  4. EnsureConcurrencyTriggerAsync

Each Ensure* is a no-op unless the entity declares the matching capability, and each is idempotent (CREATE OR ALTER on SQL Server, CREATE OR REPLACE + DROP … IF EXISTS on PostgreSQL). Turning a capability off drops that trigger on the next migration. The generated SQL is deterministic and unit-tested per dialect.

EntityMigrationStrategy walks the XML model catalog, so it never sees two other kinds of warehoused table. Both get the same trg_{t}_Syncable trigger from somewhere else, at startup:

  • The framework’s own tables (and a host’s hand-written ISyncable entities) are read from the EF model by an engine SQL contributor, Warehousing.SyncableTriggers.
  • Wizard response tables are installed by the wizard provisioner as Wizard.Resp{Name}.Warehousing, deliberately outside the shape gate that skips an unchanged wizard.

Both are recorded in Genie.ExecutedScripts and hash-gated there, so they cost one row read per boot once installed and redeploy by themselves when the engine changes the trigger body. Unlike the catalog path, neither drops a trigger — un-marking a table means removing its trigger in the same change.

Although these triggers are installed by SQL and not by EF migrations, EF Core still needs to know they exist. On SQL Server, EF’s default save pipeline uses an OUTPUT clause, which SQL Server rejects on a table that has AFTER triggers (error 334) — so a host doing context.Add(...) / Attach(...) + SaveChanges() on a logged, concurrent, sequenced, or searchable entity would fail.

The generated EF configuration handles this for you. When an entity declares any of the four capabilities, its {Entity}Configurations class emits a TriggerNames override, and EntityBaseConfiguration declares each name via ToTable(tb => tb.HasTrigger(...)). That switches EF to a trigger-safe save strategy automatically — no host configuration required. (On PostgreSQL, HasTrigger is a harmless no-op: Npgsql uses RETURNING, which tolerates triggers, so the same compiled configuration is correct for both databases.)

The trigger names are computed from the same declarations that create the triggers, so a host can introspect the set through EF’s model metadata:

var triggers = context.Model
.FindEntityType(typeof(Invoice))!
.GetDeclaredTriggers();
bool hasTriggers = triggers.Any();

Two mechanisms keep the triggers from fighting each other when several fire on the same write:

Ordering. On SQL Server the sequence trigger is explicitly ordered to fire first on INSERT (via sp_settriggerorder), so the auto-number is finalized before the audit trigger reads the committed row image — the log records the real value (SKU-01), not the raw token.

The suppression sentinel. Some triggers issue an internal follow-up write that would otherwise re-fire the others:

  • the sequence trigger’s finalize UPDATE (sets the real number), and
  • the SQL Server syncable trigger’s UPDATE IsSyncable = 1.

Each wraps that write in a scope-visible sentinel (a #genie_suppress_triggers temp table on SQL Server, a genie.suppress_triggers GUC on PostgreSQL); the audit, concurrency, and search triggers all early-return when it’s raised. Without it, a bulk insert would cascade — every inserted row would produce a phantom “changed” audit row, a spurious RowVersion bump, and a redundant re-index — turning the insert quadratic. The sentinel is scope-local, so it can never leak onto a pooled connection.

They’re safe by construction, but not free — and the cost is very asymmetric across dialects and write patterns. Each capability is opt-in, so an entity with no flags carries zero trigger overhead.

Cost per write path, heaviest first:

Path What each trigger adds
INSERT Audit is the heavyweight: full-row JSON serialization + (SQL Server) a clustered-PK re-seek to the base table per row + an insert into [Audit].[AuditLog] (its read indexes maintained in the transaction); LOB columns are serialized in. Sequence: a MERGE … WITH (HOLDLOCK) on the counter + a set-based finalize UPDATE. Syncable (SQL Server): an UPDATE IsSyncable = 1 (a second write; free on PostgreSQL’s BEFORE trigger).
UPDATE Concurrency (SQL Server): a self-UPDATE +1 (a second write of the row; free on PostgreSQL). Audit: two JSON serializations (old + new) + a change compare. Search: as above.
DELETE Audit only: one JSON of the old image + an [Audit].[AuditLog] insert. The others don’t hook DELETE.

The dominant costs, in order:

  1. Audit JSON — amplified by wide/LOB tables, and on PostgreSQL it’s FOR EACH ROW, so a bulk insert is row-by-row versus SQL Server’s single set-based statement. This is the biggest dialect gap.
  2. Sequence HOLDLOCK — serializes reservations per (Format, ScopeKey) group (deliberate, for gapless numbering), a contention point under many concurrent same-scope inserts.
  3. SQL Server double-writes — the concurrency self-update and the search flag write add extra row rewrites + transaction log.

Empirical anchor. With the current design, the whole stack runs on the order of ~0.25 ms/row for a batched insert on SQL Server (a 1,000-row Product import’s posting step; see the changelog) — dominated by audit. The earlier un-suppressed cascade made the same import grow with the square of the batch size.

Keeping it cheap:

  • Enable only the flags you use — each is a real per-write cost.
  • Logging is the expensive one — the trigger writes directly into the indexed [Audit].[AuditLog] inside the transaction. Reserve it for entities you actually audit; on PostgreSQL the ledger is UNLOGGED for cheaper writes, and in production you can drop its read indexes if write throughput matters more.
  • Wide/LOB tables + Logging is the case to watch, especially on PostgreSQL (row-by-row).
  • Bulk paths benefit most from the set-based SQL Server triggers; PostgreSQL’s per-row audit scales less well on large batches.