DocumentUsers index (version 2.21.0)
This page explains the per-user document list optimization (SIG-780), introduced in version 2.21.0, and what happens when an instance updates from a version earlier than 2.21.0 to 2.21.0 or later, including 3.x. Instances already on 2.21.0 or later have already gone through this process and are not affected.
On very large DocumentUsers tables, the process causes a period of slowness and higher database consumption after the update. Updating at a time of low usage is strongly recommended. The application keeps working during the process.
What this optimization improves
A user's document list must filter and sort by the document's situation and update date. To avoid a JOIN with the Documents table on every listing, Signer copies those two values to the DocumentUsers table (columns DocumentStatusCode and DocumentUpdateDateUtc) and builds a covering index over them:
IX_DocumentUsers_UserId_DocumentStatusCode_DocumentUpdateDateUtc
keys: UserId, DocumentStatusCode, DocumentUpdateDateUtc (descending)
includes: DocumentId, IsPending, SignerId, ApproverId, SignRuleId, ApproverRuleId
Until the backfill and the index finish, the document list is slower, and documents that predate the update may show up out of order or not show up in the per-user list.
How the automatic process works
The process has three stages, and only the first happens during the migration:
- Migration (
DenormalizeDocumentUserWithTrigger). Run on the first start. It adds the two (empty) columns toDocumentUsers, drops the oldIX_DocumentUsers_UserIdindex and creates theTR_Documents_SyncDocumentUserstrigger onDocuments, which keeps the columns in sync from then on. It is fast: it does not fill the existing data and does not build the new index, so the update is not blocked. - Enqueue on startup.
DocumentUserBackfillStarterchecks theDocumentUserDenormalizationCompletedsetting and whether the index exists. If either condition is not met, it enqueues theDocumentUserDenormalizationJobin Hangfire (Low queue, up to 3 attempts). This repeats on every startup until the process is complete. DocumentUserDenormalizationJob. Runs, in this order:- Backfill of the columns of the existing rows, in batches walking the
Idkey (batch size inDocumentUserDenormalizationBatchSize, default10000; per-batch timeout inDocumentUserDenormalizationBackfillCommandTimeoutSeconds, default300). Every 20 batches the job saves its progress point (DocumentUserDenormalizationBackfillCursor), so a new attempt continues where it stopped. - Marks completion, writing
DocumentUserDenormalizationCompleted = Trueto theSettingstable. - Builds the index
IX_DocumentUsers_UserId_DocumentStatusCode_DocumentUpdateDateUtc, with a timeout ofDocumentUserDenormalizationIndexCommandTimeoutSeconds(default7200). The build is ONLINE (does not lock the table) on Azure SQL Database, SQL Managed Instance and SQL Server Enterprise or Developer, and OFFLINE on other editions. The index is only built after the backfill, so the two operations do not compete for the table.
- Backfill of the columns of the existing rows, in batches walking the
If the ONLINE build fails with any SQL error, including a timeout, the job tries to build the index OFFLINE, which locks the DocumentUsers table for the whole build. On large tables, avoid this scenario by raising the timeout before the update or by creating the index manually (see below).
How to follow it
- In the Hangfire dashboard (
/hangfire), follow theDocumentUserDenormalizationJob. When it finishes, it shows as succeeded. - In the application logs, look for
Backfill DocumentUser denormalization,Creating IX_DocumentUsers_...andcreated successfully. - In the database:
-- Backfill completed? (must return 'True')
SELECT [Value] FROM Settings
WHERE [Key] = 'DocumentUserDenormalizationCompleted' AND AgentId IS NULL AND SubscriptionId IS NULL;
-- Rows still not filled (must return 0)
SELECT COUNT_BIG(*) FROM DocumentUsers WHERE DocumentStatusCode IS NULL OR DocumentUpdateDateUtc IS NULL;
-- Index created?
SELECT name, is_disabled, fill_factor FROM sys.indexes
WHERE name = 'IX_DocumentUsers_UserId_DocumentStatusCode_DocumentUpdateDateUtc' AND object_id = OBJECT_ID('DocumentUsers');
What to do if the automatic process fails
The job failed if it used up its 3 attempts in Hangfire, or if the logs show a timeout or a deadlock (SQL error 1205). Follow the steps in order, and stop as soon as the process completes.
1. Adjust the settings and restart
If the failure was a timeout, raise the settings below (see General Settings) and restart the application. The job is enqueued again on startup and, if the backfill was in progress, continues from the last saved point.
DocumentUserDenormalizationBatchSize: lower it to reduce the lock time of each batch on instances with live traffic.DocumentUserDenormalizationBackfillCommandTimeoutSeconds: timeout of each backfill batch.DocumentUserDenormalizationIndexCommandTimeoutSeconds: timeout of the index build.
2. Fill the columns manually
If the backfill does not complete, run the script below on the database. It walks the table by the clustered Id key in a single progressive pass, in batches, and prints the progress. It can be interrupted and re-run safely.
SET NOCOUNT ON;
DECLARE @last uniqueidentifier = '00000000-0000-0000-0000-000000000000';
DECLARE @step int = 5000, @rows int = 1;
DECLARE @totalDone bigint = 0, @batch int = 0;
DECLARE @t0 datetime2 = SYSUTCDATETIME();
DECLARE @msg nvarchar(200);
WHILE @rows > 0 BEGIN
;WITH batch AS (
SELECT TOP (@step) Id
FROM DocumentUsers
WHERE Id > @last
ORDER BY Id
)
UPDATE du
SET du.DocumentStatusCode = d.StatusCode,
du.DocumentUpdateDateUtc = d.UpdateDateUtc
FROM DocumentUsers du WITH (ROWLOCK)
INNER JOIN batch b ON b.Id = du.Id
INNER JOIN Documents d ON d.Id = du.DocumentId
WHERE du.DocumentStatusCode IS NULL OR du.DocumentUpdateDateUtc IS NULL;
DECLARE @updated int = @@ROWCOUNT;
SET @totalDone += @updated;
SET @batch += 1;
SELECT @last = MAX(Id), @rows = COUNT(*)
FROM (SELECT TOP (@step) Id FROM DocumentUsers WHERE Id > @last ORDER BY Id) x;
SET @msg = CONCAT('Batch ', @batch, ' | updated ', @updated, ' | total ', @totalDone,
' | ', DATEDIFF(second, @t0, SYSUTCDATETIME()), 's | last=', CONVERT(char(36), @last));
SET @msg = REPLACE(@msg, '%', '%%');
RAISERROR(@msg, 0, 1) WITH NOWAIT;
END
RAISERROR('Backfill completed.', 0, 1) WITH NOWAIT;
Then confirm that the count of unfilled rows is 0 (query above) and record the completion, so the job does not repeat the backfill:
IF EXISTS (SELECT 1 FROM Settings WHERE [Key] = 'DocumentUserDenormalizationCompleted' AND AgentId IS NULL AND SubscriptionId IS NULL)
UPDATE Settings SET [Value] = 'True'
WHERE [Key] = 'DocumentUserDenormalizationCompleted' AND AgentId IS NULL AND SubscriptionId IS NULL;
ELSE
INSERT INTO Settings (Id, [Key], [Value], AgentId, SubscriptionId) VALUES (NEWID(), 'DocumentUserDenormalizationCompleted', 'True', NULL, NULL);
3. Create the index manually
Only after confirming the backfill finished. Do it in a low-traffic window and in a single session: two simultaneous ONLINE builds on the same table deadlock on the final lock swap, and IF NOT EXISTS does not protect against concurrent runs. On editions without ONLINE support (SQL Server Standard and Express), use ONLINE = OFF, which locks the table during the build.
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_DocumentUsers_UserId_DocumentStatusCode_DocumentUpdateDateUtc' AND object_id = OBJECT_ID('DocumentUsers'))
CREATE INDEX [IX_DocumentUsers_UserId_DocumentStatusCode_DocumentUpdateDateUtc] ON [DocumentUsers] ([UserId] ASC, [DocumentStatusCode] ASC, [DocumentUpdateDateUtc] DESC)
INCLUDE ([DocumentId], [IsPending], [SignerId], [ApproverId], [SignRuleId], [ApproverRuleId])
WITH (ONLINE = ON, FILLFACTOR = 80, SORT_IN_TEMPDB = ON);
Precautions:
- Deadlock. The ONLINE build competes for locks with the application's writes and can cause a deadlock (error 1205); the victim may be an application transaction rather than the build. After the build, check the application logs for 1205 errors in that period.
- Azure SQL Database. Index creation is always fully logged, so the time depends on the service tier's log rate limit. Low tiers, such as the 800 DTU S7, are throttled (
LOG_RATE_GOVERNORwait; theCXCONSUMERwait is benign). On an instance with about 129 millionDocumentUsersrows on S7, the build took a long time and one attempt ended in a deadlock. If you can, scale the service tier before starting the build, since changing the tier midway aborts a build in progress. - Resumable build (optional). On SQL Server 2019 or later and Azure SQL, add
RESUMABLE = ONto theWITHclause so an interruption does not discard the progress (checksys.index_resumable_operationsand useALTER INDEX ... RESUMEorABORT).RESUMABLE = ONcannot be combined withSORT_IN_TEMPDB = ON; remove that option.
4. Confirm and restart
Run the queries in the How to follow it section: completion must be True, no pending rows and the index present. Restart the application. With the completion recorded and the index created, DocumentUserBackfillStarter no longer enqueues the job.