Skip to content

Reading content items by ID scans an index on SQL Server: ContentItemIndex has no index led by ContentItemId #19966

Description

@giannik

Is your feature request related to a problem?

Every index that OrchardCore.ContentManagement creates on ContentItemIndex is led by DocumentId (Records/Migrations.cs). A query by ContentItemId, which IContentManager.GetAsync() sends for one item or a batch, therefore can't seek on SQL Server. It scans the narrowest index, IDX_ContentItemIndex_DocumentId, and checks every row. The cost grows with the size of the table, not with the number of items read.

Measured on SQL Server 2025 (LocalDB) with 300,000 ContentItemIndex rows, using SQL Server's own per-statement statistics averaged over 51 runs:

Read Today With an index led by ContentItemId
One item by ID 28.3 ms, 5,193 logical reads 0.05 ms, 16 logical reads
A batch of 61 IDs 339.0 ms, 5,790 logical reads 1.2 ms, 918 logical reads
A batch of 500 IDs 94.4 ms, 10,358 logical reads 11.1 ms, 7,652 logical reads

Nearly all of that time is CPU. The batch of 61 costs more than the batch of 500: at that size SQL Server checks each scanned row against the list of IDs one by one. So a small change in how many items a page reads can multiply its cost.

We found this in our own application on a 4.0 preview (a field service product, with a test database seeded with two years of data, about 300,000 ContentItemIndex rows). Its dispatch board reads about 740 content items by ID in three batches and took 590 ms. With the index it takes about 150 ms. A single item by ID went from 26 ms to 0.9 ms, and a write that re-reads a job and its visits by ID from 97 ms to 14 ms.

The script below reproduces the table above in an empty database. It creates ContentItemIndex with Orchard Core's five indexes, seeds 300,000 rows, measures the reads, then adds the index and measures again.

Repro script (SQL Server)
/*
    Repro: reads of content items by id scan ContentItemIndex on SQL Server — and an index led by ContentItemId.

    Run in an EMPTY scratch database (it creates and drops two tables, [Document] and [ContentItemIndex], shaped as Orchard Core's:
    ContentItemIndex with the five indexes OrchardCore.ContentManagement's migration creates, verbatim). About 300,000 index rows are
    seeded (230,000 items, 70,000 of them with an older draft version), roughly an established site's size.

    Measured: the shape of the query IContentManager.GetAsync issues — the documents joined to ContentItemIndex, filtered by
    ContentItemId (one id, or a batch as GetAsync(ids) sends it) and Published — as parameterized statements (sp_executesql). Then the
    proposed index is created and the same reads run again. The reads' rows go to a temporary table (INSERT ... EXEC), so only the
    summary at the end is returned.
*/
SET NOCOUNT ON;

IF OBJECT_ID('dbo.ContentItemIndex') IS NOT NULL DROP TABLE dbo.ContentItemIndex;
IF OBJECT_ID('dbo.Document') IS NOT NULL DROP TABLE dbo.Document;

CREATE TABLE dbo.Document (
    Id bigint IDENTITY(1,1) NOT NULL PRIMARY KEY,
    [Type] nvarchar(255) NULL,
    Content nvarchar(max) NULL,
    [Version] bigint NOT NULL DEFAULT 0);
CREATE INDEX IX_Document_Type ON dbo.Document ([Type]);

CREATE TABLE dbo.ContentItemIndex (
    Id bigint IDENTITY(1,1) NOT NULL PRIMARY KEY,
    DocumentId bigint NULL,
    ContentItemId nvarchar(26) NULL,
    ContentItemVersionId nvarchar(26) NULL,
    Latest bit NULL,
    Published bit NULL,
    ContentType nvarchar(255) NULL,
    ModifiedUtc datetime NULL,
    PublishedUtc datetime NULL,
    CreatedUtc datetime NULL,
    [Owner] nvarchar(255) NULL,
    Author nvarchar(255) NULL,
    DisplayText nvarchar(255) NULL);

-- Orchard Core's own indexes on ContentItemIndex (OrchardCore.ContentManagement/Records/Migrations.cs), all led by DocumentId.
CREATE INDEX IDX_ContentItemIndex_DocumentId ON dbo.ContentItemIndex (DocumentId, ContentItemId, ContentItemVersionId, Published, Latest);
CREATE INDEX IDX_ContentItemIndex_DocumentId_ContentType ON dbo.ContentItemIndex (DocumentId, ContentType, CreatedUtc, ModifiedUtc, PublishedUtc, Published, Latest);
CREATE INDEX IDX_ContentItemIndex_DocumentId_Owner ON dbo.ContentItemIndex (DocumentId, [Owner], Published, Latest);
CREATE INDEX IDX_ContentItemIndex_DocumentId_Author ON dbo.ContentItemIndex (DocumentId, Author, Published, Latest);
CREATE INDEX IDX_ContentItemIndex_DocumentId_DisplayText ON dbo.ContentItemIndex (DocumentId, DisplayText, Published, Latest);

