Performance and tuning
The shape of the system
Section titled “The shape of the system”Docsentry is a read-heavy application in front of a SQL Server database. Most requests list a folder, open a document, or search. Expensive work - rendering, text extraction, scanning, notification delivery - is pushed onto background jobs and never happens inside a request.
Capacity is therefore governed mostly by how many database connections are available and how efficiently each request uses them.
Connection budget
Section titled “Connection budget”DmsDataAccess:MaxPoolSize (default 100) is the single most important setting. It caps
concurrent SQL connections; beyond it, requests wait.
The connection factory is a singleton and the connection string is resolved once, at startup, rather than per connection open. A unit of work takes one connection and one transaction, not one per query.
DmsDataAccess:CommandTimeoutSeconds (default 30) bounds a single command, so one pathological
query cannot hold a connection indefinitely.
Rate limiting and shedding
Section titled “Rate limiting and shedding”Two mechanisms protect the system from load it cannot serve.
Rate limiting applies chained fixed windows: a per-account bucket and a per-address bucket.
Static assets and the health endpoint are exempt, so page loads do not consume a user’s
allowance. Rejections return 429 with Retry-After.
Authentication concurrency bounds simultaneous password verification, which is deliberately
CPU-expensive. Beyond its permit and queue limits, requests are shed with 503 and Retry-After.
Shedding is the point. Without it, a burst of sign-ins consumes the connection pool and degrades every other request; with it, a few sign-ins fail quickly and everything else keeps working.
Pagination
Section titled “Pagination”Collections page with an opaque keyset cursor rather than an offset. Offset paging degrades as the offset grows, because the database must count past the skipped rows; keyset paging seeks directly and stays flat however deep you page.
DmsDataAccess:DefaultPageSize (50) applies when a caller does not specify one.
MaxPageSize (200) caps what a caller may request, which is what stops a client asking for the
entire repository in one response.
The folder explorer pages folders and documents with two independent cursors, so a folder with many subfolders and many documents pages each without one starving the other.
Materialised permissions
Section titled “Materialised permissions”Resolving the access-control chain per request, per document, would be the system’s dominant cost. Instead, effective permissions are materialised into tables and rebuilt when their inputs change.
The rebuild is queued and drained inline, in the same transaction as the change, so a grant is visible immediately. A background job drains the same queue as a safety net for rows that outlive their request.
The job deliberately returns without opening a write transaction when the queue is empty - an idle system should not be paying for a write every 30 seconds.
Caching
Section titled “Caching”| Cache | Lifetime | Notes |
|---|---|---|
| Full-text capability probe | 5 minutes | Avoids re-checking per query. Installing full-text search takes effect within minutes, without a restart. |
| Antivirus verdicts | VirusTotalCacheMinutes (10) |
Re-uploading identical content does not re-query the service. |
| Signed renditions | Byte-bounded | Keyed by the applied-signature digest, so a stale rendition is structurally unaddressable. |
| Thumbnails and previews | HTTP cache headers | Immutable per version, so browsers reuse them rather than refetching a folder’s worth of images. |
The preview endpoint itself is served uncached, because what it returns depends on the caller’s permissions.
Background job cadence
Section titled “Background job cadence”Job cadence is a real, permanent cost: a job that ticks and finds nothing still costs a scheduler wake-up, a connection, and a query, forever.
The configuration file records the concrete instance of this: PermissionRebuild runs every 30
seconds rather than every 5 because at the shorter cadence the job runner’s own bookkeeping became
the dominant cost of an otherwise idle system.
See Background jobs.
Search
Section titled “Search”Search has two execution modes, and the difference is large.
Full-text mode uses SQL Server’s CONTAINS against a full-text index. Cost scales with
matches.
Fallback mode uses LIKE '%term%'. The leading wildcard makes the predicate non-sargable: no
index can satisfy it, so the candidate rows are scanned. Correct, but the cost scales with the
size of the repository rather than the size of the result.
If search is slow and the repository has grown, check whether full-text search is actually installed on the instance before tuning anything else:
SELECT FULLTEXTSERVICEPROPERTY('IsFullTextInstalled');See Search.
Which knob for which symptom
Section titled “Which knob for which symptom”| Symptom | Likely cause | Action |
|---|---|---|
| Requests queue under load, database is idle | Connection pool exhausted | Raise DmsDataAccess:MaxPoolSize |
| Requests queue, database is busy | Database is the bottleneck | Do not raise the pool. Check indexes and query plans. |
| Sign-ins slow, everything else fine | Password verification saturating CPU | Tune AuthenticationConcurrency, or add CPU |
| Search slow, growing worse with size | Fallback mode | Install SQL Server full-text search |
| Folder listings slow with many items | Page size too large | Lower DefaultPageSize |
| Idle system consuming measurable resources | Job cadence too tight | Lengthen the intervals |
| Previews or extraction falling behind | Batch too small for the volume | Raise the job’s MaxItems, not its interval |
| Legitimate clients getting 429 | Limits too tight for the usage pattern | Raise the relevant permit limit; do not disable the limiter |
Observability
Section titled “Observability”Logging:SlowRequestThresholdMs (default 1000) is the line above which a request is logged as
slow. Lower it while hunting a latency problem; restore it afterwards, because request logging at
volume is itself a cost.
The job dashboard shows scheduled, running, succeeded, and failed jobs with retry history.
Database/verification/920_query_plan_baseline.sql records the access paths the hot queries are
expected to use, which is the right starting point for a suspected plan regression.
Load testing
Section titled “Load testing”A k6 harness lives under tests/load/: a smoke test, a seeder that creates content through the
real API, and a staged ramp.
Three things to get right, or the numbers mean nothing:
- Disable rate limiting. Otherwise you measure the limiter.
- Seed a realistic corpus. A repository of thirty documents exercises neither paging nor search.
- Measure as a non-administrator. An administrator is a best case that skips the per-folder permission path most real requests take.
Related
Section titled “Related”- Configuration keys - every setting mentioned here.
- Background jobs - cadences and batch sizes.
- Deployment - capacity settings at deployment time.