Skip to content

Performance and tuning

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.

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.

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.

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.

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.

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.

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 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.

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

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.

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:

  1. Disable rate limiting. Otherwise you measure the limiter.
  2. Seed a realistic corpus. A repository of thirty documents exercises neither paging nor search.
  3. Measure as a non-administrator. An administrator is a best case that skips the per-folder permission path most real requests take.