-- The seed: 230,000 items; the last 70,000 of them also keep an older version (not published, not latest).
DECLARE @items int = 230000, @drafts int = 70000;
;WITH n AS (SELECT TOP (@items) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS i FROM sys.all_objects a CROSS JOIN sys.all_objects b CROSS JOIN sys.all_objects c)
SELECT i,
       LOWER(LEFT(REPLACE(CONVERT(varchar(36), NEWID()), '-', ''), 26)) AS ContentItemId,
       CASE i % 5 WHEN 0 THEN N'Article' WHEN 1 THEN N'Product' WHEN 2 THEN N'Order' WHEN 3 THEN N'Customer' ELSE N'Page' END AS ContentType
INTO #items FROM n;

INSERT dbo.Document ([Type], Content, [Version])
SELECT N'OrchardCore.ContentManagement.ContentItem, OrchardCore.ContentManagement.Abstractions', REPLICATE(N'x', 200), 1 FROM #items ORDER BY i;
INSERT dbo.ContentItemIndex (DocumentId, ContentItemId, ContentItemVersionId, Latest, Published, ContentType, ModifiedUtc, PublishedUtc, CreatedUtc, [Owner], Author, DisplayText)
SELECT i, ContentItemId, LOWER(LEFT(REPLACE(CONVERT(varchar(36), NEWID()), '-', ''), 26)), 1, 1, ContentType, GETUTCDATE(), GETUTCDATE(), GETUTCDATE(), N'admin', N'admin', CONCAT(ContentType, N' ', i)
FROM #items ORDER BY i;

INSERT dbo.Document ([Type], Content, [Version])
SELECT N'OrchardCore.ContentManagement.ContentItem, OrchardCore.ContentManagement.Abstractions', REPLICATE(N'x', 200), 1 FROM #items WHERE i > @items - @drafts ORDER BY i;
INSERT dbo.ContentItemIndex (DocumentId, ContentItemId, ContentItemVersionId, Latest, Published, ContentType, ModifiedUtc, PublishedUtc, CreatedUtc, [Owner], Author, DisplayText)
SELECT @items + ROW_NUMBER() OVER (ORDER BY i), ContentItemId, LOWER(LEFT(REPLACE(CONVERT(varchar(36), NEWID()), '-', ''), 26)), 0, 0, ContentType, GETUTCDATE(), NULL, GETUTCDATE(), N'admin', N'admin', CONCAT(ContentType, N' ', i)
FROM #items WHERE i > @items - @drafts ORDER BY i;

UPDATE STATISTICS dbo.ContentItemIndex WITH FULLSCAN;
UPDATE STATISTICS dbo.Document WITH FULLSCAN;

-- The reads: one id, then batches of 61 and 500 ids (the ids spread over the table), each as one parameterized statement, 50 runs
-- after a first one. The numbers are SQL Server's own per-statement statistics (sys.dm_exec_query_stats: elapsed and CPU time, logical
-- reads, averaged per run), so the client's handling of the rows does not count. The database's plan cache is cleared at each phase
-- (database-scoped — nothing else on the server is touched).
CREATE TABLE #sink (Id bigint, [Type] nvarchar(255), Content nvarchar(max), [Version] bigint);
CREATE TABLE #results (Phase nvarchar(40), [Read] nvarchar(40), Runs bigint, AvgElapsedMs decimal(12,3), AvgCpuMs decimal(12,3), AvgLogicalReads bigint);

