Guild Lifecycle
The recipe for keeping GuildEntity in step with the two gateway events that
bracket a bot's presence in a guild: JoinedGuild and LeftGuild. It builds on
UpsertAsync, ApplyGuildRoot, and PurgeGuildAsync — see
Upsert and Core Graph for those on their own.
Every snippet assumes a short-lived DbContext from IDbContextFactory (see
DbContext Lifetime) built on DiscordGraphDbContext, so
db.Guilds is already exposed, and a constructor-injected TimeProvider clock
for the timestamps — the same clock Persistord.Core's own
TimestampInterceptor takes.
On JoinedGuild: upsert the root row
await db.Guilds.UpsertAsync(
g => g.Id == guild.Id,
() => new GuildEntity { Id = guild.Id },
g =>
{
g.Name = guild.Name;
g.JoinedAt ??= clock.GetUtcNow();
g.LeftAt = null;
});
This is safe to call whether or not a row already exists: a first join inserts
it, a re-invite after a soft-marked leave finds the existing row, clears
LeftAt, and — because JoinedAt is set with ??=, not = — keeps the
original join timestamp rather than overwriting it with the re-invite time.
Assign it unconditionally instead if you want JoinedAt to track the most
recent join.
On LeftGuild: soft mark or hard purge
Handling LeftGuild is a choice between two policies, and Persistord makes
neither for you:
Soft mark — stamp LeftAt and keep every row, so a re-invite finds its
history intact:
await db.Guilds.UpsertAsync(
g => g.Id == guild.Id,
() => new GuildEntity { Id = guild.Id },
g => g.LeftAt = clock.GetUtcNow());
A non-null LeftAt is just data until you act on it. To also hide left guilds
from ordinary queries, call ApplyGuildRoot(filterLeftGuilds: true) once in
OnModelCreating — it is a model-wide switch, not something you toggle per
event — and reach for IgnoreQueryFilters() on the rare query that needs to see
them anyway.
Hard purge — erase the guild's rows outright with PurgeGuildAsync (see
Purge behind a lock below). PurgeGuildAsync
ignores query filters itself, so a guild already hidden by
filterLeftGuilds: true is still reachable and still gets purged.
Pick one. With neither policy wired to LeftGuild, a left guild's rows
outlive the guild forever — nothing in Persistord removes them on its own.
PurgeGuildAsync only ranks IGuildScoped types in its delete order. The five
skeleton entities from DiscordGraphDbContext are deliberately not
IGuildScoped — see Core Graph for why — so a
consumer who also mirrors ChannelEntity, RoleEntity, MemberEntity, and the
rest must delete those itself, before or after calling PurgeGuildAsync.
Purge behind a per-guild lock
Discord can and does deliver LeftGuild more than once for the same guild —
most commonly around a gateway resume — and a reconciler pass can be mid-write
for that guild when the second one arrives. Serialize purge and reconcile work
per guild so the two can never interleave:
using (await _guildLocks.AcquireAsync(guildId, cancellationToken))
{
await db.PurgeGuildAsync(guildId, cancellationToken);
}
_guildLocks here is your own per-guild async lock (an async-friendly mutex
keyed by guild id) — Persistord ships no locking primitive; PurgeGuildAsync
only guarantees that its own deletes run in one transaction, not that nothing
else touches the guild while it runs.
Order of operations: Discord first, then the database
PurgeGuildAsync deletes records, never Discord objects — same rule as
DeleteScopeAsync in Managed Resources.
Tear down whatever Discord resources the guild's rows point at before deleting
the rows that remember them, not after: once the record is gone, so is your
only lead back to the Discord object.
using (await _guildLocks.AcquireAsync(guildId, cancellationToken))
{
await TearDownDiscordResourcesAsync(guildId, cancellationToken); // Discord side first
await db.PurgeGuildAsync(guildId, cancellationToken); // then forget the records
}
What cascade: true buys — and costs
ApplyGuildRoot's default, cascade: true, wires a cascading foreign key from
every IGuildScoped entity's GuildId to GuildEntity. That buys you a
database-level PurgeGuildAsync alternative for free: deleting the Guilds row
directly deletes every scoped row with it, no application code involved.
It also costs you an insert order: the guild row becomes a prerequisite for
every scoped insert. A scoped row written before its guild's JoinedGuild
upsert has run fails with a foreign-key violation, not a queued write. If scoped
rows legitimately need to arrive before, or outlive, their guild row, pass
cascade: false — the same choice PurgeGuildAsync's own tests make, because a
guild root with no cascading foreign key is the harder case that helper exists
for: nothing in the database deletes those rows without it.
See also
- Core Graph —
GuildEntity,ApplyGuildRoot, and why the five skeleton entities aren'tIGuildScoped. - Upsert — the
UpsertAsyncsignature and dirty-check both snippets above rely on. - Managed Resources —
DeleteScopeAsync, the scope-level equivalent of a guild purge. - DbContext Lifetime — the short-lived
DbContextevery snippet here assumes.