Skip to content

Hot tables — large prod tables that break naive queries

Some Raklet tables are small on a dev box and huge in production. A LINQ query that is instant locally can time out, or fail to translate, against prod volume. This file is the registry of those tables. Check it before writing or reviewing any EF query.

Full reasoning and the testing process: docs/testing/prod-scale-pr-testing-plan.md.


The rule

If a PR adds or changes an EF LINQ query that touches a table below:

  1. Add a real-SQL [TestCategory("Integration")] test that executes the query against SQL Server — not against .AsQueryable() over an array. In-memory LINQ-to-Objects supports every operation and proves nothing about EF6's SQL translation or performance. Pattern to copy: Raklet.UnitTests/OrganisationMembershipCrudIntegrationTests.cs.
  2. Use the DbContext the query actually uses (see table below) — the hot tables are spread across four separate databases.
  3. Name the index the query relies on. If no single index covers all the filtered/grouped columns, add a migration. A non-covering index still key-lookups once per row — fine on a dev org, a timeout on a prod-scale one.
  4. Be especially wary of GroupBy, Distinct, and .Count() on these tables — EF6 either mistranslates them or emits a per-group re-scan.

The four databases

The codebase has four EF DbContexts, each on its own database:

DbContext Connection string Holds
RakletDb (Models/RakletDb.cs) RakletV3 orgs, memberships, contacts, events, payments-side refs
RakletEmailContext (Raklet.Email/Database/) RakletEmailContext emails, email items, email actions
RakletMoneyContext (Raklet.Money/Database/) RakletMoneyContext money-side payment data
RakletSmsContext (Raklet.Sms/Database/) RakletSmsContext SMS

Registry

Prod sizes measured 2026-05-14 (re-run the queries below to refresh):

Database (DbContext) Table Prod rows Prod reserved Why large Common filter/group columns
email (RakletEmailContext) EmailItems 17.3M 32 GB one row per recipient per campaign EmailId, IsDeleted
email (RakletEmailContext) EmailItemActions 14.9M 8.3 GB one row per recipient action per campaign EmailId, Type, IsDeleted, ClickedUrl, EmailItemId
email (RakletEmailContext) EmailParts 2.5M 15 GB HTML bodies — very wide rows EmailId
email (RakletEmailContext) Emails 2.5M 10 GB every campaign ever sent — wide rows OrganisationId
main (RakletDb) OrganisationMemberships 910k 6.4 GB every contact in every org — very wide rows OrganisationId, Status, Type
main (RakletDb) Organisations 34k 3.9 GB only 34k rows but ~120 KB/row — per-org settings/CSS/config blobs Id, Permalink
main (RakletDb) Payments 474k 1.1 GB every payment ever taken OrganisationId, date ranges
main (RakletDb) Debts 1.5M 1.1 GB per-member debts OrganisationId, OrganisationMembershipId
main (RakletDb) OrganisationMembershipActivities 1.8M 722 MB activity log per member OrganisationMembershipId, OrganisationId
main (RakletDb) ApplicationForms 144k 688 MB wide form payloads OrganisationId

Notes: - *_2018 tables (EmailItems_2018, EmailItemActions_2018, …) are archive tables — don't query them by accident. - Row count alone hides scale risk. Organisations is only 34k rows but ~120 KB each — a query that .Include()s or wide-selects orgs across the table moves gigabytes. Sort the size query by UsedMB, not by row count. - Row counts here are platform-wide; query performance depends on per-org / per-email fan-out — the worst single org or single email, not the total.

Refreshing this registry

Per-table size — metadata-only, instant, safe on prod. Run once per database:

SELECT
    DB_NAME()                                              AS [Database],
    s.name                                                 AS [Schema],
    t.name                                                 AS [Table],
    SUM(CASE WHEN p.index_id IN (0,1) THEN p.row_count ELSE 0 END) AS [Rows],
    CAST(SUM(p.reserved_page_count) * 8.0 / 1024 AS DECIMAL(18,1)) AS [ReservedMB],
    CAST(SUM(p.used_page_count)     * 8.0 / 1024 AS DECIMAL(18,1)) AS [UsedMB]
FROM sys.dm_db_partition_stats p
JOIN sys.tables   t ON t.object_id = p.object_id
JOIN sys.schemas  s ON s.schema_id = t.schema_id
GROUP BY s.name, t.name
ORDER BY [UsedMB] DESC;

Worst-case fan-out per email — this does scan, so run off-peak:

SELECT TOP 20 EmailId, COUNT(*) AS Actions
FROM dbo.EmailItemActions
WHERE EmailId IS NOT NULL
GROUP BY EmailId
ORDER BY COUNT(*) DESC;