DECLARE @phase nvarchar(40) = N'Orchard Core''s indexes';
WHILE 1 = 1
BEGIN
    ALTER DATABASE SCOPED CONFIGURATION CLEAR PROCEDURE_CACHE;
    DECLARE @size int;
    DECLARE sizes CURSOR LOCAL FAST_FORWARD FOR SELECT v FROM (VALUES (1), (61), (500)) AS s(v);
    OPEN sizes;
    FETCH NEXT FROM sizes INTO @size;
    WHILE @@FETCH_STATUS = 0
    BEGIN
        DECLARE @sql nvarchar(max), @decl nvarchar(max), @args nvarchar(max), @call nvarchar(max);
        SELECT @sql = N'SELECT [Document].* FROM [Document] INNER JOIN (SELECT [Document].[Id] FROM [Document] INNER JOIN [ContentItemIndex] AS [ContentItemIndex_a1] ON [ContentItemIndex_a1].[DocumentId] = [Document].[Id] WHERE ([ContentItemIndex_a1].[ContentItemId] IN ('
                    + STRING_AGG(CONVERT(nvarchar(max), CONCAT(N'@p', k)), N', ') WITHIN GROUP (ORDER BY k)
                    + N')) AND ([ContentItemIndex_a1].[Published] = @pub)) AS [IndexQuery] ON [IndexQuery].[Id] = [Document].[Id]',
               @decl = STRING_AGG(CONVERT(nvarchar(max), CONCAT(N'@p', k, N' nvarchar(26)')), N', ') WITHIN GROUP (ORDER BY k) + N', @pub bit',
               @args = STRING_AGG(CONVERT(nvarchar(max), CONCAT(N'@p', k, N' = N''', ContentItemId, N'''')), N', ') WITHIN GROUP (ORDER BY k) + N', @pub = 1'
        FROM (SELECT ROW_NUMBER() OVER (ORDER BY i) - 1 AS k, ContentItemId FROM #items WHERE i % (@items / @size) = 7) AS picked
        WHERE k < @size;
        SET @call = N'INSERT #sink EXEC sp_executesql N''' + REPLACE(@sql, N'''', N'''''') + N''', N''' + @decl + N''', ' + @args + N';';

        DECLARE @run int = 0;
        WHILE @run < 51 BEGIN EXEC (@call); SET @run += 1; END;
        TRUNCATE TABLE #sink;

        INSERT #results
        SELECT TOP 1 @phase, CASE @size WHEN 1 THEN N'one id' ELSE CONCAT(@size, N' ids') END, qs.execution_count,
               qs.total_elapsed_time / 1000.0 / qs.execution_count, qs.total_worker_time / 1000.0 / qs.execution_count,
               qs.total_logical_reads / qs.execution_count
        FROM sys.dm_exec_query_stats qs CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
        CROSS APPLY (SELECT CONVERT(int, value) AS dbid FROM sys.dm_exec_plan_attributes(qs.plan_handle) WHERE attribute = 'dbid') pa
        WHERE pa.dbid = DB_ID() AND st.text LIKE N'%ContentItemIndex_a1%'
          AND st.text LIKE N'%@p' + CONVERT(nvarchar(10), @size - 1) + N' nvarchar(26), @pub bit)%'
        ORDER BY qs.last_execution_time DESC;
        FETCH NEXT FROM sizes INTO @size;
    END;
    CLOSE sizes; DEALLOCATE sizes;

    IF @phase <> N'Orchard Core''s indexes' BREAK;

    -- The proposal: an index led by ContentItemId (every read by id names Published or Latest; DocumentId joins the document;
    -- ContentType and DisplayText keep a search that joins through it from looking each row up in the table).
    CREATE INDEX IDX_ContentItemIndex_ContentItemId ON dbo.ContentItemIndex (ContentItemId, Published, Latest, DocumentId, ContentType, DisplayText);
    SET @phase = N'+ IDX_ContentItemIndex_ContentItemId';
END;

SELECT (SELECT COUNT(*) FROM dbo.ContentItemIndex) AS IndexRows, (SELECT COUNT(DISTINCT ContentItemId) FROM dbo.ContentItemIndex) AS Items;
SELECT [Read], Phase, Runs, AvgElapsedMs, AvgCpuMs, AvgLogicalReads FROM #results ORDER BY [Read], Phase DESC;

DROP TABLE dbo.ContentItemIndex;
DROP TABLE dbo.Document;

Describe the solution you'd like

A migration step in OrchardCore.ContentManagement that adds an index led by ContentItemId:

IDX_ContentItemIndex_ContentItemId on (ContentItemId, Published, Latest, DocumentId, ContentType, DisplayText)

  • Published and Latest follow the ID because every read by ID filters on one of them.
  • DocumentId is the join to Document.
  • ContentType and DisplayText are for queries that join through the new index and filter on them. Without them, one of our list searches (by a related item's display text) got slower, 602 ms → 896 ms, because SQL Server chose the new index and then looked up each row. With them it measured 621 ms against 602 ms without the index (within run-to-run noise), and every other measure got faster.

The existing DocumentId-led indexes stay as they are; they serve the join from Document.

The key is at most about 1,082 bytes on SQL Server, within the 1,700-byte limit for non-clustered indexes. We tested on SQL Server and SQLite.

I'll open a pull request with this change.

Describe alternatives you've considered

  • An index on (ContentItemId, Published, Latest, DocumentId) only: the same gain for reads by ID, but the list search above regressed.
  • The same key with INCLUDE (DocumentId, ContentType, DisplayText) on SQL Server: slower than the plain key in our measurements, and YesSql's CreateIndex can't express INCLUDE.
  • Each application adding the index itself: we did that in the meantime, but every Orchard Core site that reads content items by ID on SQL Server pays this cost.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions