From cab61ee004f0f00ad2c1db89e6593e15f6f2a150 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 4 Sep 2026 13:18:02 +0800 Subject: [PATCH 01/15] Add migration overview documentation for RavenDB to SQL transition --- docs/ravendb-to-sql-migration-overview.md | 246 ++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/ravendb-to-sql-migration-overview.md diff --git a/docs/ravendb-to-sql-migration-overview.md b/docs/ravendb-to-sql-migration-overview.md new file mode 100644 index 0000000000..8c5cecb922 --- /dev/null +++ b/docs/ravendb-to-sql-migration-overview.md @@ -0,0 +1,246 @@ +# Moving data from RavenDB to SQL + +## Problem + +A customer can already point ServiceControl at SQL Server or PostgreSQL. They cannot bring their existing data with them. + +## Strategy + +- Switch over first, and copy only what has to be copied. Retention does most of the work: error retention is between 5 and 45 days and event retention is shorter, so most of the source ages out on its own within weeks. That is why archived and resolved messages are optional rather than required. Retention would have deleted them anyway. +- The required set is small because unresolved failures are the only category a customer can act on, and its assumed that customers keep that number low by resolving and archiving. +- Anything not selected simply ages out of RavenDB, and the customer deletes the old database when they are ready. + +## Goals + +- **Minimal downtime**. Only the required data copies with ServiceControl closed. Optional data copies in the background while it serves traffic. +- **All three RavenDB sources are supported**. Embedded, a container, or RavenDB Cloud, on one code path rather than three. +- **No writes through the client**. Nothing the copier does changes the source, so the old database stays a valid fallback. +- **Abandonable up to a known point**. Free while ServiceControl is closed. After it opens, abandoning loses whatever SQL has ingested since. +- **No duplicates and no gaps**. Rows and the resume cursor commit in one transaction, so a crash needs no reconciliation. +- **Every identifier anything depends on is carried across**. The event log and historic retry operations are renumbered, because nothing references their keys. +- **Refuse rather than half-migrate**. Every check runs before the first row moves, and a failure is a host that will not start. +- **No silent loss**. A migration cannot end with a selected category incomplete, and skipped rows are counted and reported. +- **Bounded impact on a live instance**. Throttled behind normal ingestion and streamed, so memory does not track the size of the database. +- **Known before it starts, visible while it runs**. A dry run reports what will move and how long ServiceControl is closed, and every category transition is reported as it happens. +- **Use existing functionality where possible**. Progress goes through custom checks and the activity feed, so no new client or screen is needed. + +## Nice to have, but not planned in this initial design (phase 1) + +- **Zero downtime.** The required data is copied with ServiceControl closed, so there is a real, if short, outage. +- **Reversible once ServiceControl opens.** Nothing copies SQL rows back to RavenDB. +- **Steerable while running.** No pause, resume, or abort. Changing anything means editing configuration and restarting. +- **A general-purpose migration tool.** The source is always RavenDB and the target is always a ServiceControl EF Core persister, both at versions this build can read. +- **Custom migration UI via ServicePulse.** Custom checks and the event log will be used for progress reporting, but migration configuration and migration engine control will not be available via the UI. + +## Supported migration scenarios + +The copier runs inside the ServiceControl host, so every row and every message body travels from the source, through ServiceControl, to the target. There is no database-to-database transfer, no backup and restore, and no replication. Whether a combination works therefore comes down to whether the ServiceControl host can reach both ends at once, and how long it takes comes down to how far the data has to travel. + +**Supported locations:** + +- **The RavenDB source**: embedded on the ServiceControl host, self-hosted on the same network in a container, VM or bare metal, or RavenDB Cloud +- **The SQL target**: SQL Server or PostgreSQL on the ServiceControl host, elsewhere on the same network, in a container, or as a managed cloud service such as Azure SQL, Amazon RDS or Google Cloud SQL +- **Any combination of the two**, subject to the requirements below + +**By where the data has to travel:** + +- **On-prem to on-prem.** The common case and the fastest. Embedded or self-hosted RavenDB to SQL on the same host or the same network. +- **On-prem to cloud.** RavenDB on the network, managed SQL in a cloud. Works, but each batch is one round trip, so write latency multiplies by the number of batches rather than being amortised away. +- **Cloud to on-prem.** RavenDB Cloud down to local SQL. Works, and the customer pays egress on everything copied, most of which is archived messages and their bodies. +- **Cloud to cloud.** Works, and is only sensible when ServiceControl runs alongside one of them. A host sitting on-prem between two clouds pulls every byte down and pushes it straight back up. +- **The message body store is a third location.** Bodies come out of RavenDB attachments and go wherever the target is configured to put them: a filesystem, Azure Blob or S3, with small text bodies kept inline in the database. That decision is made at the same time as the database move. + +**Infrastructure requirements:** + +- The ServiceControl host needs network access to the RavenDB source, the SQL target and the body store simultaneously +- Both RavenDB databases, primary and throughput, on one server or cluster +- A SQL Server target must have Full-Text Search installed. `--setup` checks `SERVERPROPERTY('IsFullTextInstalled')` and fails if it is absent, because message search is not optional. A stock SQL Server container image does not include it. PostgreSQL needs nothing extra, since its index is a GIN over `to_tsvector` +- A managed target's transient failures are already survivable: retry on failure is on by default and there is no setting to turn it off + +**Not supported:** + +- Any server-to-server copy: no backup and restore, no RavenDB ETL or replication into SQL, no external data pipeline +- A host that can reach only one of the two databases at a time, so no staged move by way of an offline copy +- Primary and throughput RavenDB databases in different locations +- 🍅 Merging two ServiceControl instances into one SQL database, or splitting one instance across several +- Anything but RavenDB as the source, or anything but a ServiceControl EF Core persister as the target + +🍅 Unverified: a source whose RavenDB licence has expired. The licence check inside `DatabaseSetup.Execute` is RavenDB's own, polling the server's `/license/status` and refusing an expired one, and the read-only lifecycle never calls it. So the one thing that would have blocked this is removed by design. Whether the RavenDB server itself still serves reads on an expired licence is a question about RavenDB's behaviour and licence terms that reading our code cannot answer, and one test would settle it. A customer whose licence has expired is exactly the one most likely to be leaving RavenDB. + +## Migration workflow + +1. Upgrade ServiceControl as normal, still on RavenDB. +2. Set four things in configuration: the new `PersistenceType`, its connection string, `MigrationMode=true`, and which [optional data](#data-to-be-migrated) they want copied. +3. Run `--setup` to creates the SQL schema. It fails against a SQL Server instance without Full-Text Search installed. +4. Run the [dry run](#dry-run). It reports what it resolved as a source, what each category holds, 🍅 and how long ServiceControl will be closed, so nobody starts a copy without knowing what it will do. +5. Start ServiceControl (`MigrationMode=true`). +6. Every check runs before a single row moves. If one fails the host does not start and names which, having copied nothing, so a wrong database name or unconfigured body storage costs a restart rather than a half-finished migration. +7. The copying of [required data](#required) starts, with ServiceControl still closed. This is assumed to be a small amount of data. +8. ServiceControl opens, and whatever [optional data](#optional) they asked for is copied in the background while the instance runs normally. They can watch it from ServicePulse custom checks and events, but not steer it. +9. They run the verification pass once the background job has completed, which reports row counts on both sides category by category, accounting for deliberate skips so a difference is explained rather than reported as a fault, then set `MigrationMode=false` and restart. +10. RavenDB data can be removed. + +- If `MigrationMode=false` is set while a selected category is still incomplete, the host refuses to start and names exactly what is outstanding. +- A category that ended *complete with errors* counts as complete and does not block, though its skipped count is printed so the loss is stated rather than silent. +- An explicit override exists for a customer who has changed their mind and accepts leaving data behind. + +## Architecture + +```mermaid +flowchart TB + cfg["Configuration + restart
the only way to change anything"] + checks["Custom checks + activity feed
progress, with no new client needed"] + + subgraph host["One ServiceControl host process, started with MigrationMode = true"] + direction LR + raven["RavenDB persister
own AssemblyLoadContext
new read-only lifecycle"] + engine["MigrationEngine
categories, throttle,
dry run, verification"] + target["EF Core persister
SQL Server or PostgreSQL
own AssemblyLoadContext"] + raven -->|"IMigrationSource"| engine + engine -->|"IMigrationTarget"| target + end + + old[("Old RavenDB
read only, never written to")] + sql[("SQL Server or PostgreSQL
plus a new checkpoint table")] + bodies[("Message body store
filesystem, Azure Blob or S3")] + + cfg --> host + host --> checks + old --> raven + target --> sql + target --> bodies +``` + +- **No provider-specific code in the engine or the source.** The source is always RavenDB and the target is always an `IPersistence`. +- **Both persisters load into the same process**, each into its own `AssemblyLoadContext`. +- **The engine references neither assembly.** It knows only `IMigrationSource` and `IMigrationTarget`, and treats the resume cursor as an opaque value it passes from one to the other, so it can be tested against fakes on either side. + +## Startup sequence + +```mermaid +flowchart TB + A["Restart with MigrationMode = true"] --> B["Open the SQL target, exactly as today"] + B --> C["Open the old RavenDB, read only"] + C --> D{"All checks pass?"} + D -->|"No"| E["Host does not start.
Says which check failed.
Nothing has been copied."] + D -->|"Yes"| F["Copy what cannot be recreated.
Minutes. ServiceControl still closed."] + F --> G["ServiceControl opens.
New failed messages go straight to SQL."] + G --> H["Copy the selected history in the background,
throttled behind normal ingestion"] + H --> I["Verify row counts on both sides,
category by category"] + I --> J{"MigrationMode = false,
everything complete?"} + J -->|"No"| K["Host does not start.
Names what is outstanding.
An override exists."] + J -->|"Yes"| L["RavenDB is never opened again"] +``` + +**Checked before a single row moves:** + +- The SQL schema is current +- Message body storage is writable +- Both RavenDB databases are reachable +- The source is at a version this build can read +- The selected categories are valid + +## Data to be migrated (Categories) + +### Required + +- Unresolved failed messages, with their bodies. Attempt history collapses to the newest attempt, because the SQL model has no attempts table 🍅 A prerequisite to migration could be for a customer to clean this up first. i.e. archive. This will move the failed message to optional and can be migrated in the background. +- Message redirects +- Endpoint settings +- Known endpoints, including the monitored flag. One category, because the flag is a property of the endpoint row and cannot be copied without it +- Notification settings +- The licence trial end date +- Throughput history +- Retry operations, unacknowledged and historic. One category, because RavenDB holds both lists in a single document +- Licensing report masks +- The uploaded licensed endpoint details file, which nothing recomputes: skipping it means the customer re-downloads it from the licence portal and uploads it again +- Subscriptions + +### Optional + +- Archived and resolved failed messages: the biggest category by far, and most of the copying time +- The event log, without which the ServicePulse activity feed starts empty +- Custom checks, which cost almost nothing to skip because every check re-reports on its next interval +- Group comments and failed error imports +- Failed message edits + +### Not migrated + +- The fifteen RavenDB index definitions, which map to a much smaller set of ordinary SQL indexes, and two of which are dead already +- The transient in-flight collections, which are empty when nothing is running +- `ArchiveBatches`, which exists only because of how RavenDB works +- Integration events still waiting to be sent when you switch over are never sent +- Broker and audit service version details, which refill on the throughput collector's next run + +## Reading from RavenDB + +- A third RavenDB lifecycle opens the source: connect, check the version, stop. It never calls `DatabaseSetup.Execute`. +- Both source databases must be on the same server or cluster (`LicensingDataStore.cs:35`). +- 🍅 The only version check that exists today compares the RavenDB server version to the RavenDB client version, and runs only for an external source. Nothing stamps a ServiceControl data version in the database, so refusing an unsupported source and naming the version to upgrade through is new work. +- Duration scales with distance to the source. Each body costs two round trips, a document load and an attachment fetch, and egress out of RavenDB Cloud is billed to the customer. + +## Writing to SQL + +- A whole `FailedMessage` is written with its stored status intact. No existing caller does that, though the dialect upsert already accepts a status, so the gap is smaller than it looks. +- `UniqueMessageId` keeps its value, but converts type: the source holds a string and the target column is a `uniqueidentifier`. It is the primary key, the ServicePulse URL, the retry correlation key and the body lookup key at once. +- `StatusChangedAt` is reconstructed from `@expires` for resolved and archived messages, which is the only place RavenDB sets it. Unresolved and retry-issued messages have no `@expires`, so the copier uses the newest processing attempt's timestamp. The column is `NOT NULL`, so it cannot be left empty, but the value is harmless for those two: the retention sweep only considers resolved and archived rows, so an unresolved message never ages out whatever is written here. +- Message bodies go through `IBodyStoragePersistence`, which owns the compression threshold and the choice of filesystem, Azure Blob or S3. The separate 102,400-byte inline threshold is not there: it lives on the ingestion path, so the copier has to apply it rather than inherit it. +- Throughput rows are written directly rather than through the collector, and the write sets each day's count rather than adding to it. +- 🍅 Three identifiers narrow on the way across, and the dry run counts all three: throughput endpoint names are lower-cased into the key; subscriptions lose the message-type version, so two subscriptions differing only in major version merge onto one row; and a subscription key longer than 200 characters cannot be stored at all. + +## Batching and throttling + +- Batch size comes from the provider: SQL Server computes it from the 2,100-parameter ceiling, PostgreSQL uses a flat 50. +- The throttle is a configurable pause between batches. Lowering it, or turning `MigrationMode` off, is the only remedy for a copy competing with production. + +## Checkpointing and resume + +- 🍅 The checkpoint table is a new migration on the target. +- It holds one row per category: the selection that row ran with, the state, the resume cursor, copied and skipped counts, timings and the last error. +- Only the copier writes to it. The status command reads it. +- Rows and the resume cursor commit in one transaction. +- If a message is in both databases the SQL row wins and the copier skips it, so every category is safe to run twice. +- Each category has its own cursor, so a half-copied category picks up where it stopped. + +## Error handling + +- A `UniqueMessageId` that will not parse as a GUID is skipped and counted. +- A message whose body cannot be read is skipped whole, after three attempts. Exhausted attempts count toward the halt threshold. +- A bad row does not stop the copy. Its category finishes in a separate complete-with-errors state. + +## Dry run + +Runnable before anything starts, and again later against whatever is still outstanding. It never writes to RavenDB. + +What it resolves and reports: + +- Whether the source is embedded or external, and which server +- Which two RavenDB databases, and the setting each name came from +- What it found in each of them +- Rows per category, and message-body volume per category +- A duration for the window while ServiceControl is closed, as a range + +It runs the same six checks that gate startup, so a missing setting surfaces before a customer books an outage. + +It counts, before anything moves, the rows that cannot cross as they stand: + +- Documents whose `UniqueMessageId` will not parse as a GUID +- Throughput endpoint names that differ only in case, and so merge onto one row +- Subscriptions that differ only in message-type version, and so merge onto one row +- Subscriptions whose message type or transport address exceeds the 200-character key limit +- Integration event dispatches still queued, which are not copied and will never be sent + +It reports no duration for the optional categories, and nothing about load on the source. + +## Configuration and control + +- A customer sets `MigrationMode` and the list of categories next to it. A status command and a custom check report back. +- Categories are read fresh at every startup. Adding one copies it on the next restart, removing one deletes nothing. +- There is no HTTP API, no pause, no resume, no abort, and no way to add a category to a running instance. All of those mean editing configuration and restarting. +- The checkpoint table is a record of what happened, not a control channel. +- Stopping a copy takes a restart, so it cannot be stopped in ten seconds. + +## Out of scope + +- The audit instance, which has no EF Core persister at all, so a customer who finishes this migration is still running RavenDB for audit. The documentation needs to say so plainly and early +- The monitoring instance, which keeps its data in memory, so there is nothing to move From 7f1a10001fee4f9c2d11888f6159f660041d820a Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 10 Sep 2026 14:35:06 +0800 Subject: [PATCH 02/15] Refine migration overview documentation for RavenDB to SQL transition --- docs/ravendb-to-sql-migration-overview.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/ravendb-to-sql-migration-overview.md b/docs/ravendb-to-sql-migration-overview.md index 8c5cecb922..60188b7acd 100644 --- a/docs/ravendb-to-sql-migration-overview.md +++ b/docs/ravendb-to-sql-migration-overview.md @@ -62,16 +62,13 @@ The copier runs inside the ServiceControl host, so every row and every message b - Any server-to-server copy: no backup and restore, no RavenDB ETL or replication into SQL, no external data pipeline - A host that can reach only one of the two databases at a time, so no staged move by way of an offline copy - Primary and throughput RavenDB databases in different locations -- 🍅 Merging two ServiceControl instances into one SQL database, or splitting one instance across several - Anything but RavenDB as the source, or anything but a ServiceControl EF Core persister as the target -🍅 Unverified: a source whose RavenDB licence has expired. The licence check inside `DatabaseSetup.Execute` is RavenDB's own, polling the server's `/license/status` and refusing an expired one, and the read-only lifecycle never calls it. So the one thing that would have blocked this is removed by design. Whether the RavenDB server itself still serves reads on an expired licence is a question about RavenDB's behaviour and licence terms that reading our code cannot answer, and one test would settle it. A customer whose licence has expired is exactly the one most likely to be leaving RavenDB. - ## Migration workflow 1. Upgrade ServiceControl as normal, still on RavenDB. 2. Set four things in configuration: the new `PersistenceType`, its connection string, `MigrationMode=true`, and which [optional data](#data-to-be-migrated) they want copied. -3. Run `--setup` to creates the SQL schema. It fails against a SQL Server instance without Full-Text Search installed. +3. Run `--setup` to create the SQL schema. It fails against a SQL Server instance without Full-Text Search installed. 4. Run the [dry run](#dry-run). It reports what it resolved as a source, what each category holds, 🍅 and how long ServiceControl will be closed, so nobody starts a copy without knowing what it will do. 5. Start ServiceControl (`MigrationMode=true`). 6. Every check runs before a single row moves. If one fails the host does not start and names which, having copied nothing, so a wrong database name or unconfigured body storage costs a restart rather than a half-finished migration. @@ -137,6 +134,7 @@ flowchart TB - The SQL schema is current - Message body storage is writable - Both RavenDB databases are reachable +- The client certificate is valid, where the source is an external server - The source is at a version this build can read - The selected categories are valid @@ -144,7 +142,7 @@ flowchart TB ### Required -- Unresolved failed messages, with their bodies. Attempt history collapses to the newest attempt, because the SQL model has no attempts table 🍅 A prerequisite to migration could be for a customer to clean this up first. i.e. archive. This will move the failed message to optional and can be migrated in the background. +- Unresolved failed messages, with their bodies. Attempt history collapses to the newest attempt, because the SQL model has no attempts table. - Message redirects - Endpoint settings - Known endpoints, including the monitored flag. One category, because the flag is a property of the endpoint row and cannot be copied without it @@ -171,6 +169,7 @@ flowchart TB - `ArchiveBatches`, which exists only because of how RavenDB works - Integration events still waiting to be sent when you switch over are never sent - Broker and audit service version details, which refill on the throughput collector's next run +- The last computed licensed-endpoint count, which is recomputed from the throughput data that is being copied ## Reading from RavenDB @@ -207,6 +206,10 @@ flowchart TB - A `UniqueMessageId` that will not parse as a GUID is skipped and counted. - A message whose body cannot be read is skipped whole, after three attempts. Exhausted attempts count toward the halt threshold. - A bad row does not stop the copy. Its category finishes in a separate complete-with-errors state. +- A row already past the target's retention cutoff is skipped and counted. The sweeper would delete it within the hour, so copying it would write a body to blob storage for nothing. Two retention periods are in play: the source's reverses `@expires` back into the status-change instant, and the target's current one decides whether that instant is past the cutoff. +- A group comment whose failure group has no messages left in the target is skipped and counted. RavenDB never expires comments but the SQL sweeper reclaims orphans, so copying one writes a row the sweeper deletes within the hour. +- The halt threshold is proportional with an absolute floor, and a category halts only when both are exceeded. Proportional alone halts a three-row category on one bad row; absolute alone lets ten thousand failures pass on a five-million-row table as "only 0.2%". +- Verification therefore cannot treat any count difference as a fault. It accounts for all four skip rules, or it reports every successful migration as broken. ## Dry run From 9173ec256933dd020fe55e3d897afd5a02689934 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 10 Sep 2026 14:46:20 +0800 Subject: [PATCH 03/15] Clarify documentation on RavenDB source requirements for containerized ServiceControl migration --- docs/ravendb-to-sql-migration-overview.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/ravendb-to-sql-migration-overview.md b/docs/ravendb-to-sql-migration-overview.md index 60188b7acd..bef9fa6af7 100644 --- a/docs/ravendb-to-sql-migration-overview.md +++ b/docs/ravendb-to-sql-migration-overview.md @@ -38,7 +38,7 @@ The copier runs inside the ServiceControl host, so every row and every message b **Supported locations:** -- **The RavenDB source**: embedded on the ServiceControl host, self-hosted on the same network in a container, VM or bare metal, or RavenDB Cloud +- **The RavenDB source**: embedded on the ServiceControl host (Windows installations only, see below), self-hosted on the same network in a container, VM or bare metal, or RavenDB Cloud - **The SQL target**: SQL Server or PostgreSQL on the ServiceControl host, elsewhere on the same network, in a container, or as a managed cloud service such as Azure SQL, Amazon RDS or Google Cloud SQL - **Any combination of the two**, subject to the requirements below @@ -61,6 +61,7 @@ The copier runs inside the ServiceControl host, so every row and every message b - Any server-to-server copy: no backup and restore, no RavenDB ETL or replication into SQL, no external data pipeline - A host that can reach only one of the two databases at a time, so no staged move by way of an offline copy +- **An embedded RavenDB source when ServiceControl runs in a container.** Reading an embedded database means starting a RavenDB server process, and the container image does not carry one: `ServiceControl.Persistence.RavenDB.csproj:36` excludes the `RavenDBServer` directory from the artifact, and the copy that would restore it at `:44` is conditional on `CI` not being set, which the Dockerfile sets. A containerised instance migrating away from embedded RavenDB has to point at an external RavenDB server rather than at a data directory. Windows installations are unaffected: the installer unzips the server unconditionally - Primary and throughput RavenDB databases in different locations - Anything but RavenDB as the source, or anything but a ServiceControl EF Core persister as the target @@ -142,7 +143,7 @@ flowchart TB ### Required -- Unresolved failed messages, with their bodies. Attempt history collapses to the newest attempt, because the SQL model has no attempts table. +- Unresolved **and retry-issued** failed messages, with their bodies. Attempt history collapses to the newest attempt, because the SQL model has no attempts table. Retry-issued messages are required for the same reason unresolved ones are: issuing a retry deletes the expiry, so they never age out. Leaving one behind means the retry confirmation arrives with no row to mark resolved, and the message stays missing from the customer's list while the retry actually succeeded - Message redirects - Endpoint settings - Known endpoints, including the monitored flag. One category, because the flag is a property of the endpoint row and cannot be copied without it From 82ea176dd57e0e36dac23b93a8079f1c535dcdc8 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 11 Sep 2026 14:04:53 +0800 Subject: [PATCH 04/15] Enhance migration overview documentation for RavenDB to SQL transition, clarifying strategies, goals, and migration limitations. --- docs/ravendb-to-sql-migration-overview.md | 83 +++++++++++++++++------ 1 file changed, 62 insertions(+), 21 deletions(-) diff --git a/docs/ravendb-to-sql-migration-overview.md b/docs/ravendb-to-sql-migration-overview.md index bef9fa6af7..93306b9566 100644 --- a/docs/ravendb-to-sql-migration-overview.md +++ b/docs/ravendb-to-sql-migration-overview.md @@ -7,15 +7,15 @@ A customer can already point ServiceControl at SQL Server or PostgreSQL. They ca ## Strategy - Switch over first, and copy only what has to be copied. Retention does most of the work: error retention is between 5 and 45 days and event retention is shorter, so most of the source ages out on its own within weeks. That is why archived and resolved messages are optional rather than required. Retention would have deleted them anyway. -- The required set is small because unresolved failures are the only category a customer can act on, and its assumed that customers keep that number low by resolving and archiving. +- The required set is small because unresolved failures are the only category a customer can act on, and the strategy assumes customers keep that number low by resolving and archiving. **A neglected instance breaks that assumption**: unresolved failures can legitimately be months old, and a large backlog of them makes the closed window long rather than short. The dry run is what tells a customer which case they are in. - Anything not selected simply ages out of RavenDB, and the customer deletes the old database when they are ready. ## Goals - **Minimal downtime**. Only the required data copies with ServiceControl closed. Optional data copies in the background while it serves traffic. - **All three RavenDB sources are supported**. Embedded, a container, or RavenDB Cloud, on one code path rather than three. -- **No writes through the client**. Nothing the copier does changes the source, so the old database stays a valid fallback. -- **Abandonable up to a known point**. Free while ServiceControl is closed. After it opens, abandoning loses whatever SQL has ingested since. +- **No writes through the client**. The copier never changes the source, but RavenDB's own expiration does: the primary database already has it configured, and the sweep keeps deleting failed messages and event log items throughout the migration and for as long afterwards as the instance is left running. The old database is a fallback that degrades from the moment you start. +- **Abandonable up to a known point, and only up to that point**. While ServiceControl is closed the copy can be thrown away at no cost, because nothing but the copier has written to SQL and RavenDB is untouched: see [the one point you can go back](#the-one-point-you-can-go-back). Once the host opens there is no way back at all. - **No duplicates and no gaps**. Rows and the resume cursor commit in one transaction, so a crash needs no reconciliation. - **Every identifier anything depends on is carried across**. The event log and historic retry operations are renumbered, because nothing references their keys. - **Refuse rather than half-migrate**. Every check runs before the first row moves, and a failure is a host that will not start. @@ -24,10 +24,12 @@ A customer can already point ServiceControl at SQL Server or PostgreSQL. They ca - **Known before it starts, visible while it runs**. A dry run reports what will move and how long ServiceControl is closed, and every category transition is reported as it happens. - **Use existing functionality where possible**. Progress goes through custom checks and the activity feed, so no new client or screen is needed. -## Nice to have, but not planned in this initial design (phase 1) +## Deliberately not built, and not currently planned + +*Each of these was considered and left out. None of them is scheduled: if one becomes a requirement it is new design work, not a later increment of this one.* - **Zero downtime.** The required data is copied with ServiceControl closed, so there is a real, if short, outage. -- **Reversible once ServiceControl opens.** Nothing copies SQL rows back to RavenDB. +- **Reversible once ServiceControl opens.** Nothing copies SQL rows back to RavenDB, so once the host has served traffic there is no rollback of any kind. - **Steerable while running.** No pause, resume, or abort. Changing anything means editing configuration and restarting. - **A general-purpose migration tool.** The source is always RavenDB and the target is always a ServiceControl EF Core persister, both at versions this build can read. - **Custom migration UI via ServicePulse.** Custom checks and the event log will be used for progress reporting, but migration configuration and migration engine control will not be available via the UI. @@ -70,7 +72,7 @@ The copier runs inside the ServiceControl host, so every row and every message b 1. Upgrade ServiceControl as normal, still on RavenDB. 2. Set four things in configuration: the new `PersistenceType`, its connection string, `MigrationMode=true`, and which [optional data](#data-to-be-migrated) they want copied. 3. Run `--setup` to create the SQL schema. It fails against a SQL Server instance without Full-Text Search installed. -4. Run the [dry run](#dry-run). It reports what it resolved as a source, what each category holds, 🍅 and how long ServiceControl will be closed, so nobody starts a copy without knowing what it will do. +4. Run the [dry run](#dry-run). It reports what it resolved as a source, what each category holds, and an estimate of how long ServiceControl will be closed. Read [what the estimate is worth](#what-the-estimate-is-worth) before booking an outage around it. 5. Start ServiceControl (`MigrationMode=true`). 6. Every check runs before a single row moves. If one fails the host does not start and names which, having copied nothing, so a wrong database name or unconfigured body storage costs a restart rather than a half-finished migration. 7. The copying of [required data](#required) starts, with ServiceControl still closed. This is assumed to be a small amount of data. @@ -80,7 +82,9 @@ The copier runs inside the ServiceControl host, so every row and every message b - If `MigrationMode=false` is set while a selected category is still incomplete, the host refuses to start and names exactly what is outstanding. - A category that ended *complete with errors* counts as complete and does not block, though its skipped count is printed so the loss is stated rather than silent. -- An explicit override exists for a customer who has changed their mind and accepts leaving data behind. +- An explicit override exists for a customer who has changed their mind and accepts leaving data behind. It marks the outstanding categories as abandoned, which is a deliberate end state rather than a failure, so the progress check settles and the guard stays armed for any later migration. +- **Steps 5 to 7 are the abort window**, which is not the override above: see [the one point you can go back](#the-one-point-you-can-go-back). +- A category that stops because too many rows failed is *halted*, and restarting will halt it again. Fix the cause and restart to resume it, or abandon it deliberately if you accept the loss. ## Architecture @@ -138,6 +142,7 @@ flowchart TB - The client certificate is valid, where the source is an external server - The source is at a version this build can read - The selected categories are valid +- `RetryHistoryDepth` is greater than zero. At zero or less, the first completed retry after the migration deletes the entire copied retry history, and no row count would ever show it ## Data to be migrated (Categories) @@ -160,7 +165,8 @@ flowchart TB - Archived and resolved failed messages: the biggest category by far, and most of the copying time - The event log, without which the ServicePulse activity feed starts empty - Custom checks, which cost almost nothing to skip because every check re-reports on its next interval -- Group comments and failed error imports +- Failed error imports, the record of errors that could not be ingested +- Group comments, **copied last of everything**, after archived and resolved messages. A comment survives only once the failed messages its group is built from have arrived, so on a large archive the comments are the last thing to appear. An empty comment field partway through a migration is the copy still running, not data loss - Failed message edits ### Not migrated @@ -172,12 +178,40 @@ flowchart TB - Broker and audit service version details, which refill on the throughput collector's next run - The last computed licensed-endpoint count, which is recomputed from the throughput data that is being copied +## What does not come across + +**Whole categories are never copied.** Which ones, and why nothing needs them, is the [not migrated](#not-migrated) list above. Anything in an optional category you did not select is also never copied, and nothing later goes back for it. + +**Rows skipped one at a time, and counted.** Each of these shows up in the skipped count for its category, broken out by reason, so you can see how much went and why: + +- A failed message whose `UniqueMessageId` is not a GUID. The target column is a `uniqueidentifier` and the value is never regenerated, because it is simultaneously the primary key, the ServicePulse URL, the retry correlation key and the body lookup key. +- A failed message with no processing attempts recorded against it. The SQL model keeps the newest attempt and derives the failure time, the failing endpoint and the exception from it, all of which are required columns, so a message with nothing to derive them from cannot be written at all rather than being written blank. +- A failed message whose body cannot be read after three attempts. **The whole message is skipped, not just its body**, because a message with no body is worse than no message. +- A row already past the target's retention cutoff. The sweeper would delete it within the hour, so copying it would write a body to blob storage for nothing. +- A group comment whose failure group has no messages left in the target. RavenDB never expires comments, but the SQL sweeper reclaims orphans. + +**Things that change shape, and are not counted as skips at all.** The dry run counts these before anything moves, so they are a number you see in advance rather than a discovery afterwards. They are also the ones to read twice: + +- **Processing attempt history collapses to the newest attempt.** The SQL model has no attempts table. This affects every failed message that failed more than once, in the one category every customer copies. A message that failed five times arrives showing one attempt, and the other four are gone. +- **Subscriptions that differ only in message-type version merge onto one row**, because the target key carries the type name without the version. +- **A subscription whose message type or transport address exceeds 200 characters cannot be stored at all.** +- **Throughput endpoint names that differ only in case merge onto one row**, because the target key is lower-cased. +- **Event log items and historic retry operations are renumbered.** Their keys are database identities and nothing references them, so this is safe, but the old numbers do not survive. + +**A category can finish with a small amount of loss and still count as complete.** A few skipped rows in a large table leave the category in a *complete with errors* state, which blocks nothing. Its skipped count is printed and the ids of the skipped rows are written to the log, so while the RavenDB database still exists you can go and look at exactly what did not make it. + +## The one point you can go back + +While ServiceControl is closed and the required copy is running, nothing except the copier has written to SQL and RavenDB is untouched and still authoritative. If you need your instance back, set `MigrationMode=false`, point `PersistenceType` back at RavenDB, and start. You lose the copy, not your data, and you can start again later. + +That window closes the moment ServiceControl opens. From then on new failed messages are ingesting into SQL, RavenDB is no longer current, and there is no rollback: nothing copies SQL rows back. The choice at that point is to finish the migration or to accept losing whatever has not been copied. + ## Reading from RavenDB - A third RavenDB lifecycle opens the source: connect, check the version, stop. It never calls `DatabaseSetup.Execute`. - Both source databases must be on the same server or cluster (`LicensingDataStore.cs:35`). -- 🍅 The only version check that exists today compares the RavenDB server version to the RavenDB client version, and runs only for an external source. Nothing stamps a ServiceControl data version in the database, so refusing an unsupported source and naming the version to upgrade through is new work. -- Duration scales with distance to the source. Each body costs two round trips, a document load and an attachment fetch, and egress out of RavenDB Cloud is billed to the customer. +- The source has to be at a ServiceControl version this build can read, and nothing in RavenDB records one today. The only version check that exists compares the RavenDB server version to the RavenDB client version, and runs only for an external source. So a marker is stamped into the database on upgrade, and a source without one, or one from a newer major version, is refused by name rather than misread. +- Duration scales with distance to the source. The copier already holds the document from the stream, so each body costs **one** round trip rather than two, but it is one per message and they are not batched. Egress out of RavenDB Cloud is billed to the customer. See [how long the background copy actually takes](#how-long-the-background-copy-actually-takes). ## Writing to SQL @@ -186,16 +220,16 @@ flowchart TB - `StatusChangedAt` is reconstructed from `@expires` for resolved and archived messages, which is the only place RavenDB sets it. Unresolved and retry-issued messages have no `@expires`, so the copier uses the newest processing attempt's timestamp. The column is `NOT NULL`, so it cannot be left empty, but the value is harmless for those two: the retention sweep only considers resolved and archived rows, so an unresolved message never ages out whatever is written here. - Message bodies go through `IBodyStoragePersistence`, which owns the compression threshold and the choice of filesystem, Azure Blob or S3. The separate 102,400-byte inline threshold is not there: it lives on the ingestion path, so the copier has to apply it rather than inherit it. - Throughput rows are written directly rather than through the collector, and the write sets each day's count rather than adding to it. -- 🍅 Three identifiers narrow on the way across, and the dry run counts all three: throughput endpoint names are lower-cased into the key; subscriptions lose the message-type version, so two subscriptions differing only in major version merge onto one row; and a subscription key longer than 200 characters cannot be stored at all. +- Identifiers narrow on the way across, and the dry run counts every kind. What narrows, merges or cannot be stored at all is in [what does not come across](#what-does-not-come-across). ## Batching and throttling -- Batch size comes from the provider: SQL Server computes it from the 2,100-parameter ceiling, PostgreSQL uses a flat 50. -- The throttle is a configurable pause between batches. Lowering it, or turning `MigrationMode` off, is the only remedy for a copy competing with production. +- Batch size comes from the provider: SQL Server divides its own parameter budget by the column count, PostgreSQL uses a flat 50 rows. +- The throttle is a configurable pause between batches, defaulting to 100 ms. Lowering it, or turning `MigrationMode` off, is the only remedy for a copy competing with production. ## Checkpointing and resume -- 🍅 The checkpoint table is a new migration on the target. +- The checkpoint table is a new table on the target, created by `--setup` along with the rest of the schema. - It holds one row per category: the selection that row ran with, the state, the resume cursor, copied and skipped counts, timings and the last error. - Only the copier writes to it. The status command reads it. - Rows and the resume cursor commit in one transaction. @@ -204,13 +238,12 @@ flowchart TB ## Error handling -- A `UniqueMessageId` that will not parse as a GUID is skipped and counted. -- A message whose body cannot be read is skipped whole, after three attempts. Exhausted attempts count toward the halt threshold. +- Which rows are skipped, and why, is in [what does not come across](#what-does-not-come-across). What follows is the mechanics around those rules. +- A body is read up to three times before the message is skipped whole, and the exhausted attempts count toward the halt threshold. +- Deciding whether a row is past the target's retention cutoff needs two retention periods: the source's reverses `@expires` back into the status-change instant, and the target's current one decides whether that instant is past the cutoff. - A bad row does not stop the copy. Its category finishes in a separate complete-with-errors state. -- A row already past the target's retention cutoff is skipped and counted. The sweeper would delete it within the hour, so copying it would write a body to blob storage for nothing. Two retention periods are in play: the source's reverses `@expires` back into the status-change instant, and the target's current one decides whether that instant is past the cutoff. -- A group comment whose failure group has no messages left in the target is skipped and counted. RavenDB never expires comments but the SQL sweeper reclaims orphans, so copying one writes a row the sweeper deletes within the hour. - The halt threshold is proportional with an absolute floor, and a category halts only when both are exceeded. Proportional alone halts a three-row category on one bad row; absolute alone lets ten thousand failures pass on a five-million-row table as "only 0.2%". -- Verification therefore cannot treat any count difference as a fault. It accounts for all four skip rules, or it reports every successful migration as broken. +- Verification therefore cannot treat any count difference as a fault. It accounts for all five skip rules, or it reports every successful migration as broken. ## Dry run @@ -224,7 +257,7 @@ What it resolves and reports: - Rows per category, and message-body volume per category - A duration for the window while ServiceControl is closed, as a range -It runs the same six checks that gate startup, so a missing setting surfaces before a customer books an outage. +It runs the same seven checks that gate startup, so a missing setting surfaces before a customer books an outage. It counts, before anything moves, the rows that cannot cross as they stand: @@ -236,6 +269,14 @@ It counts, before anything moves, the rows that cannot cross as they stand: It reports no duration for the optional categories, and nothing about load on the source. +### When you can run the read-only commands + +`--migration-source-report`, `--migration-verify` and `--migration-dry-run` all open the RavenDB source. **On an embedded source that means stopping the ServiceControl service first**, because a second RavenDB process cannot attach to a data directory the first one holds. Plan the dry run as part of the outage rather than as something you run the day before while the instance keeps serving traffic. On an external source, a container or RavenDB Cloud, all three run against a live instance with no interruption. + +One deployment shape they cannot help at all: all three need shell access to the host, so a containerised instance has no easy way to run them, and a containerised instance cannot use an embedded source either. + +`--migration-status` is the exception and is deliberately so: it reads only the checkpoint table in SQL and never opens the source, so it works on every source shape at any time, including during the background copy. It is the command to use for watching progress. + ## Configuration and control - A customer sets `MigrationMode` and the list of categories next to it. A status command and a custom check report back. @@ -246,5 +287,5 @@ It reports no duration for the optional categories, and nothing about load on th ## Out of scope -- The audit instance, which has no EF Core persister at all, so a customer who finishes this migration is still running RavenDB for audit. The documentation needs to say so plainly and early +- The audit instance, which has no EF Core persister at all, so a customer who finishes this migration is still running RavenDB for audit. This is stated up front under [Problem](#problem), because it changes whether the migration is worth doing at all - The monitoring instance, which keeps its data in memory, so there is nothing to move From 6028d945cdda79de68895926ac88276a9ee68dd7 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Fri, 11 Sep 2026 15:59:44 +0800 Subject: [PATCH 05/15] Implement the foundation for migration engine --- .github/workflows/ci.yml | 8 +- .../.editorconfig | 5 + .../MigrationSourceReportCommandTests.cs | 62 ++++ .../MigrationSourceServer.cs | 94 +++++ .../ServiceControl.Migration.Tests.csproj | 27 ++ .../TwoPersistersInOneProcessTests.cs | 128 +++++++ .../DataMigration/RavenMigrationSource.cs | 41 +++ .../RavenReadOnlySourceLifecycle.cs | 189 ++++++++++ .../RavenPersistenceConfiguration.cs | 9 +- .../ReadOnlySourceLifecycleTests.cs | 346 ++++++++++++++++++ .../DataMigration/IMigrationSource.cs | 16 + .../DataMigration/IMigrationSourceFactory.cs | 6 + .../DataMigration/MigrationSourceDatabase.cs | 7 + .../MigrationSourceDescription.cs | 8 + ...rovals.PlatformSampleSettings.approved.txt | 1 + .../MigrationSourceReportArgumentTests.cs | 21 ++ src/ServiceControl.slnx | 1 + .../Commands/MigrationSourceReportCommand.cs | 56 +++ src/ServiceControl/Hosting/Help.txt | 14 + src/ServiceControl/Hosting/HostArguments.cs | 16 + .../Infrastructure/Settings/Settings.cs | 1 + .../Persistence/PersistenceFactory.cs | 42 ++- src/ServiceControl/ServiceControl.csproj | 1 + 23 files changed, 1089 insertions(+), 10 deletions(-) create mode 100644 src/ServiceControl.Migration.Tests/.editorconfig create mode 100644 src/ServiceControl.Migration.Tests/MigrationSourceReportCommandTests.cs create mode 100644 src/ServiceControl.Migration.Tests/MigrationSourceServer.cs create mode 100644 src/ServiceControl.Migration.Tests/ServiceControl.Migration.Tests.csproj create mode 100644 src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs create mode 100644 src/ServiceControl.Persistence.RavenDB/DataMigration/RavenMigrationSource.cs create mode 100644 src/ServiceControl.Persistence.RavenDB/DataMigration/RavenReadOnlySourceLifecycle.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/ReadOnlySourceLifecycleTests.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/IMigrationSource.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/IMigrationSourceFactory.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/MigrationSourceDatabase.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/MigrationSourceDescription.cs create mode 100644 src/ServiceControl.UnitTests/Hosting/MigrationSourceReportArgumentTests.cs create mode 100644 src/ServiceControl/Hosting/Commands/MigrationSourceReportCommand.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f9632f42a..8e5d6fda64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: # Categories are declared by the property in each test project. A category can # span several projects that share infrastructure, so that the container is provisioned once # and the union of their build closures is compiled once. - test-category: [ DefaultCore, DefaultMonitoring, Raven, SqlServer, PostgreSql, RabbitMQ, AzureServiceBus, AzureStorageQueues, MSMQ, SQS, IBMMQ ] + test-category: [ DefaultCore, DefaultMonitoring, Migration, Raven, SqlServer, PostgreSql, RabbitMQ, AzureServiceBus, AzureStorageQueues, MSMQ, SQS, IBMMQ ] include: - os: windows-latest os-name: Windows @@ -87,7 +87,7 @@ jobs: id: select run: ./tools/select-test-projects.ps1 -Category ${{ matrix.test-category }} - name: Download RavenDB Server - if: matrix.test-category == 'DefaultCore' + if: contains(fromJSON('["DefaultCore", "Migration"]'), matrix.test-category) run: ./tools/download-ravendb-server.ps1 - name: Build id: build @@ -128,14 +128,14 @@ jobs: # purpose: with the VM and the build competing for the runner's memory, jobs have died with "The # hosted runner lost communication with the server". - name: Setup WSL - if: contains(fromJSON('["RabbitMQ", "SqlServer", "PostgreSql", "IBMMQ"]'), matrix.test-category) + if: contains(fromJSON('["RabbitMQ", "SqlServer", "PostgreSql", "IBMMQ", "Migration"]'), matrix.test-category) uses: Particular/setup-wsl-action@v1.2.0 with: # The action defaults to 4GB. The runner has 16GB, so give the VM real headroom. memory: 8GB - name: Setup SQL Server uses: Particular/install-sql-server-action@v3.0.0 - if: matrix.test-category == 'SqlServer' + if: contains(fromJSON('["SqlServer", "Migration"]'), matrix.test-category) with: connection-string-env-var: ServiceControl_Persistence_SqlServer_ConnectionString catalog: ServiceControl diff --git a/src/ServiceControl.Migration.Tests/.editorconfig b/src/ServiceControl.Migration.Tests/.editorconfig new file mode 100644 index 0000000000..ca5ad8bd2e --- /dev/null +++ b/src/ServiceControl.Migration.Tests/.editorconfig @@ -0,0 +1,5 @@ +[*.cs] + +# Justification: Test project +dotnet_diagnostic.CA2007.severity = none +dotnet_diagnostic.PS0018.severity = none diff --git a/src/ServiceControl.Migration.Tests/MigrationSourceReportCommandTests.cs b/src/ServiceControl.Migration.Tests/MigrationSourceReportCommandTests.cs new file mode 100644 index 0000000000..8184bc21de --- /dev/null +++ b/src/ServiceControl.Migration.Tests/MigrationSourceReportCommandTests.cs @@ -0,0 +1,62 @@ +namespace ServiceControl.Migration.Tests; + +using System; +using System.IO; +using System.Threading.Tasks; +using NUnit.Framework; +using Particular.ServiceControl.Hosting; +using ServiceBus.Management.Infrastructure.Settings; +using ServiceControl.Hosting.Commands; + +[TestFixture] +[NonParallelizable] +class MigrationSourceReportCommandTests +{ + [SetUp] + public void SetUp() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_ERRORRETENTIONPERIOD", "10.00:00:00"); + Environment.SetEnvironmentVariable("SERVICECONTROL_RAVENDB_CONNECTIONSTRING", MigrationSourceServer.ServerUrl); + Environment.SetEnvironmentVariable("SERVICECONTROL_RAVENDB_DATABASENAME", MigrationSourceServer.PrimaryDatabase); + Environment.SetEnvironmentVariable("LICENSINGCOMPONENT_RAVENDB_THROUGHPUTDATABASENAME", MigrationSourceServer.ThroughputDatabase); + } + + [TearDown] + public void TearDown() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_ERRORRETENTIONPERIOD", null); + Environment.SetEnvironmentVariable("SERVICECONTROL_RAVENDB_CONNECTIONSTRING", null); + Environment.SetEnvironmentVariable("SERVICECONTROL_RAVENDB_DATABASENAME", null); + Environment.SetEnvironmentVariable("LICENSINGCOMPONENT_RAVENDB_THROUGHPUTDATABASENAME", null); + } + + [Test] + public async Task The_report_names_the_databases_the_settings_and_the_collections() + { + var settings = new Settings(persisterType: "RavenDB", forwardErrorMessages: false, errorRetentionPeriod: TimeSpan.FromDays(10)); + + var writer = new StringWriter(); + var original = Console.Out; + Console.SetOut(writer); + + try + { + await new MigrationSourceReportCommand().Execute(new HostArguments([]), settings); + } + finally + { + Console.SetOut(original); + } + + var report = writer.ToString(); + + Assert.Multiple(() => + { + Assert.That(report, Does.Contain("(external)")); + Assert.That(report, Does.Contain(MigrationSourceServer.ServerUrl)); + Assert.That(report, Does.Contain("from ServiceControl/RavenDB/DatabaseName")); + Assert.That(report, Does.Contain("from LicensingComponent/RavenDB/ThroughputDatabaseName")); + Assert.That(report, Does.Match(@"EndpointSettings\s+1"), "The report has to render a count, not just name the collection."); + }); + } +} diff --git a/src/ServiceControl.Migration.Tests/MigrationSourceServer.cs b/src/ServiceControl.Migration.Tests/MigrationSourceServer.cs new file mode 100644 index 0000000000..9b6e933c03 --- /dev/null +++ b/src/ServiceControl.Migration.Tests/MigrationSourceServer.cs @@ -0,0 +1,94 @@ +namespace ServiceControl.Migration.Tests; + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; +using Raven.Client.Documents; +using Raven.Client.Documents.Conventions; +using Raven.Client.ServerWide; +using Raven.Client.ServerWide.Operations; +using ServiceControl.Persistence; +using ServiceControl.RavenDB; +using TestHelper; + +/// +/// One embedded RavenDB for the whole assembly, holding a seeded database that stands in for a +/// customer's old instance. EmbeddedServer.Instance is process wide, so there can only be one. +/// +static class MigrationSourceServer +{ + public static string ServerUrl { get; private set; } + + public static string PrimaryDatabase { get; private set; } + + public static string ThroughputDatabase { get; private set; } + + public static async Task Start(CancellationToken cancellationToken = default) + { + var dbPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "MigrationSource", "Data"); + var logPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "MigrationSource", "Logs"); + var port = PortUtility.GetAssignedOrAvailablePort(33377); + + ServerUrl = $"http://localhost:{port}"; + PrimaryDatabase = "primary"; + ThroughputDatabase = "throughput"; + + var configuration = new EmbeddedDatabaseConfiguration(ServerUrl, PrimaryDatabase, dbPath, logPath, "Operations") { RunInMemory = true }; + database = EmbeddedDatabase.Start(configuration, lifetime); + + using var store = await database.Connect(cancellationToken); + + await store.Maintenance.Server.SendAsync(new CreateDatabaseOperation(new DatabaseRecord(PrimaryDatabase)), cancellationToken); + await store.Maintenance.Server.SendAsync(new CreateDatabaseOperation(new DatabaseRecord(ThroughputDatabase)), cancellationToken); + + using var seeder = new DocumentStore + { + Urls = [ServerUrl], + Database = PrimaryDatabase, + Conventions = new DocumentConventions { SaveEnumsAsIntegers = true } + }.Initialize(); + + using var session = seeder.OpenAsyncSession(); + await session.StoreAsync(new EndpointSettings { Name = "Sales", TrackInstances = true }, "EndpointSettings/1", cancellationToken); + await session.SaveChangesAsync(cancellationToken); + } + + public static async Task Stop() + { + if (database is null) + { + return; + } + + await database.Stop(CancellationToken.None); + database.Dispose(); + database = null; + lifetime.StopApplication(); + } + + static EmbeddedDatabase database; + static readonly TestLifetime lifetime = new(); + + sealed class TestLifetime : IHostApplicationLifetime + { + public CancellationToken ApplicationStarted => CancellationToken.None; + public CancellationToken ApplicationStopping => stopping.Token; + public CancellationToken ApplicationStopped => CancellationToken.None; + public void StopApplication() => stopping.Cancel(); + + readonly CancellationTokenSource stopping = new(); + } +} + +[SetUpFixture] +public class MigrationSourceServerFixture +{ + [OneTimeSetUp] + public Task StartSource() => MigrationSourceServer.Start(); + + [OneTimeTearDown] + public Task StopSource() => MigrationSourceServer.Stop(); +} diff --git a/src/ServiceControl.Migration.Tests/ServiceControl.Migration.Tests.csproj b/src/ServiceControl.Migration.Tests/ServiceControl.Migration.Tests.csproj new file mode 100644 index 0000000000..8b9f287883 --- /dev/null +++ b/src/ServiceControl.Migration.Tests/ServiceControl.Migration.Tests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + Migration + + + + + + + + + + + + + + + + + + + diff --git a/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs b/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs new file mode 100644 index 0000000000..af0c18df37 --- /dev/null +++ b/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs @@ -0,0 +1,128 @@ +namespace ServiceControl.Migration.Tests; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.Loader; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using ServiceBus.Management.Infrastructure.Settings; +using ServiceControl.Infrastructure; +using ServiceControl.Persistence; +using ServiceControl.Persistence.DataMigration; + +[TestFixture] +[NonParallelizable] +class TwoPersistersInOneProcessTests +{ + Settings settings; + string bodyStoragePath; + + [SetUp] + public void SetUp() + { + bodyStoragePath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "Bodies", Guid.NewGuid().ToString("n")); + + // Both persistence configurations read ErrorRetentionPeriod through the settings reader rather + // than off the Settings object, so the constructor argument below does not satisfy either of them. + SetVariable("SERVICECONTROL_ERRORRETENTIONPERIOD", "10.00:00:00"); + // CI's install-sql-server-action publishes only this variable and creates the catalog + // ServiceControl; a developer machine has neither, so the local default is the fallback. + SetVariable("SERVICECONTROL_DATABASE_CONNECTIONSTRING", + Environment.GetEnvironmentVariable("ServiceControl_Persistence_SqlServer_ConnectionString") + ?? "Server=localhost;Database=ServiceControl;Trusted_Connection=True;TrustServerCertificate=True"); + SetVariable("SERVICECONTROL_MESSAGEBODY_STORAGETYPE", "FileSystem"); + SetVariable("SERVICECONTROL_MESSAGEBODY_FILESYSTEM_STORAGEPATH", bodyStoragePath); + SetVariable("SERVICECONTROL_RAVENDB_CONNECTIONSTRING", MigrationSourceServer.ServerUrl); + SetVariable("SERVICECONTROL_RAVENDB_DATABASENAME", MigrationSourceServer.PrimaryDatabase); + SetVariable("LICENSINGCOMPONENT_RAVENDB_THROUGHPUTDATABASENAME", MigrationSourceServer.ThroughputDatabase); + + settings = new Settings(persisterType: "SQLServer", forwardErrorMessages: false, errorRetentionPeriod: TimeSpan.FromDays(10)); + } + + [TearDown] + public void TearDown() + { + foreach (var name in variables) + { + Environment.SetEnvironmentVariable(name, null); + } + + variables.Clear(); + } + + [Test] + public async Task A_source_and_a_target_load_side_by_side_and_share_one_type_identity() + { + Assert.That(settings.AssemblyLoadContextResolver(typeof(Settings).Assembly.Location), Is.InstanceOf(), + "This test is meaningless if the resolver is pinned to AssemblyLoadContext.Default the way every acceptance test pins it."); + + var services = new ServiceCollection(); + services.AddPersistence(settings); + var targetSettings = settings.PersisterSpecificSettings; + + await using var source = await PersistenceFactory.OpenMigrationSource(settings); + + var sourceContext = AssemblyLoadContext.GetLoadContext(source.GetType().Assembly); + var targetContext = AssemblyLoadContext.GetLoadContext(settings.PersisterSpecificSettings.GetType().Assembly); + + Assert.Multiple(() => + { + Assert.That(sourceContext, Is.Not.SameAs(AssemblyLoadContext.Default), + "The RavenDB persister must load into its own plugin context, or this proves nothing."); + Assert.That(targetContext, Is.Not.SameAs(AssemblyLoadContext.Default), + "The target persister must be isolated too, otherwise only one half of the pairing is being tested."); + Assert.That(sourceContext, Is.Not.SameAs(targetContext), + "Source and target must land in separate contexts. That separation is the whole feature, and nothing else here asserts it."); + Assert.That(source, Is.InstanceOf(), + "A plugin-context type must still cast to the host's copy of the interface."); + Assert.That(settings.PersisterSpecificSettings, Is.SameAs(targetSettings), + "Opening a source must leave the target's settings object exactly where it was."); + }); + + var description = await source.Describe(); + + Assert.Multiple(() => + { + Assert.That(AssemblyLoadContext.GetLoadContext(description.GetType().Assembly), Is.SameAs(AssemblyLoadContext.Default), + "A shared type produced inside the plugin context must arrive as the host's own type."); + Assert.That(description.PrimaryDatabase, Is.EqualTo(MigrationSourceServer.PrimaryDatabase), + "The source read its own RavenDB settings rather than the target's."); + }); + } + + [Test] + public void Asking_a_SQL_persister_for_a_source_names_the_setting_to_change() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_SOURCEPERSISTENCETYPE", "SQLServer"); + + try + { + var refusal = Assert.ThrowsAsync(async () => await PersistenceFactory.OpenMigrationSource(new Settings(persisterType: "SQLServer", forwardErrorMessages: false, errorRetentionPeriod: TimeSpan.FromDays(10)))); + + Assert.That(refusal.Message, Does.Contain("cannot be read as a migration source").And.Contain("ServiceControl/Migration/SourcePersistenceType")); + } + finally + { + Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_SOURCEPERSISTENCETYPE", null); + } + } + + [Test] + public void Asking_a_SQL_persister_for_maintenance_mode_is_still_refused() + { + var refusal = Assert.Throws(() => PersistenceFactory.Create(settings, maintenanceMode: true)); + + Assert.That(refusal.Message, Does.Contain("Maintenance mode is not supported").And.Contain("SQLServer"), + "PersistenceFactory's SupportsMaintenanceMode guard is what turns a persister mismatch into a sentence instead of a cast exception. A restructure that drops it compiles and passes every other test."); + } + + void SetVariable(string name, string value) + { + Environment.SetEnvironmentVariable(name, value); + variables.Add(name); + } + + readonly List variables = []; +} diff --git a/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenMigrationSource.cs b/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenMigrationSource.cs new file mode 100644 index 0000000000..edf971e7c2 --- /dev/null +++ b/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenMigrationSource.cs @@ -0,0 +1,41 @@ +#nullable enable + +namespace ServiceControl.Persistence.RavenDB.DataMigration; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Raven.Client.Documents.Operations; +using Raven.Client.ServerWide.Operations; +using ServiceControl.Persistence.DataMigration; + +sealed class RavenMigrationSource(RavenReadOnlySourceLifecycle lifecycle) : IMigrationSource +{ + public Task Open(CancellationToken cancellationToken = default) => lifecycle.Open(cancellationToken); + + public async Task Describe(CancellationToken cancellationToken = default) + { + var settings = lifecycle.Settings; + var build = await lifecycle.DocumentStore.Maintenance.Server.SendAsync(new GetBuildNumberOperation(), cancellationToken); + + return new MigrationSourceDescription( + settings.UseEmbeddedServer, + settings.UseEmbeddedServer ? settings.ServerUrl : settings.ConnectionString, + settings.DatabaseName, + settings.ThroughputDatabaseName, + build.ProductVersion); + } + + public async Task> CountCollections(MigrationSourceDatabase database, CancellationToken cancellationToken = default) + { + var databaseName = database == MigrationSourceDatabase.Primary + ? lifecycle.Settings.DatabaseName + : lifecycle.Settings.ThroughputDatabaseName; + + var statistics = await lifecycle.DocumentStore.Maintenance.ForDatabase(databaseName).SendAsync(new GetCollectionStatisticsOperation(), cancellationToken); + + return statistics.Collections; + } + + public ValueTask DisposeAsync() => lifecycle.DisposeAsync(); +} diff --git a/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenReadOnlySourceLifecycle.cs b/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenReadOnlySourceLifecycle.cs new file mode 100644 index 0000000000..d5ecc8694f --- /dev/null +++ b/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenReadOnlySourceLifecycle.cs @@ -0,0 +1,189 @@ +#nullable enable + +namespace ServiceControl.Persistence.RavenDB.DataMigration; + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Particular.LicensingComponent.Contracts; +using Raven.Client.Documents; +using Raven.Client.Documents.Conventions; +using Raven.Client.Documents.Operations; +using Raven.Client.Documents.Session; +using Raven.Client.Exceptions.Database; +using Raven.Client.ServerWide.Operations; +using ServiceControl.RavenDB; + +sealed class RavenReadOnlySourceLifecycle(RavenPersisterSettings settings) : IAsyncDisposable +{ + public RavenPersisterSettings Settings => settings; + + public IDocumentStore DocumentStore => documentStore ?? throw new InvalidOperationException($"The migration source is not open. Call {nameof(Open)} first."); + + public async Task Open(CancellationToken cancellationToken = default) + { + if (documentStore is not null) + { + throw new InvalidOperationException("The migration source is already open. Opening it twice would abandon the first server without stopping it."); + } + + try + { + var serverUrl = settings.UseEmbeddedServer ? StartEmbedded() : settings.ConnectionString; + documentStore = Connect(serverUrl); + + if (!settings.UseEmbeddedServer) + { + // Only an external server can be older than the client; an embedded one ships beside it. + await StartupChecks.EnsureServerVersion(documentStore, cancellationToken); + } + + // The persister cannot reach the host's Settings class for the root namespace, so it is spelled + // out here: a customer reads these two keys back out of app.config, not out of code. + await EnsureReadable(settings.DatabaseName, $"ServiceControl/{RavenBootstrapper.DatabaseNameKey}", cancellationToken); + await EnsureReadable(settings.ThroughputDatabaseName, $"{ThroughputSettings.SettingsNamespace}/{ThroughputSettings.DatabaseNameKey}", cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await DisposeAsync(); + throw; + } + catch (Exception) + { + await DisposeAsync(); + throw; + } + } + + public IAsyncDocumentSession OpenSession(string databaseName) => + DocumentStore.OpenAsyncSession(new SessionOptions { Database = databaseName, NoTracking = true }); + + async Task EnsureReadable(string databaseName, string settingKey, CancellationToken cancellationToken) + { + var record = await DocumentStore.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(databaseName), cancellationToken); + + if (record is null) + { + throw new InvalidOperationException($"The RavenDB migration source at {Located()} has no database named '{databaseName}'. That name comes from the '{settingKey}' setting. Correct it before migrating: a wrong name reads a database that is not there rather than the one that is."); + } + + await LoadDatabase(databaseName, settingKey, cancellationToken); + } + + async Task LoadDatabase(string databaseName, string settingKey, CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await DocumentStore.Maintenance.ForDatabase(databaseName).SendAsync(new GetStatisticsOperation(), cancellationToken); + return; + } + catch (DatabaseLoadTimeoutException) when (settings.UseEmbeddedServer) + { + // A large embedded database routinely exceeds the load timeout on first open, which + // RavenEmbeddedPersistenceLifecycle already allows for the same way. + await Task.Delay(EmbeddedLoadRetryDelay, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception e) when (e is not DatabaseLoadTimeoutException) + { + throw new InvalidOperationException($"The RavenDB migration source at {Located()} has a database named '{databaseName}', from the '{settingKey}' setting, but could not load it.", e); + } + } + } + + string Located() => settings.UseEmbeddedServer + ? $"{settings.ServerUrl} (embedded, data directory '{settings.DatabasePath}', from 'ServiceControl/DBPath')" + : settings.ConnectionString; + + string StartEmbedded() + { + var configuration = new EmbeddedDatabaseConfiguration(settings.ServerUrl, settings.DatabaseName, settings.DatabasePath, settings.LogPath, settings.LogsMode); + + embedded = EmbeddedDatabase.Start(configuration, lifetime); + + return embedded.ServerUrl; + } + + IDocumentStore Connect(string serverUrl) + { + var store = new DocumentStore + { + Database = settings.DatabaseName, + Urls = [serverUrl], + Conventions = new DocumentConventions { SaveEnumsAsIntegers = true } + }; + + if (!settings.UseEmbeddedServer) + { + store.Certificate = RavenClientCertificate.FindClientCertificate(settings); + } + + store.OnBeforeRequest += RefuseWrite; + + return store.Initialize(); + } + + static void RefuseWrite(object? sender, BeforeRequestEventArgs e) + { + if (IsRead(e.Request.Method, new Uri(e.Url).AbsolutePath)) + { + return; + } + + throw new InvalidOperationException($"The RavenDB migration source is open read-only and refused a {e.Request.Method} to '{e.Url}'. Nothing in a migration may write to the database it is reading."); + } + + static bool IsRead(HttpMethod method, string path) + { + if (method == HttpMethod.Get || method == HttpMethod.Head) + { + // HiLo persists the id range it hands out, so it writes despite being a GET. + return !path.Contains("/hilo/", StringComparison.OrdinalIgnoreCase); + } + + return method == HttpMethod.Post && Array.Exists(ReadOnlyPostPaths, suffix => path.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); + } + + public async ValueTask DisposeAsync() + { + documentStore?.Dispose(); + documentStore = null; + + if (embedded is not null) + { + // Stop force-kills only once this token cancels; EmbeddedDatabase sets the graceful wait to an hour. + using var shutdown = new CancellationTokenSource(EmbeddedShutdownTimeout); + await embedded.Stop(shutdown.Token); + embedded.Dispose(); + embedded = null; + } + } + + static readonly string[] ReadOnlyPostPaths = ["/queries", "/multi_get", "/streams/queries"]; + static readonly TimeSpan EmbeddedShutdownTimeout = TimeSpan.FromSeconds(30); + static readonly TimeSpan EmbeddedLoadRetryDelay = TimeSpan.FromMilliseconds(500); + + IDocumentStore? documentStore; + EmbeddedDatabase? embedded; + readonly SourceLifetime lifetime = new(); + + sealed class SourceLifetime : IHostApplicationLifetime + { + public CancellationToken ApplicationStarted => CancellationToken.None; + public CancellationToken ApplicationStopping => CancellationToken.None; + public CancellationToken ApplicationStopped => CancellationToken.None; + + public void StopApplication() + { + } + } +} diff --git a/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs b/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs index 44b20a23a2..787813ed3c 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs @@ -1,15 +1,17 @@ -namespace ServiceControl.Persistence.RavenDB +namespace ServiceControl.Persistence.RavenDB { using System; using System.IO; using System.Reflection; using Configuration; using CustomChecks; + using DataMigration; using Microsoft.Extensions.Logging; using Particular.LicensingComponent.Contracts; using ServiceControl.Infrastructure; + using ServiceControl.Persistence.DataMigration; - class RavenPersistenceConfiguration : PersistenceConfiguration, IPersistenceConfiguration + class RavenPersistenceConfiguration : PersistenceConfiguration, IPersistenceConfiguration, IMigrationSourceFactory { public const string DataSpaceRemainingThresholdKey = "DataSpaceRemainingThreshold"; const string AuditRetentionPeriodKey = "AuditRetentionPeriod"; @@ -90,5 +92,8 @@ public IPersistence Create(PersistenceSettings settings) var specificSettings = (RavenPersisterSettings)settings; return new RavenPersistence(specificSettings); } + + public IMigrationSource CreateSource(PersistenceSettings settings) => + new RavenMigrationSource(new RavenReadOnlySourceLifecycle((RavenPersisterSettings)settings)); } } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/ReadOnlySourceLifecycleTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/ReadOnlySourceLifecycleTests.cs new file mode 100644 index 0000000000..e7ce5edc04 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/ReadOnlySourceLifecycleTests.cs @@ -0,0 +1,346 @@ +namespace ServiceControl.Persistence.Tests.RavenDB.DataMigration; + +using System; +using System.IO; +using System.Threading.Tasks; +using NUnit.Framework; +using Raven.Client.Documents; +using Raven.Client.Documents.Conventions; +using Raven.Client.Documents.Operations; +using Raven.Client.Documents.Operations.Expiration; +using Raven.Client.Documents.Session; +using Raven.Client.ServerWide; +using Raven.Client.ServerWide.Operations; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence.DataMigration; +using ServiceControl.Persistence.RavenDB; +using ServiceControl.Persistence.RavenDB.DataMigration; +using ServiceControl.Persistence.Tests; + +[TestFixture] +class ReadOnlySourceLifecycleTests +{ + string databaseName; + IDocumentStore bootstrapStore; + RavenPersisterSettings sourceSettings; + + [SetUp] + public async Task SetUp() + { + var embeddedServer = await SharedEmbeddedServer.GetInstance(); + databaseName = Guid.NewGuid().ToString("n"); + + bootstrapStore = new DocumentStore + { + Urls = [embeddedServer.ServerUrl], + Database = databaseName, + Conventions = new DocumentConventions { SaveEnumsAsIntegers = true } + }.Initialize(); + + await bootstrapStore.Maintenance.Server.SendAsync(new CreateDatabaseOperation(new DatabaseRecord(databaseName))); + await bootstrapStore.Maintenance.Server.SendAsync(new CreateDatabaseOperation(new DatabaseRecord($"{databaseName}-throughput"))); + + using (var session = bootstrapStore.OpenAsyncSession()) + { + await session.StoreAsync(new FailedMessage { UniqueMessageId = "abc", Status = FailedMessageStatus.Archived }, "FailedMessages/abc"); + await session.SaveChangesAsync(); + } + + sourceSettings = new RavenPersisterSettings + { + DatabaseName = databaseName, + ThroughputDatabaseName = $"{databaseName}-throughput", + ConnectionString = embeddedServer.ServerUrl, + ErrorRetentionPeriod = TimeSpan.FromDays(10) + }; + } + + [TearDown] + public void TearDown() => bootstrapStore?.Dispose(); + + [Test] + public async Task Opening_the_source_creates_no_index() + { + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await lifecycle.Open(); + + var statistics = await bootstrapStore.Maintenance.ForDatabase(databaseName).SendAsync(new GetStatisticsOperation()); + + Assert.That(statistics.CountOfIndexes, Is.Zero, "Opening a migration source must not create the fifteen ServiceControl indexes on it."); + } + [Test] + public async Task Opening_the_source_creates_no_database() + { + var absentThroughput = $"{databaseName}-absent"; + sourceSettings.ThroughputDatabaseName = absentThroughput; + + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + + var exception = Assert.ThrowsAsync(async () => await lifecycle.Open()); + + Assert.That(exception.Message, Does.Contain(absentThroughput).And.Contain("LicensingComponent/RavenDB/ThroughputDatabaseName")); + Assert.That(await bootstrapStore.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(absentThroughput)), Is.Null, "Opening a migration source must not create a database that was missing."); + } + + [Test] + public async Task Opening_the_source_writes_no_database_settings() + { + var before = (await bootstrapStore.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(databaseName))).Settings; + + await using (var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings)) + { + await lifecycle.Open(); + } + + var after = (await bootstrapStore.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(databaseName))).Settings; + + Assert.That(after, Is.EquivalentTo(before), "Opening a migration source must not rewrite the settings of the database it reads."); + } + + [Test] + public async Task Opening_the_source_configures_no_expiration() + { + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await lifecycle.Open(); + + var record = await bootstrapStore.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(databaseName)); + + Assert.That(record.Expiration, Is.Null, "Opening a migration source must not enable RavenDB document expiry against it, or the customer's fallback data is deleted while they are migrating."); + } + + [Test] + public async Task Writing_through_the_source_store_throws() + { + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await lifecycle.Open(); + + using var session = lifecycle.DocumentStore.OpenAsyncSession(new SessionOptions { Database = databaseName }); + await session.StoreAsync(new FailedMessage { UniqueMessageId = "def", Status = FailedMessageStatus.Unresolved }, "FailedMessages/def"); + + var exception = Assert.ThrowsAsync(async () => await session.SaveChangesAsync()); + + Assert.That(exception.Message, Does.Contain("open read-only")); + await AssertAbsent("FailedMessages/def"); + } + + [Test] + public async Task A_source_session_cannot_even_stage_a_write() + { + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await lifecycle.Open(); + + using var session = lifecycle.OpenSession(databaseName); + + var exception = Assert.ThrowsAsync(async () => + await session.StoreAsync(new FailedMessage { UniqueMessageId = "def", Status = FailedMessageStatus.Unresolved }, "FailedMessages/def")); + + Assert.That(exception.Message, Does.Contain("tracking is disabled"), "OpenSession is NoTracking, so a write through it fails at Store rather than reaching the OnBeforeStore refusal. Both guards have to hold: this one is the only one a copier's own sessions ever meet."); + } + + [Test] + public async Task Failed_message_status_reads_back_as_stored() + { + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await lifecycle.Open(); + + using var session = lifecycle.OpenSession(databaseName); + var loaded = await session.LoadAsync("FailedMessages/abc"); + + Assert.That(loaded.Status, Is.EqualTo(FailedMessageStatus.Archived), "Without SaveEnumsAsIntegers every message status is misread, and it looks like data corruption rather than a missing convention."); + } + + [Test] + public async Task A_failed_open_leaves_no_store_behind() + { + sourceSettings.ThroughputDatabaseName = $"{databaseName}-absent"; + + var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + + Assert.ThrowsAsync(async () => await lifecycle.Open()); + + var afterFailure = Assert.Throws(() => _ = lifecycle.DocumentStore); + + Assert.That(afterFailure.Message, Does.Contain("is not open"), "A failed Open must dispose what it created. The caller never received a source, so nothing else can, and on the embedded path what leaks is a live RavenDB server process holding the customer's data directory."); + + await lifecycle.DisposeAsync(); + } + [Test] + public async Task The_RavenDB_configuration_opens_a_source_that_describes_itself() + { + var factory = (IMigrationSourceFactory)new RavenPersistenceConfiguration(); + + await using var source = factory.CreateSource(sourceSettings); + await source.Open(); + var description = await source.Describe(); + + Assert.Multiple(() => + { + Assert.That(description.Embedded, Is.False); + Assert.That(description.PrimaryDatabase, Is.EqualTo(databaseName)); + Assert.That(description.ThroughputDatabase, Is.EqualTo($"{databaseName}-throughput")); + Assert.That(description.ServerVersion, Does.StartWith("6.")); + }); + } + + [Test] + public async Task An_unopened_source_refuses_to_describe_itself() + { + var factory = (IMigrationSourceFactory)new RavenPersistenceConfiguration(); + + await using var source = factory.CreateSource(sourceSettings); + + var exception = Assert.ThrowsAsync(async () => await source.Describe()); + + Assert.That(exception.Message, Does.Contain("is not open"), "Separating construction from Open is what lets a host register a source in its container, and it buys a state where nothing is connected yet. That state has to fail loudly rather than return an empty description."); + } + + [Test] + public async Task The_source_counts_the_collections_it_finds() + { + var factory = (IMigrationSourceFactory)new RavenPersistenceConfiguration(); + + await using var source = factory.CreateSource(sourceSettings); + await source.Open(); + var collections = await source.CountCollections(MigrationSourceDatabase.Primary); + + Assert.That(collections["FailedMessages"], Is.EqualTo(1)); + } + + [Test] + public async Task Opening_the_source_twice_is_refused() + { + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await lifecycle.Open(); + + var exception = Assert.ThrowsAsync(async () => await lifecycle.Open()); + + Assert.That(exception.Message, Does.Contain("already open"), "A second Open would abandon the first store, and on the embedded path a running server process with it."); + } + + [Test] + public async Task An_embedded_open_that_cannot_start_leaves_nothing_behind() + { + sourceSettings.ConnectionString = null; + sourceSettings.DatabasePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n")); + + var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + + Assert.CatchAsync(async () => await lifecycle.Open()); + + var afterFailure = Assert.Throws(() => _ = lifecycle.DocumentStore); + + Assert.That(afterFailure.Message, Does.Contain("is not open"), "A failed embedded open must leave no store, or the caller cannot tell an unopened source from a half-open one."); + + await lifecycle.DisposeAsync(); + } + + [Test] + public async Task Generating_an_id_is_refused() + { + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await lifecycle.Open(); + + using var session = lifecycle.DocumentStore.OpenAsyncSession(new SessionOptions { Database = databaseName }); + + Assert.CatchAsync(async () => + await session.StoreAsync(new FailedMessage { UniqueMessageId = "hilo", Status = FailedMessageStatus.Unresolved })); + + Assert.That(await CountDocuments(), Is.EqualTo(1), "HiLo writes an id range to the source before SaveChanges is reached, so a guard that only sees SaveChanges lets it through."); + } + + [Test] + public async Task A_patch_against_the_source_is_refused() + { + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await lifecycle.Open(); + + Assert.CatchAsync(async () => + await lifecycle.DocumentStore.Operations.ForDatabase(databaseName).SendAsync( + new PatchOperation("FailedMessages/abc", null, new PatchRequest { Script = "this.Status = 1;" }))); + + Assert.That(await LoadStatus("FailedMessages/abc"), Is.EqualTo(FailedMessageStatus.Archived), "A patch never goes through a session, so it bypasses OnBeforeStore entirely. This is the write style the RavenDB persister itself uses."); + } + + [Test] + public async Task A_bulk_insert_into_the_source_is_refused() + { + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await lifecycle.Open(); + + Assert.CatchAsync(async () => + { + await using var bulk = lifecycle.DocumentStore.BulkInsert(databaseName); + await bulk.StoreAsync(new FailedMessage { UniqueMessageId = "bulk", Status = FailedMessageStatus.Unresolved }, "FailedMessages/bulk"); + }); + + await AssertAbsent("FailedMessages/bulk"); + } + + [Test] + public async Task Reconfiguring_expiry_on_the_source_is_refused() + { + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await lifecycle.Open(); + + Assert.CatchAsync(async () => + await lifecycle.DocumentStore.Maintenance.ForDatabase(databaseName).SendAsync( + new ConfigureExpirationOperation(new ExpirationConfiguration { Disabled = false, DeleteFrequencyInSec = 60 }))); + + var record = await bootstrapStore.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(databaseName)); + + Assert.That(record.Expiration, Is.Null, "Enabling expiry on the source would start deleting the customer fallback data."); + } + + [Test] + public async Task Deleting_an_untracked_document_is_refused() + { + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await lifecycle.Open(); + + using var session = lifecycle.DocumentStore.OpenAsyncSession(new SessionOptions { Database = databaseName }); + session.Delete("FailedMessages/abc"); + + Assert.CatchAsync(async () => await session.SaveChangesAsync()); + + Assert.That(await CountDocuments(), Is.EqualTo(1), "Delete by id on an untracked document is deferred, so it never raises OnBeforeDelete."); + } + + [Test] + public async Task Reading_the_source_still_works() + { + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await lifecycle.Open(); + + using var session = lifecycle.OpenSession(databaseName); + + var loaded = await session.LoadAsync("FailedMessages/abc"); + var queried = await session.Query().ToListAsync(); + + Assert.Multiple(() => + { + Assert.That(loaded, Is.Not.Null, "A guard that fails closed still has to let every read the migration needs through."); + Assert.That(queried, Has.Count.EqualTo(1)); + }); + } + + async Task AssertAbsent(string documentId) + { + using var session = bootstrapStore.OpenAsyncSession(); + var found = await session.LoadAsync(documentId); + + Assert.That(found, Is.Null, $"The source refused the write, so '{documentId}' must not be in the database. Asserting the exception alone tests the guard, not the guarantee."); + } + + async Task CountDocuments() + { + var statistics = await bootstrapStore.Maintenance.ForDatabase(databaseName).SendAsync(new GetStatisticsOperation()); + return statistics.CountOfDocuments; + } + + async Task LoadStatus(string documentId) + { + using var session = bootstrapStore.OpenAsyncSession(); + var found = await session.LoadAsync(documentId); + return found.Status; + } +} diff --git a/src/ServiceControl.Persistence/DataMigration/IMigrationSource.cs b/src/ServiceControl.Persistence/DataMigration/IMigrationSource.cs new file mode 100644 index 0000000000..9ba0554964 --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/IMigrationSource.cs @@ -0,0 +1,16 @@ +namespace ServiceControl.Persistence.DataMigration; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +public interface IMigrationSource : IAsyncDisposable +{ + Task Open(CancellationToken cancellationToken = default); + + Task Describe(CancellationToken cancellationToken = default); + + // Collection names as the source reports them, not migration categories + Task> CountCollections(MigrationSourceDatabase database, CancellationToken cancellationToken = default); +} diff --git a/src/ServiceControl.Persistence/DataMigration/IMigrationSourceFactory.cs b/src/ServiceControl.Persistence/DataMigration/IMigrationSourceFactory.cs new file mode 100644 index 0000000000..f015402f2c --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/IMigrationSourceFactory.cs @@ -0,0 +1,6 @@ +namespace ServiceControl.Persistence.DataMigration; + +public interface IMigrationSourceFactory +{ + IMigrationSource CreateSource(PersistenceSettings settings); +} diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationSourceDatabase.cs b/src/ServiceControl.Persistence/DataMigration/MigrationSourceDatabase.cs new file mode 100644 index 0000000000..de1d54c3a7 --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/MigrationSourceDatabase.cs @@ -0,0 +1,7 @@ +namespace ServiceControl.Persistence.DataMigration; + +public enum MigrationSourceDatabase +{ + Primary, + Throughput +} diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationSourceDescription.cs b/src/ServiceControl.Persistence/DataMigration/MigrationSourceDescription.cs new file mode 100644 index 0000000000..c8dcd27f02 --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/MigrationSourceDescription.cs @@ -0,0 +1,8 @@ +namespace ServiceControl.Persistence.DataMigration; + +public sealed record MigrationSourceDescription( + bool Embedded, + string ServerUrl, + string PrimaryDatabase, + string ThroughputDatabase, + string ServerVersion); diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index abd4c98313..f73200052b 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -63,6 +63,7 @@ "VirtualDirectory": "", "HeartbeatGracePeriod": "00:00:40", "TransportType": "ServiceControl.Transports.Learning.LearningTransportCustomization, ServiceControl.Transports.Learning", + "MigrationSourcePersistenceType": "RavenDB", "ErrorLogQueue": "error.log", "ErrorQueue": "error", "ForwardErrorMessages": false, diff --git a/src/ServiceControl.UnitTests/Hosting/MigrationSourceReportArgumentTests.cs b/src/ServiceControl.UnitTests/Hosting/MigrationSourceReportArgumentTests.cs new file mode 100644 index 0000000000..d3c8851ed0 --- /dev/null +++ b/src/ServiceControl.UnitTests/Hosting/MigrationSourceReportArgumentTests.cs @@ -0,0 +1,21 @@ +namespace ServiceControl.UnitTests.Hosting; + +using NUnit.Framework; +using Particular.ServiceControl.Hosting; +using ServiceControl.Hosting.Commands; + +[TestFixture] +class MigrationSourceReportArgumentTests +{ + [Test] + public void The_flag_selects_the_report_command() => + Assert.That(new HostArguments(["--migration-source-report"]).Command, Is.EqualTo(typeof(MigrationSourceReportCommand))); + + [Test] + public void No_flag_still_selects_the_run_command() => + Assert.That(new HostArguments([]).Command, Is.EqualTo(typeof(RunCommand))); + + [Test] + public void The_setup_flag_is_undisturbed() => + Assert.That(new HostArguments(["--setup"]).Command, Is.EqualTo(typeof(SetupCommand))); +} diff --git a/src/ServiceControl.slnx b/src/ServiceControl.slnx index 5abc0cecec..7ea43dbfe6 100644 --- a/src/ServiceControl.slnx +++ b/src/ServiceControl.slnx @@ -48,6 +48,7 @@ + diff --git a/src/ServiceControl/Hosting/Commands/MigrationSourceReportCommand.cs b/src/ServiceControl/Hosting/Commands/MigrationSourceReportCommand.cs new file mode 100644 index 0000000000..ee2743bb8b --- /dev/null +++ b/src/ServiceControl/Hosting/Commands/MigrationSourceReportCommand.cs @@ -0,0 +1,56 @@ +namespace ServiceControl.Hosting.Commands +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Particular.ServiceControl.Hosting; + using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Persistence; + using ServiceControl.Persistence.DataMigration; + + class MigrationSourceReportCommand : AbstractCommand + { + public override async Task Execute(HostArguments args, Settings settings, CancellationToken cancellationToken = default) + { + await using var source = await PersistenceFactory.OpenMigrationSource(settings, cancellationToken); + + var description = await source.Describe(cancellationToken); + + Console.Out.WriteLine("ServiceControl migration source report"); + Console.Out.WriteLine(); + Console.Out.WriteLine($"Source persistence : {settings.MigrationSourcePersistenceType} ({(description.Embedded ? "embedded" : "external")})"); + Console.Out.WriteLine($"Server URL : {description.ServerUrl}"); + Console.Out.WriteLine($"Server version : {description.ServerVersion}"); + Console.Out.WriteLine($"Primary database : {description.PrimaryDatabase} (from ServiceControl/RavenDB/DatabaseName)"); + Console.Out.WriteLine($"Throughput database : {description.ThroughputDatabase} (from LicensingComponent/RavenDB/ThroughputDatabaseName)"); + + await PrintCollections(source, MigrationSourceDatabase.Primary, description.PrimaryDatabase, cancellationToken); + await PrintCollections(source, MigrationSourceDatabase.Throughput, description.ThroughputDatabase, cancellationToken); + } + + static async Task PrintCollections(IMigrationSource source, MigrationSourceDatabase database, string databaseName, CancellationToken cancellationToken) + { + var counted = new SortedDictionary(StringComparer.Ordinal); + + foreach (var collection in await source.CountCollections(database, cancellationToken)) + { + counted[collection.Key] = collection.Value; + } + + Console.Out.WriteLine(); + Console.Out.WriteLine($"Collections in {databaseName}:"); + + if (counted.Count == 0) + { + Console.Out.WriteLine(" (none)"); + return; + } + + foreach (var collection in counted) + { + Console.Out.WriteLine($" {collection.Key,-42}{collection.Value,12:N0}"); + } + } + } +} diff --git a/src/ServiceControl/Hosting/Help.txt b/src/ServiceControl/Hosting/Help.txt index d616180539..8bceaf1eb6 100644 --- a/src/ServiceControl/Hosting/Help.txt +++ b/src/ServiceControl/Hosting/Help.txt @@ -23,6 +23,20 @@ This mode runs no setup, so combining it with --setup or --setup-and-run is refu Message bodies must be stored somewhere every host can read, so this mode should not be combined with file system body storage unless the path is a shared mount. +MIGRATION SOURCE REPORT + + ServiceControl.exe --migration-source-report + +Reports what a RavenDB to SQL migration would read: whether the old database is embedded or on its +own server, which server, both database names with the setting each came from, and a row count for +every collection. + +An EXTERNAL RavenDB source can be reported on while ServiceControl is running. An EMBEDDED source +cannot: ServiceControl starts its own RavenDB process against that data directory, and a second one +cannot attach to it. Stop the ServiceControl service first, run the report, and start it again. An +instance that does not ship the RavenDB server, such as the container image, cannot report on an +embedded source at all. + SERVICE INSTALL AND UNINSTALL AND CONFIGURATION OPTIONS As of Service Control 1.7 the command line uninstall and install switches have been removed. diff --git a/src/ServiceControl/Hosting/HostArguments.cs b/src/ServiceControl/Hosting/HostArguments.cs index 88fe15a162..c8c0b694e0 100644 --- a/src/ServiceControl/Hosting/HostArguments.cs +++ b/src/ServiceControl/Hosting/HostArguments.cs @@ -62,6 +62,15 @@ public HostArguments(string[] args) } }; + var migrationSourceReportOptions = new OptionSet + { + { + "migration-source-report", + "Report what a migration would read from the old RavenDB database, without changing it", + s => Command = typeof(MigrationSourceReportCommand) + } + }; + try { // Parsed before setup returns, so setup can refuse to provision an ingestion-only worker. @@ -94,6 +103,13 @@ public HostArguments(string[] args) return; } + migrationSourceReportOptions.Parse(args); + + if (Command == typeof(MigrationSourceReportCommand)) + { + return; + } + defaultOptions.Parse(args); } catch (Exception e) diff --git a/src/ServiceControl/Infrastructure/Settings/Settings.cs b/src/ServiceControl/Infrastructure/Settings/Settings.cs index af52f4bf88..5c9c6de8ad 100644 --- a/src/ServiceControl/Infrastructure/Settings/Settings.cs +++ b/src/ServiceControl/Infrastructure/Settings/Settings.cs @@ -185,6 +185,7 @@ public string InstanceId public string TransportType { get; set; } public string PersistenceType { get; private set; } + public string MigrationSourcePersistenceType => SettingsReader.Read(SettingsRootNamespace, "Migration/SourcePersistenceType", "RavenDB"); public string ErrorLogQueue { get; set; } public string ErrorQueue { get; set; } diff --git a/src/ServiceControl/Persistence/PersistenceFactory.cs b/src/ServiceControl/Persistence/PersistenceFactory.cs index 892920cf18..9d4f255e7f 100644 --- a/src/ServiceControl/Persistence/PersistenceFactory.cs +++ b/src/ServiceControl/Persistence/PersistenceFactory.cs @@ -2,13 +2,17 @@ namespace ServiceControl.Persistence { using System; using System.IO; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; using ServiceBus.Management.Infrastructure.Settings; + using ServiceControl.Persistence.DataMigration; static class PersistenceFactory { public static IPersistence Create(Settings settings, bool maintenanceMode = false) { - var persistenceConfiguration = CreatePersistenceConfiguration(settings); + var persistenceConfiguration = CreatePersistenceConfiguration(settings.PersistenceType, settings); if (maintenanceMode && !persistenceConfiguration.SupportsMaintenanceMode) { @@ -24,11 +28,41 @@ public static IPersistence Create(Settings settings, bool maintenanceMode = fals return persistence; } - static IPersistenceConfiguration CreatePersistenceConfiguration(Settings settings) + public static IMigrationSource CreateMigrationSource(Settings settings) { + var persistenceType = settings.MigrationSourcePersistenceType; + var persistenceConfiguration = CreatePersistenceConfiguration(persistenceType, settings); + + if (persistenceConfiguration is not IMigrationSourceFactory sourceFactory) + { + throw new Exception($"The '{persistenceType}' persistence cannot be read as a migration source. Set ServiceControl/Migration/SourcePersistenceType to the persistence that holds the data being migrated away from."); + } + + // Not PersisterSpecificSettings: it is null here, and a host that has populated it put the + // target's connection string in it. + return sourceFactory.CreateSource(persistenceConfiguration.CreateSettings(Settings.SettingsRootNamespace)); + } + + public static async Task OpenMigrationSource(Settings settings, CancellationToken cancellationToken = default) + { + var source = CreateMigrationSource(settings); + await source.Open(cancellationToken); + + return source; + } + + static IPersistenceConfiguration CreatePersistenceConfiguration(string persistenceType, Settings settings) + { + var persistenceManifest = PersistenceManifestLibrary.Find(persistenceType) + ?? throw new Exception($"There is no persistence named '{persistenceType}'. Available: {string.Join(", ", PersistenceManifestLibrary.PersistenceManifests.Where(m => m.IsSupported).Select(m => m.Name))}."); + + if (persistenceManifest.TypeName is null) + { + throw new Exception($"The '{persistenceManifest.DisplayName}' persistence no longer ships an assembly and cannot be loaded."); + } + try { - var persistenceManifest = PersistenceManifestLibrary.Find(settings.PersistenceType); var assemblyPath = Path.Combine(persistenceManifest.Location, $"{persistenceManifest.AssemblyName}.dll"); var loadContext = settings.AssemblyLoadContextResolver(assemblyPath); var customizationType = Type.GetType(persistenceManifest.TypeName, loadContext.LoadFromAssemblyName, null, true); @@ -37,7 +71,7 @@ static IPersistenceConfiguration CreatePersistenceConfiguration(Settings setting } catch (Exception e) { - throw new Exception($"Could not load persistence customization type {settings.PersistenceType}.", e); + throw new Exception($"Could not load persistence customization type {persistenceType}.", e); } } } diff --git a/src/ServiceControl/ServiceControl.csproj b/src/ServiceControl/ServiceControl.csproj index 7afd8f88cf..d9b9958c43 100644 --- a/src/ServiceControl/ServiceControl.csproj +++ b/src/ServiceControl/ServiceControl.csproj @@ -68,6 +68,7 @@ + From 3a0263adb27450429f4cb8651c78dd5d06d5931e Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Tue, 15 Sep 2026 13:59:10 +0800 Subject: [PATCH 06/15] Add unit tests for migration engine functionality and improve migration source reporting - Implemented MigrationEngineFailurePathTests to validate behavior during write failures and halts. - Created MigrationEngineHaltTests to ensure categories halt correctly on systemic failures. - Added MigrationEngineOptionsTests to verify default and environment variable configurations. - Developed MigrationEngineOrderingTests to check execution order of migration categories. - Introduced MigrationEngineResumeTests to confirm no duplicates or gaps after restarts. - Added MigrationEngineRunCategoriesTests to ensure all categories run in specified order. - Implemented MigrationEngineSkipReasonTests to validate skip reasons and their aggregation. - Enhanced MigrationEngineThrottleTests to verify pause behavior for optional categories. - Updated MigrationSourceReportCommand for improved output and clarity. - Modified Help.txt and HostArguments.cs for better command descriptions. - Refined Settings.cs and PersistenceFactory.cs for clearer migration source configuration. --- README.md | 4 + .../migration-system-design-diagram.png | Bin 0 -> 319535 bytes .../ravendb-to-sql-migration-instructions.md | 54 ++++ .../ravendb-to-sql-migration-overview.md | 34 +-- .../MigrationSourceReportCommandTests.cs | 25 +- .../TwoPersistersInOneProcessTests.cs | 3 +- .../DataMigration/RavenMigrationSource.cs | 47 +++- .../RavenReadOnlySourceLifecycle.cs | 42 +-- .../RavenPersistenceConfiguration.cs | 4 +- .../ReadOnlySourceLifecycleTests.cs | 105 ++++--- .../DataMigration/HaltThreshold.cs | 17 ++ .../IMigrationCheckpointStore.cs | 40 +++ .../DataMigration/IMigrationSource.cs | 19 +- .../DataMigration/IMigrationSourceFactory.cs | 6 +- .../DataMigration/IMigrationTarget.cs | 25 ++ .../DataMigration/MigrationBatch.cs | 27 ++ .../DataMigration/MigrationCategoryIds.cs | 24 ++ .../MigrationCategoryRegistry.cs | 38 +++ .../DataMigration/MigrationEngine.cs | 266 ++++++++++++++++++ .../DataMigration/MigrationEngineOptions.cs | 48 ++++ .../DataMigration/MigrationSettings.cs | 19 ++ .../DataMigration/MigrationSkipReason.cs | 6 + .../DataMigration/MigrationSourceDatabase.cs | 7 - .../MigrationSourceDescription.cs | 16 +- .../Fakes/InMemoryMigrationCheckpointStore.cs | 26 ++ .../Fakes/InMemoryMigrationSource.cs | 85 ++++++ .../Fakes/InMemoryMigrationSourceTests.cs | 78 +++++ .../Fakes/InMemoryMigrationTarget.cs | 85 ++++++ .../Fakes/InMemoryMigrationTargetTests.cs | 80 ++++++ .../Migration/HaltThresholdTests.cs | 50 ++++ .../MigrationCategoryRegistryTests.cs | 110 ++++++++ .../Migration/MigrationContractShapeTests.cs | 99 +++++++ .../MigrationEngineBodyRetryTests.cs | 124 ++++++++ .../MigrationEngineCategorySelectionTests.cs | 64 +++++ .../MigrationEngineCollisionTests.cs | 45 +++ .../Migration/MigrationEngineCopyTests.cs | 64 +++++ .../MigrationEngineFailurePathTests.cs | 163 +++++++++++ .../Migration/MigrationEngineHaltTests.cs | 84 ++++++ .../Migration/MigrationEngineOptionsTests.cs | 80 ++++++ .../Migration/MigrationEngineOrderingTests.cs | 137 +++++++++ .../Migration/MigrationEngineResumeTests.cs | 60 ++++ .../MigrationEngineRunCategoriesTests.cs | 67 +++++ .../MigrationEngineSkipReasonTests.cs | 96 +++++++ .../Migration/MigrationEngineThrottleTests.cs | 62 ++++ .../Commands/MigrationSourceReportCommand.cs | 40 +-- src/ServiceControl/Hosting/Help.txt | 9 +- src/ServiceControl/Hosting/HostArguments.cs | 2 +- .../Infrastructure/Settings/Settings.cs | 3 +- .../Persistence/PersistenceFactory.cs | 6 +- 49 files changed, 2443 insertions(+), 152 deletions(-) create mode 100644 docs/migration/migration-system-design-diagram.png create mode 100644 docs/migration/ravendb-to-sql-migration-instructions.md rename docs/{ => migration}/ravendb-to-sql-migration-overview.md (89%) create mode 100644 src/ServiceControl.Persistence/DataMigration/HaltThreshold.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/IMigrationCheckpointStore.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/IMigrationTarget.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/MigrationBatch.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/MigrationCategoryIds.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/MigrationCategoryRegistry.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/MigrationEngineOptions.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/MigrationSettings.cs create mode 100644 src/ServiceControl.Persistence/DataMigration/MigrationSkipReason.cs delete mode 100644 src/ServiceControl.Persistence/DataMigration/MigrationSourceDatabase.cs create mode 100644 src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationCheckpointStore.cs create mode 100644 src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSource.cs create mode 100644 src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSourceTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs create mode 100644 src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTargetTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/HaltThresholdTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationCategoryRegistryTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationContractShapeTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationEngineBodyRetryTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationEngineCollisionTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationEngineCopyTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationEngineOptionsTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationEngineRunCategoriesTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs create mode 100644 src/ServiceControl.UnitTests/Migration/MigrationEngineThrottleTests.cs diff --git a/README.md b/README.md index f2e4c32b88..e1230fbf9f 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ It's also possible to [locally test containers built from PRs in GitHub Containe If the instance is executed for the first time, it must set up the required infrastructure. To do so, once the instance is configured to use the selected transport and persister, run it in setup mode. This can be done by using the `Setup {instance name}` launch profile that is defined in the `launchSettings.json` file of each instance. When started in setup mode, the instance will start as usual, execute the setup process, and exit. At this point the instance can be run normally by using the non-setup launch profile. +## Migrating from RavenDB to SQL Server or PostgreSQL + +See [Migrating from RavenDB to SQL Server or PostgreSQL](docs/migration/ravendb-to-sql-migration-instructions.md). + ## Secrets Testing using the [CI workflow](/.github/workflows/ci.yml) depends on the following secrets. The Particular values for these secrets are stored in the secure note named **ServiceControl Repo Secrets**. diff --git a/docs/migration/migration-system-design-diagram.png b/docs/migration/migration-system-design-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..15845976ae747d176e438dfb06641e47bc39df37 GIT binary patch literal 319535 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 xqAB4 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%0A0H++C+*mi$3)tg@oO|QGo13KM!wLD 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 category's saved progress: where a restart carries on from, and what the status and verify commands report. +public sealed record MigrationCheckpoint( + string CategoryId, + bool Selected, + MigrationCategoryState State, + string? Cursor, + long CopiedCount, + long SkippedCount, + long? SourceTotal, + IReadOnlyDictionary? SkipReasons, + DateTime? StartedAt, + DateTime? LastProgressAt, + DateTime? CompletedAt, + DateTime? AbandonedAt, + string? LastError, + long AlreadyPresentCount = 0); + +public interface IMigrationCheckpointStore +{ + Task> ReadAll(CancellationToken cancellationToken = default); + Task Read(string categoryId, CancellationToken cancellationToken = default); + Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default); +} diff --git a/src/ServiceControl.Persistence/DataMigration/IMigrationSource.cs b/src/ServiceControl.Persistence/DataMigration/IMigrationSource.cs index 9ba0554964..bc5bd1c3bf 100644 --- a/src/ServiceControl.Persistence/DataMigration/IMigrationSource.cs +++ b/src/ServiceControl.Persistence/DataMigration/IMigrationSource.cs @@ -7,10 +7,25 @@ namespace ServiceControl.Persistence.DataMigration; public interface IMigrationSource : IAsyncDisposable { + /// Connects to the source read-only. Every other member throws until this has run. Task Open(CancellationToken cancellationToken = default); + /// What the source report and dry run print about the source. Task Describe(CancellationToken cancellationToken = default); - // Collection names as the source reports them, not migration categories - Task> CountCollections(MigrationSourceDatabase database, CancellationToken cancellationToken = default); + /// A count of everything the source holds, including data no category copies. + Task> Inventory(CancellationToken cancellationToken = default); + + /// How many rows the source holds for one category, for progress and verify. + Task Count(MigrationCategory category, CancellationToken cancellationToken = default); + + /// Reads a category in batches, after the checkpoint cursor if provided, or from the start when it is null. Throws on a cursor it never issued. + IAsyncEnumerable Read( + MigrationCategory category, + string? resumeAfter, + int batchSize, + CancellationToken cancellationToken = default); + + /// Reads one row's message body when Read did not attach it. Returns null when the row has no body. + Task ReadBody(MigrationCategory category, string sourceId, CancellationToken cancellationToken = default); } diff --git a/src/ServiceControl.Persistence/DataMigration/IMigrationSourceFactory.cs b/src/ServiceControl.Persistence/DataMigration/IMigrationSourceFactory.cs index f015402f2c..1f4de16640 100644 --- a/src/ServiceControl.Persistence/DataMigration/IMigrationSourceFactory.cs +++ b/src/ServiceControl.Persistence/DataMigration/IMigrationSourceFactory.cs @@ -1,6 +1,10 @@ namespace ServiceControl.Persistence.DataMigration; +using ServiceControl.Configuration; + +/// Implemented by a persister that can be read as the old database a migration copies from. public interface IMigrationSourceFactory { - IMigrationSource CreateSource(PersistenceSettings settings); + /// Builds the source from the instance's own settings, without connecting to it. + IMigrationSource CreateSource(SettingsRootNamespace settingsRoot); } diff --git a/src/ServiceControl.Persistence/DataMigration/IMigrationTarget.cs b/src/ServiceControl.Persistence/DataMigration/IMigrationTarget.cs new file mode 100644 index 0000000000..6ab337d815 --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/IMigrationTarget.cs @@ -0,0 +1,25 @@ +namespace ServiceControl.Persistence.DataMigration; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +/// Implemented by a persister that can be the new database a migration copies into. +public interface IMigrationTarget +{ + /// How many rows to read per batch for this category. The target picks it because its own database sets the limits. + int BatchSizeFor(MigrationCategory category); + + /// Saves the batch's rows and checkpointAfterBatch in one go, so progress never gets ahead of the data. Save the checkpoint exactly as given, without adding counts to it. + Task Write( + MigrationCategory category, + MigrationBatch batch, + MigrationCheckpoint checkpointAfterBatch, + CancellationToken cancellationToken = default); + + /// How many rows the target holds for one category, for progress and verify. Counts only that category, even where two categories share a table. + Task Count(MigrationCategory category, CancellationToken cancellationToken = default); +} + +/// What the target did with one batch. Every skipped row must have a reason in SkipReasons. +public sealed record MigrationWriteResult(int Copied, int Skipped, IReadOnlyList SkippedIds, int AlreadyPresent = 0, IReadOnlyDictionary? SkipReasons = null); diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationBatch.cs b/src/ServiceControl.Persistence/DataMigration/MigrationBatch.cs new file mode 100644 index 0000000000..431730ba82 --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/MigrationBatch.cs @@ -0,0 +1,27 @@ +namespace ServiceControl.Persistence.DataMigration; + +using System; +using System.Collections.Generic; + +public enum MigrationCategoryKind { Required, Optional } + +/// One kind of data, such as endpoint settings, copied as a unit and resumed from its own cursor. +public sealed record MigrationCategory( + string Id, + MigrationCategoryKind Kind, + bool CarriesBodies, + int Order, + string? MustFollow = null); + +/// A message body read from the source, as bytes plus its content type. +public sealed record MigrationBody(ReadOnlyMemory Content, string ContentType); + +/// One item read from the source. Body is null unless the source attached it or the engine fetched it. +public sealed record MigrationRow( + string SourceId, + object Document, + IReadOnlyDictionary Metadata, + MigrationBody? Body = null); + +/// Rows read from the source together, plus the cursor to resume after them. +public sealed record MigrationBatch(IReadOnlyList Rows, string Cursor); diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationCategoryIds.cs b/src/ServiceControl.Persistence/DataMigration/MigrationCategoryIds.cs new file mode 100644 index 0000000000..6f15bcdf24 --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/MigrationCategoryIds.cs @@ -0,0 +1,24 @@ +namespace ServiceControl.Persistence.DataMigration; + +public static class MigrationCategoryIds +{ + public const string KnownEndpoints = nameof(KnownEndpoints); + public const string EndpointSettings = nameof(EndpointSettings); + public const string MessageRedirects = nameof(MessageRedirects); + public const string Subscriptions = nameof(Subscriptions); + public const string NotificationSettings = nameof(NotificationSettings); + public const string TrialEndDate = nameof(TrialEndDate); + public const string RetryOperations = nameof(RetryOperations); + public const string LicensingEndpoints = nameof(LicensingEndpoints); + public const string LicensingThroughput = nameof(LicensingThroughput); + public const string LicensingReportMasks = nameof(LicensingReportMasks); + public const string LicensedEndpointDetails = nameof(LicensedEndpointDetails); + public const string UnresolvedAndRetryIssuedFailedMessages = nameof(UnresolvedAndRetryIssuedFailedMessages); + + public const string EventLog = nameof(EventLog); + public const string CustomChecks = nameof(CustomChecks); + public const string FailedErrorImports = nameof(FailedErrorImports); + public const string FailedMessageEdits = nameof(FailedMessageEdits); + public const string ArchivedAndResolvedFailedMessages = nameof(ArchivedAndResolvedFailedMessages); + public const string GroupComments = nameof(GroupComments); +} diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationCategoryRegistry.cs b/src/ServiceControl.Persistence/DataMigration/MigrationCategoryRegistry.cs new file mode 100644 index 0000000000..69665e34e1 --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/MigrationCategoryRegistry.cs @@ -0,0 +1,38 @@ +namespace ServiceControl.Persistence.DataMigration; + +using System.Collections.Generic; +using System.Linq; +using ServiceControl.MessageFailures; + +public static class MigrationCategoryRegistry +{ + public static readonly IReadOnlyList UnresolvedAndRetryIssuedStatuses = [FailedMessageStatus.Unresolved, FailedMessageStatus.RetryIssued]; + public static readonly IReadOnlyList ArchivedAndResolvedStatuses = [FailedMessageStatus.Archived, FailedMessageStatus.Resolved]; + + public static readonly IReadOnlyList All = + [ + // Required, copied with ServiceControl closed. + new(MigrationCategoryIds.KnownEndpoints, MigrationCategoryKind.Required, CarriesBodies: false, Order: 1), + new(MigrationCategoryIds.EndpointSettings, MigrationCategoryKind.Required, CarriesBodies: false, Order: 2, MustFollow: MigrationCategoryIds.KnownEndpoints), + new(MigrationCategoryIds.MessageRedirects, MigrationCategoryKind.Required, CarriesBodies: false, Order: 3), + new(MigrationCategoryIds.Subscriptions, MigrationCategoryKind.Required, CarriesBodies: false, Order: 4), + new(MigrationCategoryIds.NotificationSettings, MigrationCategoryKind.Required, CarriesBodies: false, Order: 5), + new(MigrationCategoryIds.TrialEndDate, MigrationCategoryKind.Required, CarriesBodies: false, Order: 6), + new(MigrationCategoryIds.RetryOperations, MigrationCategoryKind.Required, CarriesBodies: false, Order: 7), + new(MigrationCategoryIds.LicensingEndpoints, MigrationCategoryKind.Required, CarriesBodies: false, Order: 8), + new(MigrationCategoryIds.LicensingThroughput, MigrationCategoryKind.Required, CarriesBodies: false, Order: 9, MustFollow: MigrationCategoryIds.LicensingEndpoints), + new(MigrationCategoryIds.LicensingReportMasks, MigrationCategoryKind.Required, CarriesBodies: false, Order: 10), + new(MigrationCategoryIds.LicensedEndpointDetails, MigrationCategoryKind.Required, CarriesBodies: false, Order: 11), + new(MigrationCategoryIds.UnresolvedAndRetryIssuedFailedMessages, MigrationCategoryKind.Required, CarriesBodies: true, Order: 12), + + // Optional, copied in the background once the host is open. + new(MigrationCategoryIds.EventLog, MigrationCategoryKind.Optional, CarriesBodies: false, Order: 1), + new(MigrationCategoryIds.CustomChecks, MigrationCategoryKind.Optional, CarriesBodies: false, Order: 2), + new(MigrationCategoryIds.FailedErrorImports, MigrationCategoryKind.Optional, CarriesBodies: true, Order: 3), + new(MigrationCategoryIds.FailedMessageEdits, MigrationCategoryKind.Optional, CarriesBodies: false, Order: 4), + new(MigrationCategoryIds.ArchivedAndResolvedFailedMessages, MigrationCategoryKind.Optional, CarriesBodies: true, Order: 5), + new(MigrationCategoryIds.GroupComments, MigrationCategoryKind.Optional, CarriesBodies: false, Order: 6, MustFollow: MigrationCategoryIds.ArchivedAndResolvedFailedMessages), + ]; + + public static MigrationCategory? Find(string id) => All.FirstOrDefault(c => c.Id == id); +} diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs b/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs new file mode 100644 index 0000000000..c9ce5d9fc7 --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs @@ -0,0 +1,266 @@ +namespace ServiceControl.Persistence.DataMigration; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +public sealed class MigrationEngine( + IMigrationSource source, + IMigrationTarget target, + IMigrationCheckpointStore checkpointStore, + TimeProvider timeProvider, + MigrationEngineOptions options, + ILogger logger) +{ + public const int MaxBodyReadAttempts = 3; + + public IReadOnlyList SelectCategories(MigrationCategoryKind kind) => + MigrationCategoryRegistry.All + .Where(c => c.Kind == kind) + .Where(c => kind == MigrationCategoryKind.Required || options.SelectedOptionalCategoryIds.Contains(c.Id)) + .OrderBy(c => c.Order) + .ToArray(); + + // Runs in the order given without re-sorting: required and optional orders both start at 1, so + // sorting a mixed list would put an optional category in front of a required one. + public async Task> RunCategories( + IReadOnlyList categories, + CancellationToken cancellationToken = default) + { + var results = new List(categories.Count); + + foreach (var category in categories) + { + results.Add(await RunCategoryAsync(category, cancellationToken)); + } + + return results; + } + + public async Task RunCategoryAsync(MigrationCategory category, CancellationToken cancellationToken = default) + { + var checkpoint = await checkpointStore.Read(category.Id, cancellationToken) ?? NotStarted(category); + + // Halted is deliberately not one of them: a halt says "stopped, and here is why", and a restart after the cause is fixed has to be able to pick it up again. + if (checkpoint.State is MigrationCategoryState.Complete or MigrationCategoryState.CompleteWithErrors or MigrationCategoryState.Abandoned) + { + return checkpoint; + } + + if (category.MustFollow is { } mustFollowId) + { + var predecessor = await checkpointStore.Read(mustFollowId, cancellationToken); + if (predecessor is not { State: MigrationCategoryState.Complete or MigrationCategoryState.CompleteWithErrors or MigrationCategoryState.Abandoned }) + { + var blocked = checkpoint with + { + Selected = true, + LastError = $"Blocked: {category.Id} must follow {mustFollowId}, which is {predecessor?.State.ToString() ?? "not started"}" + }; + await checkpointStore.Upsert(blocked, cancellationToken); + logger.LogWarning("Category {CategoryId} did not run: it must follow {PredecessorId}, which is {PredecessorState}", + category.Id, mustFollowId, predecessor?.State.ToString() ?? "not started"); + return blocked; + } + } + + if (checkpoint.State is MigrationCategoryState.NotStarted or MigrationCategoryState.Halted) + { + checkpoint = checkpoint with + { + Selected = true, + State = MigrationCategoryState.InProgress, + StartedAt = checkpoint.StartedAt ?? timeProvider.GetUtcNow().UtcDateTime, + LastError = null + }; + await checkpointStore.Upsert(checkpoint, cancellationToken); + } + + var batchSize = target.BatchSizeFor(category); + var isFirstBatch = true; + // Per run, not the persisted totals: the skips that tripped a halt stay on the row, so + // counting them again would re-halt a restart whose cause has been fixed. + var skippedThisRun = 0L; + var processedThisRun = 0L; + + try + { + await foreach (var batch in source.Read(category, checkpoint.Cursor, batchSize, cancellationToken).WithCancellation(cancellationToken)) + { + if (!isFirstBatch && category.Kind == MigrationCategoryKind.Optional) + { + await Pause(options.ThrottlePause, cancellationToken); + } + isFirstBatch = false; + + var batchToWrite = batch; + // Stays off checkpoint until the write commits: the catch persists checkpoint, and a restart + // re-reads an uncommitted batch and would count these skips again. + var bodySkips = 0; + if (category.CarriesBodies) + { + var (withBodies, failedIds) = await FetchBodiesWithRetry(category, batch, cancellationToken); + batchToWrite = withBodies; + bodySkips = failedIds.Count; + + foreach (var id in failedIds) + { + logger.LogWarning("Skipped {SourceId} in category {CategoryId}: body unreadable after {MaxAttempts} attempts", id, category.Id, MaxBodyReadAttempts); + } + } + + // Absolute totals counting every handed-over row as copied, persisted verbatim with the rows. + // The real split comes back in the result and lands on the next checkpoint. + var checkpointAfterBatch = checkpoint with + { + Cursor = batch.Cursor, + CopiedCount = checkpoint.CopiedCount + batchToWrite.Rows.Count, + SkippedCount = checkpoint.SkippedCount + bodySkips, + SkipReasons = AddSkipReasons(checkpoint.SkipReasons, bodySkips == 0 ? null : new Dictionary { [nameof(MigrationSkipReason.BodyUnreadable)] = bodySkips }) + }; + + var result = await target.Write(category, batchToWrite, checkpointAfterBatch, cancellationToken); + + checkpoint = checkpointAfterBatch with + { + CopiedCount = checkpoint.CopiedCount + result.Copied, + SkippedCount = checkpointAfterBatch.SkippedCount + result.Skipped, + AlreadyPresentCount = checkpoint.AlreadyPresentCount + result.AlreadyPresent, + SkipReasons = AddSkipReasons(checkpointAfterBatch.SkipReasons, result.SkipReasons) + }; + + var explainedSkips = result.SkipReasons?.Values.Sum() ?? 0; + if (explainedSkips != result.Skipped) + { + throw new InvalidOperationException($"The target reported {result.Skipped} skipped rows in category {category.Id} but gave reasons for {explainedSkips}. Every skipped row needs a reason, or --migration-verify cannot account for it."); + } + + foreach (var id in result.SkippedIds) + { + logger.LogWarning("Skipped {SourceId} in category {CategoryId}", id, category.Id); + } + + processedThisRun += bodySkips + result.Copied + result.Skipped + result.AlreadyPresent; + skippedThisRun += bodySkips + result.Skipped; + + if (HaltThreshold.Exceeded(skippedThisRun, processedThisRun, options.HaltThresholdPercent, options.HaltThresholdMinimum)) + { + checkpoint = checkpoint with + { + State = MigrationCategoryState.Halted, + CompletedAt = timeProvider.GetUtcNow().UtcDateTime, + LastError = $"Halted: {skippedThisRun} of {processedThisRun} rows skipped in this run exceeds the configured threshold of {options.HaltThresholdPercent}% and {options.HaltThresholdMinimum} rows. Fix the cause and restart to resume from the cursor, or abandon the category to accept the loss." + }; + await checkpointStore.Upsert(checkpoint, cancellationToken); + logger.LogError("Category {CategoryId} halted at cursor {Cursor}: {LastError}", category.Id, checkpoint.Cursor, checkpoint.LastError); + return checkpoint; + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The host is stopping. The category stays InProgress and the next start resumes from + // the cursor the last committed batch left behind. + throw; + } + catch (Exception ex) + { + checkpoint = checkpoint with + { + State = MigrationCategoryState.Halted, + CompletedAt = timeProvider.GetUtcNow().UtcDateTime, + LastError = $"{ex.GetType().Name} at cursor {checkpoint.Cursor ?? "the start"}: {ex.Message}" + }; + await checkpointStore.Upsert(checkpoint, cancellationToken); + logger.LogError(ex, "Category {CategoryId} halted at cursor {Cursor}", category.Id, checkpoint.Cursor); + return checkpoint; + } + + checkpoint = checkpoint with + { + State = checkpoint.SkippedCount > 0 ? MigrationCategoryState.CompleteWithErrors : MigrationCategoryState.Complete, + CompletedAt = timeProvider.GetUtcNow().UtcDateTime + }; + await checkpointStore.Upsert(checkpoint, cancellationToken); + return checkpoint; + } + + async Task<(MigrationBatch Batch, IReadOnlyList FailedIds)> FetchBodiesWithRetry(MigrationCategory category, MigrationBatch batch, CancellationToken cancellationToken) + { + var survivors = new List(batch.Rows.Count); + var failed = new List(); + + foreach (var row in batch.Rows) + { + if (row.Body is not null) + { + survivors.Add(row); + continue; + } + + MigrationBody? body = null; + var succeeded = false; + + for (var attempt = 1; attempt <= MaxBodyReadAttempts && !succeeded; attempt++) + { + try + { + body = await source.ReadBody(category, row.SourceId, cancellationToken); + succeeded = true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // A shutdown is not a transient body failure. Retrying it to the attempt limit and then + // recording the message as permanently unreadable would lose a row to a restart. + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Attempt {Attempt} to read the body for {SourceId} failed", attempt, row.SourceId); + if (attempt < MaxBodyReadAttempts) + { + await Pause(options.BodyRetryBackoff, cancellationToken); + } + } + } + + if (succeeded) + { + survivors.Add(row with { Body = body }); + } + else + { + failed.Add(row.SourceId); + } + } + + return (batch with { Rows = survivors }, failed); + } + + static IReadOnlyDictionary? AddSkipReasons(IReadOnlyDictionary? totals, IReadOnlyDictionary? additions) + { + if (additions is not { Count: > 0 }) + { + return totals; + } + + Dictionary sum = totals is null ? [] : new(totals); + foreach (var (reason, count) in additions) + { + sum[reason] = sum.GetValueOrDefault(reason) + count; + } + + return sum; + } + + // A configured pause of zero means "do not throttle", and a timer that is never going to be + // waited on is worse than no timer: against a fake clock nobody advances, it never completes. + Task Pause(TimeSpan duration, CancellationToken cancellationToken) => + duration <= TimeSpan.Zero ? Task.CompletedTask : Task.Delay(duration, timeProvider, cancellationToken); + + static MigrationCheckpoint NotStarted(MigrationCategory category) => + new(category.Id, Selected: false, MigrationCategoryState.NotStarted, null, 0, 0, null, null, null, null, null, null, null); +} diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationEngineOptions.cs b/src/ServiceControl.Persistence/DataMigration/MigrationEngineOptions.cs new file mode 100644 index 0000000000..0b5a6e3ab3 --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/MigrationEngineOptions.cs @@ -0,0 +1,48 @@ +namespace ServiceControl.Persistence.DataMigration; + +using System; +using System.Collections.Generic; +using System.Linq; +using ServiceControl.Configuration; + +/// The engine's tuning, read from settings once at startup. +/// How long to wait between batches of optional data, so normal work isn't slowed down. Zero means don't wait. +/// A category halts when more than this percent of the rows handled in this run were skipped, and the minimum below is also passed. +/// A category never halts until more than this many rows were skipped in this run, so a few bad rows can't stop a small category. +/// The optional categories to copy. Required categories are always copied. +public sealed record MigrationEngineOptions( + TimeSpan ThrottlePause, + int HaltThresholdPercent, + int HaltThresholdMinimum, + IReadOnlyCollection SelectedOptionalCategoryIds) +{ + public static readonly TimeSpan DefaultBodyRetryBackoff = TimeSpan.FromMilliseconds(200); + + /// How long to wait before trying again to read a message body that failed. Not read from settings. + public TimeSpan BodyRetryBackoff { get; init; } = DefaultBodyRetryBackoff; + + /// Reads the options from settings. Throws if the optional categories setting names one that doesn't exist or isn't optional. + public static MigrationEngineOptions FromSettings(SettingsRootNamespace settingsRootNamespace) + { + var throttleMilliseconds = SettingsReader.Read(settingsRootNamespace, MigrationSettings.ThrottlePauseMillisecondsKey, MigrationSettings.DefaultThrottlePauseMilliseconds); + var haltPercent = SettingsReader.Read(settingsRootNamespace, MigrationSettings.HaltThresholdPercentKey, MigrationSettings.DefaultHaltThresholdPercent); + var haltMinimum = SettingsReader.Read(settingsRootNamespace, MigrationSettings.HaltThresholdMinimumKey, MigrationSettings.DefaultHaltThresholdMinimum); + var optionalCategories = SettingsReader.Read(settingsRootNamespace, MigrationSettings.OptionalCategoriesKey, string.Empty); + + var selectedIds = optionalCategories + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToArray(); + + var unknown = selectedIds + .Where(id => MigrationCategoryRegistry.Find(id) is not { Kind: MigrationCategoryKind.Optional }) + .ToArray(); + + if (unknown.Length > 0) + { + throw new InvalidOperationException( + $"{MigrationSettings.OptionalCategoriesKey} names categories that do not exist or are not optional: {string.Join(", ", unknown)}"); + } + + return new MigrationEngineOptions(TimeSpan.FromMilliseconds(throttleMilliseconds), haltPercent, haltMinimum, selectedIds); + } +} diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationSettings.cs b/src/ServiceControl.Persistence/DataMigration/MigrationSettings.cs new file mode 100644 index 0000000000..2b4faf5c91 --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/MigrationSettings.cs @@ -0,0 +1,19 @@ +namespace ServiceControl.Persistence.DataMigration; + +/// The migration's setting names, relative to the instance's settings root, and their defaults. explains the tuning ones. +public static class MigrationSettings +{ + public const string ThrottlePauseMillisecondsKey = "Migration/ThrottlePauseMilliseconds"; + public const string HaltThresholdPercentKey = "Migration/HaltThresholdPercent"; + public const string HaltThresholdMinimumKey = "Migration/HaltThresholdMinimum"; + /// A comma-separated list of the optional categories to copy, such as "EventLog, CustomChecks". + public const string OptionalCategoriesKey = "Migration/OptionalCategories"; + /// Which persister holds the old data being copied from. + public const string SourcePersistenceTypeKey = "Migration/SourcePersistenceType"; + + public const int DefaultThrottlePauseMilliseconds = 100; + public const int DefaultHaltThresholdPercent = 5; + public const int DefaultHaltThresholdMinimum = 100; + /// RavenDB is the only source supported today. + public const string DefaultSourcePersistenceType = "RavenDB"; +} diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationSkipReason.cs b/src/ServiceControl.Persistence/DataMigration/MigrationSkipReason.cs new file mode 100644 index 0000000000..c4f442305f --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/MigrationSkipReason.cs @@ -0,0 +1,6 @@ +namespace ServiceControl.Persistence.DataMigration; + +public enum MigrationSkipReason +{ + BodyUnreadable +} diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationSourceDatabase.cs b/src/ServiceControl.Persistence/DataMigration/MigrationSourceDatabase.cs deleted file mode 100644 index de1d54c3a7..0000000000 --- a/src/ServiceControl.Persistence/DataMigration/MigrationSourceDatabase.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ServiceControl.Persistence.DataMigration; - -public enum MigrationSourceDatabase -{ - Primary, - Throughput -} diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationSourceDescription.cs b/src/ServiceControl.Persistence/DataMigration/MigrationSourceDescription.cs index c8dcd27f02..3d5692a56d 100644 --- a/src/ServiceControl.Persistence/DataMigration/MigrationSourceDescription.cs +++ b/src/ServiceControl.Persistence/DataMigration/MigrationSourceDescription.cs @@ -1,8 +1,12 @@ namespace ServiceControl.Persistence.DataMigration; -public sealed record MigrationSourceDescription( - bool Embedded, - string ServerUrl, - string PrimaryDatabase, - string ThroughputDatabase, - string ServerVersion); +using System.Collections.Generic; + +/// What the source report and dry run print about the source: its version and a list of facts. +public sealed record MigrationSourceDescription(string Version, IReadOnlyList Facts); + +/// One line the source report prints, such as which server or database the source read, with the setting to change if it is wrong. +public sealed record MigrationSourceFact(string Label, string Value, string? SettingKey = null); + +/// A count the source report prints, such as the rows in one collection of one database, so an operator sees how much there is to copy. +public sealed record MigrationSourceInventoryEntry(string Scope, string Name, long Count); diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationCheckpointStore.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationCheckpointStore.cs new file mode 100644 index 0000000000..ea6803524f --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationCheckpointStore.cs @@ -0,0 +1,26 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration.Fakes; + +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ServiceControl.Persistence.DataMigration; + +public sealed class InMemoryMigrationCheckpointStore : IMigrationCheckpointStore +{ + readonly ConcurrentDictionary checkpoints = new(); + + public Task> ReadAll(CancellationToken cancellationToken = default) => + Task.FromResult>(checkpoints.Values.ToArray()); + + public Task Read(string categoryId, CancellationToken cancellationToken = default) => + Task.FromResult(checkpoints.TryGetValue(categoryId, out var checkpoint) ? checkpoint : null); + + public Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default) + { + checkpoints[checkpoint.CategoryId] = checkpoint; + return Task.CompletedTask; + } +} diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSource.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSource.cs new file mode 100644 index 0000000000..ad411cc65b --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSource.cs @@ -0,0 +1,85 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration.Fakes; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using ServiceControl.Persistence.DataMigration; + +public sealed class InMemoryMigrationSource : IMigrationSource +{ + readonly Dictionary> rowsByCategory = []; + readonly Dictionary>> bodyAttempts = []; + readonly Dictionary bodies = []; + + public MigrationSourceDescription Description { get; set; } = new("in-memory", [new MigrationSourceFact("Store", "in memory")]); + + public void Seed(string categoryId, params MigrationRow[] rows) => rowsByCategory[categoryId] = [.. rows]; + + public void SetBody(string sourceId, MigrationBody? body) => bodies[sourceId] = body; + + public void QueueBodyAttempt(string sourceId, Func attempt) + { + if (!bodyAttempts.TryGetValue(sourceId, out var queue)) + { + bodyAttempts[sourceId] = queue = new Queue>(); + } + queue.Enqueue(attempt); + } + + public Task Open(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task Describe(CancellationToken cancellationToken = default) => Task.FromResult(Description); + + public Task> Inventory(CancellationToken cancellationToken = default) => + Task.FromResult>( + [.. rowsByCategory.Select(pair => new MigrationSourceInventoryEntry("memory", pair.Key, pair.Value.Count))]); + + public Task Count(MigrationCategory category, CancellationToken cancellationToken = default) => + Task.FromResult((long)(rowsByCategory.TryGetValue(category.Id, out var rows) ? rows.Count : 0)); + + public async IAsyncEnumerable Read( + MigrationCategory category, + string? resumeAfter, + int batchSize, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var rows = rowsByCategory.GetValueOrDefault(category.Id) ?? []; + var startIndex = 0; + + if (resumeAfter is not null) + { + var cursorIndex = rows.FindIndex(r => r.SourceId == resumeAfter); + if (cursorIndex < 0) + { + throw new InvalidOperationException($"Category {category.Id} was asked to resume after {resumeAfter}, a cursor this source never issued"); + } + startIndex = cursorIndex + 1; + } + + for (var i = startIndex; i < rows.Count; i += batchSize) + { + cancellationToken.ThrowIfCancellationRequested(); + var slice = rows.Skip(i).Take(batchSize).ToArray(); + yield return new MigrationBatch(slice, slice[^1].SourceId); + await Task.Yield(); + } + } + + public async Task ReadBody(MigrationCategory category, string sourceId, CancellationToken cancellationToken = default) + { + await Task.Yield(); + + if (bodyAttempts.TryGetValue(sourceId, out var queue) && queue.Count > 0) + { + return queue.Dequeue()(); + } + + return bodies.GetValueOrDefault(sourceId); + } + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSourceTests.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSourceTests.cs new file mode 100644 index 0000000000..6d7b2cda2b --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSourceTests.cs @@ -0,0 +1,78 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration.Fakes; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; + +[TestFixture] +class InMemoryMigrationSourceTests +{ + static MigrationRow Row(string id) => new(id, new object(), new Dictionary()); + + [Test] + public async Task Streams_seeded_rows_in_batches_of_the_requested_size() + { + var source = new InMemoryMigrationSource(); + source.Seed("EndpointSettings", Row("a"), Row("b"), Row("c"), Row("d"), Row("e")); + + var batches = new List(); + await foreach (var batch in source.Read(MigrationCategoryRegistry.Find("EndpointSettings")!, resumeAfter: null, batchSize: 2)) + { + batches.Add(batch); + } + + using (Assert.EnterMultipleScope()) + { + Assert.That(batches, Has.Count.EqualTo(3)); + Assert.That(batches[0].Rows.Select(r => r.SourceId), Is.EqualTo(new[] { "a", "b" })); + Assert.That(batches[2].Rows.Select(r => r.SourceId), Is.EqualTo(new[] { "e" })); + } + } + + [Test] + public async Task ResumeAfter_skips_everything_up_to_and_including_that_id() + { + var source = new InMemoryMigrationSource(); + source.Seed("EndpointSettings", Row("a"), Row("b"), Row("c")); + + var batches = new List(); + await foreach (var batch in source.Read(MigrationCategoryRegistry.Find("EndpointSettings")!, resumeAfter: "a", batchSize: 10)) + { + batches.Add(batch); + } + + Assert.That(batches.Single().Rows.Select(r => r.SourceId), Is.EqualTo(new[] { "b", "c" })); + } + + [Test] + public void A_cursor_the_source_never_issued_throws_rather_than_starting_again() + { + var source = new InMemoryMigrationSource(); + source.Seed("EndpointSettings", Row("a"), Row("b")); + + Assert.ThrowsAsync(async () => + { + await foreach (var _ in source.Read(MigrationCategoryRegistry.Find("EndpointSettings")!, resumeAfter: "no-such-row", batchSize: 10)) + { + } + }); + } + + [Test] + public async Task ReadBody_returns_a_queued_attempt_then_falls_back_to_the_seeded_body() + { + var source = new InMemoryMigrationSource(); + var finalBody = new MigrationBody(new byte[] { 9 }, "text/plain"); + source.SetBody("msg-1", finalBody); + source.QueueBodyAttempt("msg-1", () => throw new System.Exception("transient")); + + Assert.ThrowsAsync(() => source.ReadBody(MigrationCategoryRegistry.Find(MigrationCategoryIds.UnresolvedAndRetryIssuedFailedMessages)!, "msg-1")); + var secondAttempt = await source.ReadBody(MigrationCategoryRegistry.Find(MigrationCategoryIds.UnresolvedAndRetryIssuedFailedMessages)!, "msg-1"); + + Assert.That(secondAttempt, Is.EqualTo(finalBody)); + } +} diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs new file mode 100644 index 0000000000..68bbe3139f --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs @@ -0,0 +1,85 @@ +namespace ServiceControl.UnitTests.Migration.Fakes; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using ServiceControl.Persistence.DataMigration; + +public sealed class InMemoryMigrationTarget(IMigrationCheckpointStore checkpointStore) : IMigrationTarget +{ + readonly Dictionary> writtenKeysByCategory = []; + readonly Dictionary> writtenRowsByCategory = []; + readonly HashSet preExistingKeys = []; + readonly Dictionary rejectedKeys = []; + + public int DefaultBatchSize { get; set; } = 3; + public int? FailOnCallNumber { get; set; } + public (int CallNumber, CancellationTokenSource Source)? StopOnCall { get; set; } + int callCount; + + public void SeedExistingKey(string sourceId) => preExistingKeys.Add(sourceId); + + public void RejectKey(string sourceId, string reason) => rejectedKeys[sourceId] = reason; + + public IReadOnlyList WrittenRows(string categoryId) => + writtenRowsByCategory.TryGetValue(categoryId, out var rows) ? rows : []; + + public int BatchSizeFor(MigrationCategory category) => DefaultBatchSize; + + public async Task Write( + MigrationCategory category, + MigrationBatch batch, + MigrationCheckpoint checkpointAfterBatch, + CancellationToken cancellationToken = default) + { + callCount++; + + if (StopOnCall is { } stop && stop.CallNumber == callCount) + { + await stop.Source.CancelAsync(); + throw new OperationCanceledException(stop.Source.Token); + } + + if (FailOnCallNumber == callCount) + { + throw new InvalidOperationException($"Simulated failure on write {callCount}"); + } + + var keys = writtenKeysByCategory.TryGetValue(category.Id, out var existingKeys) ? existingKeys : writtenKeysByCategory[category.Id] = []; + var rows = writtenRowsByCategory.TryGetValue(category.Id, out var existingRows) ? existingRows : writtenRowsByCategory[category.Id] = []; + + var copied = 0; + var alreadyPresent = 0; + var skippedIds = new List(); + var skipReasons = new Dictionary(); + + foreach (var row in batch.Rows) + { + if (rejectedKeys.TryGetValue(row.SourceId, out var reason)) + { + skippedIds.Add(row.SourceId); + skipReasons[reason] = skipReasons.GetValueOrDefault(reason) + 1; + continue; + } + + if (preExistingKeys.Contains(row.SourceId) || !keys.Add(row.SourceId)) + { + alreadyPresent++; + continue; + } + + rows.Add(row); + copied++; + } + + // Verbatim and in the same operation as the rows, as the real targets persist it. Adding the + // batch's own counts here would double every number the engine already included. + await checkpointStore.Upsert(checkpointAfterBatch, cancellationToken); + + return new MigrationWriteResult(copied, skippedIds.Count, skippedIds, alreadyPresent, skipReasons); + } + + public Task Count(MigrationCategory category, CancellationToken cancellationToken = default) => + Task.FromResult((long)(writtenRowsByCategory.TryGetValue(category.Id, out var rows) ? rows.Count : 0)); +} diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTargetTests.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTargetTests.cs new file mode 100644 index 0000000000..b10a340257 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTargetTests.cs @@ -0,0 +1,80 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration.Fakes; + +using System.Collections.Generic; +using System.Threading.Tasks; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; + +[TestFixture] +class InMemoryMigrationTargetTests +{ + static MigrationRow Row(string id) => new(id, new object(), new Dictionary()); + + static MigrationCheckpoint EmptyCheckpoint(string categoryId) => + new(categoryId, Selected: true, MigrationCategoryState.InProgress, Cursor: null, 0, 0, null, null, null, null, null, null, null); + + [Test] + public async Task Writes_new_rows_and_persists_the_checkpoint_it_was_handed_exactly_as_given() + { + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var category = MigrationCategoryRegistry.Find("EndpointSettings")!; + var batch = new MigrationBatch([Row("a"), Row("b")], Cursor: "b"); + // Absolute post-batch totals, computed by the caller. The target does no arithmetic on them. + var checkpointAfterBatch = EmptyCheckpoint(category.Id) with { Cursor = "b", CopiedCount = 2 }; + + var result = await target.Write(category, batch, checkpointAfterBatch); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Copied, Is.EqualTo(2)); + Assert.That(result.Skipped, Is.Zero); + Assert.That(target.WrittenRows(category.Id), Has.Count.EqualTo(2)); + Assert.That(await checkpointStore.Read(category.Id), Is.EqualTo(checkpointAfterBatch)); + } + } + + [Test] + public async Task The_persisted_checkpoint_is_not_adjusted_by_what_the_write_actually_did() + { + // The engine's totals already count every row as copied, so a target that corrected them from + // its own result would double the counts. + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; + target.SeedExistingKey("already-present"); + target.RejectKey("rejected", "Rejected"); + var batch = new MigrationBatch([Row("already-present"), Row("rejected"), Row("new-row")], Cursor: "new-row"); + var checkpointAfterBatch = EmptyCheckpoint(category.Id) with { Cursor = "new-row", CopiedCount = 3 }; + + var result = await target.Write(category, batch, checkpointAfterBatch); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Copied, Is.EqualTo(1)); + Assert.That(result.Skipped, Is.EqualTo(1)); + Assert.That(result.SkippedIds, Is.EqualTo(new[] { "rejected" })); + Assert.That(result.SkipReasons, Is.EquivalentTo(new Dictionary { ["Rejected"] = 1 })); + Assert.That(result.AlreadyPresent, Is.EqualTo(1)); + Assert.That(target.WrittenRows(category.Id), Has.Count.EqualTo(1)); + Assert.That((await checkpointStore.Read(category.Id))!.CopiedCount, Is.EqualTo(3)); + } + } + + [Test] + public async Task Count_answers_for_one_category_rather_than_one_table() + { + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var required = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; + var archive = MigrationCategoryRegistry.Find("ArchivedAndResolvedFailedMessages")!; + await target.Write(required, new MigrationBatch([Row("a"), Row("b")], "b"), EmptyCheckpoint(required.Id)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(await target.Count(required), Is.EqualTo(2)); + Assert.That(await target.Count(archive), Is.Zero); + } + } +} diff --git a/src/ServiceControl.UnitTests/Migration/HaltThresholdTests.cs b/src/ServiceControl.UnitTests/Migration/HaltThresholdTests.cs new file mode 100644 index 0000000000..f0b02dbdfb --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/HaltThresholdTests.cs @@ -0,0 +1,50 @@ +namespace ServiceControl.UnitTests.Migration; + +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; + +[TestFixture] +class HaltThresholdTests +{ + [Test] + public void Does_not_halt_a_three_row_category_with_one_bad_row() + { + // 1 of 3 is 33%, well past the 5% proportion, but 1 skip never passes the 100-row floor. + var exceeded = HaltThreshold.Exceeded(skippedCount: 1, totalCount: 3, percentThreshold: 5, minimumFloor: 100); + + Assert.That(exceeded, Is.False); + } + + [Test] + public void Halts_a_large_category_with_a_systemic_failure() + { + // 600 of 10,000 is 6%, past both the proportion and the floor. + var exceeded = HaltThreshold.Exceeded(skippedCount: 600, totalCount: 10_000, percentThreshold: 5, minimumFloor: 100); + + Assert.That(exceeded, Is.True); + } + + [Test] + public void Does_not_halt_a_large_category_with_only_a_small_proportion_skipped() + { + // 10,000 of 5,000,000 is 0.2%: past the floor, nowhere near the proportion. + var exceeded = HaltThreshold.Exceeded(skippedCount: 10_000, totalCount: 5_000_000, percentThreshold: 5, minimumFloor: 100); + + Assert.That(exceeded, Is.False); + } + + [Test] + public void Exactly_at_both_boundaries_does_not_halt_because_both_must_be_exceeded() + { + // 100 of 2,000 is exactly 5% and exactly the floor. "Exceed" means strictly past, not "at". + var exceeded = HaltThreshold.Exceeded(skippedCount: 100, totalCount: 2_000, percentThreshold: 5, minimumFloor: 100); + + Assert.That(exceeded, Is.False); + } + + [Test] + public void No_rows_processed_yet_never_halts() + { + Assert.That(HaltThreshold.Exceeded(skippedCount: 0, totalCount: 0, percentThreshold: 5, minimumFloor: 100), Is.False); + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationCategoryRegistryTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationCategoryRegistryTests.cs new file mode 100644 index 0000000000..9b5e637ec3 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationCategoryRegistryTests.cs @@ -0,0 +1,110 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration; + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence.DataMigration; + +[TestFixture] +class MigrationCategoryRegistryTests +{ + [Test] + public void Contains_all_eighteen_categories_with_unique_ids() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(MigrationCategoryRegistry.All, Has.Count.EqualTo(18)); + Assert.That(MigrationCategoryRegistry.All.Select(c => c.Id).Distinct().Count(), Is.EqualTo(18)); + } + } + + [Test] + public void Required_categories_are_ordered_exactly_as_the_contract_lists_them() + { + var requiredIds = MigrationCategoryRegistry.All + .Where(c => c.Kind == MigrationCategoryKind.Required) + .OrderBy(c => c.Order) + .Select(c => c.Id) + .ToArray(); + + Assert.That(requiredIds, Is.EqualTo(new[] + { + "KnownEndpoints", "EndpointSettings", "MessageRedirects", "Subscriptions", + "NotificationSettings", "TrialEndDate", "RetryOperations", + "LicensingEndpoints", "LicensingThroughput", "LicensingReportMasks", + "LicensedEndpointDetails", "UnresolvedAndRetryIssuedFailedMessages" + })); + } + + [Test] + public void Optional_categories_are_ordered_exactly_as_the_contract_lists_them() + { + var optionalIds = MigrationCategoryRegistry.All + .Where(c => c.Kind == MigrationCategoryKind.Optional) + .OrderBy(c => c.Order) + .Select(c => c.Id) + .ToArray(); + + Assert.That(optionalIds, Is.EqualTo(new[] + { + "EventLog", "CustomChecks", "FailedErrorImports", + "FailedMessageEdits", "ArchivedAndResolvedFailedMessages", "GroupComments" + })); + } + + [Test] + public void Three_categories_declare_a_MustFollow() + { + var declared = MigrationCategoryRegistry.All + .Where(c => c.MustFollow is not null) + .ToDictionary(c => c.Id, c => c.MustFollow); + + Assert.That(declared, Is.EqualTo(new Dictionary + { + // A cascading foreign key: throughput rows cannot exist before their endpoint row. + ["LicensingThroughput"] = "LicensingEndpoints", + // Keeps settings back while known endpoints are unfinished. The heartbeat sync is kept off + // the copied settings because both categories finish before the host opens. + ["EndpointSettings"] = "KnownEndpoints", + // A group comment whose failed messages have not arrived reads as an orphan to the + // retention sweeper, which deletes it. + ["GroupComments"] = "ArchivedAndResolvedFailedMessages" + })); + } + + [Test] + public void The_two_failed_message_categories_and_failed_imports_carry_bodies() + { + var carriesBodies = MigrationCategoryRegistry.All.Where(c => c.CarriesBodies).Select(c => c.Id).ToArray(); + + Assert.That(carriesBodies, Is.EquivalentTo(new[] { "UnresolvedAndRetryIssuedFailedMessages", "ArchivedAndResolvedFailedMessages", "FailedErrorImports" })); + } + + [Test] + public void The_two_failed_message_categories_split_every_status_between_them() + { + var split = MigrationCategoryRegistry.UnresolvedAndRetryIssuedStatuses.Concat(MigrationCategoryRegistry.ArchivedAndResolvedStatuses).ToArray(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(split, Is.Unique); + Assert.That(split, Is.EquivalentTo(Enum.GetValues()), + "a status neither category claims is a failed message no migration copies"); + } + } + + [Test] + public void Find_returns_null_for_an_unknown_id() + { + Assert.That(MigrationCategoryRegistry.Find("NoSuchCategory"), Is.Null); + } + + [Test] + public void Find_returns_the_category_for_a_known_id() + { + Assert.That(MigrationCategoryRegistry.Find("EventLog")?.Kind, Is.EqualTo(MigrationCategoryKind.Optional)); + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationContractShapeTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationContractShapeTests.cs new file mode 100644 index 0000000000..2c662bffbe --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationContractShapeTests.cs @@ -0,0 +1,99 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration; + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; + +[TestFixture] +class MigrationContractShapeTests +{ + [Test] + public void MigrationCategory_carries_no_fact_about_where_a_store_keeps_its_rows() + { + var properties = typeof(MigrationCategory).GetProperties().Select(p => p.Name); + + Assert.That(properties, Is.EquivalentTo(new[] { "Id", "Kind", "CarriesBodies", "Order", "MustFollow" }), + "a source database or target table belongs in that store's own adapter, where a different store pair can map it differently"); + } + + [Test] + public void Abandoned_is_a_state_of_its_own_and_not_a_kind_of_complete() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(MigrationCategoryState.Abandoned, Is.Not.EqualTo(MigrationCategoryState.Complete)); + Assert.That(MigrationCategoryState.Abandoned, Is.Not.EqualTo(MigrationCategoryState.CompleteWithErrors)); + Assert.That(MigrationCategoryState.Abandoned, Is.Not.EqualTo(MigrationCategoryState.Halted)); + } + } + + [Test] + public void Category_states_keep_the_integers_stored_checkpoints_already_hold() + { + var stored = System.Enum.GetValues().ToDictionary(state => state.ToString(), state => (int)state); + + Assert.That(stored, Is.EqualTo(new Dictionary + { + ["NotStarted"] = 0, + ["InProgress"] = 1, + ["Complete"] = 2, + ["CompleteWithErrors"] = 3, + ["Halted"] = 4, + ["Abandoned"] = 5 + }), "checkpoints store State as an integer, so a reordered or inserted member silently changes what every saved checkpoint means"); + } + + [Test] + public void MigrationRow_body_defaults_to_null() + { + var row = new MigrationRow("id-1", new object(), new Dictionary()); + + Assert.That(row.Body, Is.Null); + } + + [Test] + public void MigrationRow_can_carry_a_body() + { + var body = new MigrationBody(new byte[] { 1, 2, 3 }, "text/plain"); + var row = new MigrationRow("id-1", new object(), new Dictionary(), body); + + Assert.That(row.Body, Is.EqualTo(body)); + } + + [Test] + public void MigrationBatch_groups_rows_under_one_cursor() + { + var rows = new[] { new MigrationRow("id-1", new object(), new Dictionary()) }; + var batch = new MigrationBatch(rows, Cursor: "id-1"); + + Assert.That(batch.Cursor, Is.EqualTo("id-1")); + } + + [Test] + public void MigrationCheckpoint_starts_with_no_cursor_and_zero_counts() + { + var checkpoint = new MigrationCheckpoint( + CategoryId: "EndpointSettings", + Selected: false, + State: MigrationCategoryState.NotStarted, + Cursor: null, + CopiedCount: 0, + SkippedCount: 0, + SourceTotal: null, + SkipReasons: null, + StartedAt: null, + LastProgressAt: null, + CompletedAt: null, + AbandonedAt: null, + LastError: null); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.Cursor, Is.Null); + Assert.That(checkpoint.CopiedCount, Is.Zero); + Assert.That(checkpoint.AlreadyPresentCount, Is.Zero); + } + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineBodyRetryTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineBodyRetryTests.cs new file mode 100644 index 0000000000..a2680372f8 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineBodyRetryTests.cs @@ -0,0 +1,124 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; +using ServiceControl.UnitTests.Migration.Fakes; + +[TestFixture] +class MigrationEngineBodyRetryTests +{ + static MigrationRow Row(string id) => new(id, new object(), new Dictionary()); + + [Test] + public async Task A_body_that_fails_until_the_last_attempt_then_succeeds_is_written_with_its_body_attached() + { + var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("msg-1")); + var body = new MigrationBody(new byte[] { 1 }, "text/plain"); + source.SetBody("msg-1", body); + for (var attempt = 1; attempt < MigrationEngine.MaxBodyReadAttempts; attempt++) + { + source.QueueBodyAttempt("msg-1", () => throw new InvalidOperationException("blip")); + } + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + // Zeroed because FakeTimeProvider is never advanced here: a 200 ms Task.Delay against a clock + // nobody moves never completes, and the test hangs until the runner kills it. + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = TimeSpan.Zero }; + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); + Assert.That(checkpoint.SkippedCount, Is.Zero); + Assert.That(target.WrittenRows(category.Id).Single().Body, Is.EqualTo(body)); + } + } + + [Test] + public async Task A_body_the_source_already_attached_is_written_without_reading_it_again() + { + var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; + var source = new InMemoryMigrationSource(); + var attached = new MigrationBody(new byte[] { 7 }, "text/plain"); + source.Seed(category.Id, Row("msg-1") with { Body = attached }); + for (var attempt = 0; attempt < MigrationEngine.MaxBodyReadAttempts; attempt++) + { + source.QueueBodyAttempt("msg-1", () => throw new InvalidOperationException("a body read nobody needed")); + } + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = TimeSpan.Zero }; + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); + Assert.That(target.WrittenRows(category.Id).Single().Body, Is.EqualTo(attached)); + } + } + + [Test] + public async Task Exhausted_attempts_skip_the_whole_message_and_count_toward_the_halt_threshold() + { + var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("msg-1"), Row("msg-2")); + for (var attempt = 0; attempt < MigrationEngine.MaxBodyReadAttempts; attempt++) + { + source.QueueBodyAttempt("msg-1", () => throw new InvalidOperationException("down")); + } + source.SetBody("msg-2", new MigrationBody(new byte[] { 2 }, "text/plain")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = TimeSpan.Zero }; + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + // msg-1 is excluded entirely: not written with a missing body, not written at all. + Assert.That(target.WrittenRows(category.Id).Select(r => r.SourceId), Is.EqualTo(new[] { "msg-2" })); + Assert.That(checkpoint.SkippedCount, Is.EqualTo(1)); + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.CompleteWithErrors)); + } + } + + [Test] + public async Task A_body_store_outage_across_many_messages_halts_the_category() + { + var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; + var source = new InMemoryMigrationSource(); + var ids = Enumerable.Range(1, 1_000).Select(i => $"msg-{i}").ToArray(); + source.Seed(category.Id, [.. ids.Select(Row)]); + // Every body read fails every attempt: a down body store, not a per-message fluke. + foreach (var id in ids) + { + for (var attempt = 0; attempt < MigrationEngine.MaxBodyReadAttempts; attempt++) + { + source.QueueBodyAttempt(id, () => throw new InvalidOperationException("body store unreachable")); + } + } + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 100 }; + var options = new MigrationEngineOptions(TimeSpan.Zero, HaltThresholdPercent: 5, HaltThresholdMinimum: 100, []) { BodyRetryBackoff = TimeSpan.Zero }; + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Halted)); + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs new file mode 100644 index 0000000000..82d806a947 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs @@ -0,0 +1,64 @@ +namespace ServiceControl.UnitTests.Migration; + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; +using ServiceControl.UnitTests.Migration.Fakes; + +[TestFixture] +class MigrationEngineCategorySelectionTests +{ + static MigrationEngine BuildEngine(IReadOnlyCollection selectedOptionalIds, out InMemoryMigrationCheckpointStore checkpointStore) + { + checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var options = new MigrationEngineOptions(TimeSpan.FromSeconds(1), 5, 100, selectedOptionalIds); + return new MigrationEngine(new InMemoryMigrationSource(), target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + } + + [Test] + public void All_twelve_required_categories_are_always_selected() + { + var engine = BuildEngine([], out _); + + Assert.That(engine.SelectCategories(MigrationCategoryKind.Required), Has.Count.EqualTo(12)); + } + + [Test] + public void Only_configured_optional_categories_are_selected() + { + var engine = BuildEngine(["EventLog"], out _); + + var selected = engine.SelectCategories(MigrationCategoryKind.Optional); + + Assert.That(selected.Select(c => c.Id), Is.EqualTo(new[] { "EventLog" })); + } + + [Test] + public void A_category_removed_from_configuration_leaves_its_checkpoint_row_untouched() + { + var engine = BuildEngine([], out var checkpointStore); + var previousRun = new MigrationCheckpoint("EventLog", Selected: true, MigrationCategoryState.CompleteWithErrors, "cursor-99", 40, 2, 42, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null, null); + checkpointStore.Upsert(previousRun).GetAwaiter().GetResult(); + + var selected = engine.SelectCategories(MigrationCategoryKind.Optional); + + using (Assert.EnterMultipleScope()) + { + Assert.That(selected.Select(c => c.Id), Does.Not.Contain("EventLog")); + Assert.That(checkpointStore.Read("EventLog").GetAwaiter().GetResult(), Is.EqualTo(previousRun)); + } + } + + [Test] + public void A_category_added_to_configuration_is_selected_on_the_next_run() + { + var engine = BuildEngine(["CustomChecks"], out _); + + Assert.That(engine.SelectCategories(MigrationCategoryKind.Optional).Select(c => c.Id), Is.EqualTo(new[] { "CustomChecks" })); + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineCollisionTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineCollisionTests.cs new file mode 100644 index 0000000000..59b53c6a07 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineCollisionTests.cs @@ -0,0 +1,45 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; +using ServiceControl.UnitTests.Migration.Fakes; + +[TestFixture] +class MigrationEngineCollisionTests +{ + static MigrationRow Row(string id, string name) => new(id, new { Name = name }, new Dictionary()); + + [Test] + public async Task Rows_already_present_in_the_target_are_counted_as_already_present_across_batches_not_skipped_or_overwritten() + { + var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("msg-1", "from-source"), Row("msg-2", "from-source"), Row("msg-3", "from-source"), Row("msg-4", "from-source")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 3 }; + // msg-1 and msg-4 already exist in the target: they failed again after cutover, and real ingestion wrote them. + target.SeedExistingKey("msg-1"); + target.SeedExistingKey("msg-4"); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); + Assert.That(checkpoint.CopiedCount, Is.EqualTo(2)); + Assert.That(checkpoint.SkippedCount, Is.Zero); + Assert.That(checkpoint.AlreadyPresentCount, Is.EqualTo(2)); + // Asserted through the fake's recorded rows, never by the engine inspecting a document. + Assert.That(target.WrittenRows(category.Id).Select(r => r.SourceId), Is.EqualTo(new[] { "msg-2", "msg-3" })); + } + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineCopyTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineCopyTests.cs new file mode 100644 index 0000000000..c48c726e82 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineCopyTests.cs @@ -0,0 +1,64 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; +using ServiceControl.UnitTests.Migration.Fakes; + +[TestFixture] +class MigrationEngineCopyTests +{ + static MigrationRow Row(string id) => new(id, new { Name = id }, new Dictionary()); + + [Test] + public async Task Copies_every_row_and_finishes_Complete_when_nothing_was_skipped() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b"), Row("c")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2 }; + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); + Assert.That(checkpoint.CopiedCount, Is.EqualTo(3)); + Assert.That(checkpoint.SkippedCount, Is.Zero); + Assert.That(checkpoint.Cursor, Is.EqualTo("c")); + Assert.That(checkpoint.StartedAt, Is.Not.Null); + Assert.That(checkpoint.CompletedAt, Is.Not.Null); + Assert.That(target.WrittenRows(category.Id), Has.Count.EqualTo(3)); + } + } + + [Test] + public async Task A_category_already_Complete_is_left_alone_on_a_second_run() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var alreadyDone = new MigrationCheckpoint(category.Id, true, MigrationCategoryState.Complete, "a", 1, 0, 1, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null, null); + await checkpointStore.Upsert(alreadyDone); + var target = new InMemoryMigrationTarget(checkpointStore); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint, Is.EqualTo(alreadyDone)); + Assert.That(target.WrittenRows(category.Id), Is.Empty); + } + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs new file mode 100644 index 0000000000..4c42966148 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs @@ -0,0 +1,163 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; +using ServiceControl.UnitTests.Migration.Fakes; + +[TestFixture] +class MigrationEngineFailurePathTests +{ + static MigrationRow Row(string id) => new(id, new object(), new Dictionary()); + + static MigrationEngine BuildEngine(InMemoryMigrationSource source, InMemoryMigrationCheckpointStore checkpointStore, InMemoryMigrationTarget target) => + new(source, target, checkpointStore, new FakeTimeProvider(), new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []), NullLogger.Instance); + + [Test] + public async Task A_failing_write_halts_the_category_and_records_the_error_instead_of_throwing() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b"), Row("c"), Row("d")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2, FailOnCallNumber = 2 }; + var engine = BuildEngine(source, checkpointStore, target); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Halted)); + Assert.That(checkpoint.LastError, Does.Contain("Simulated failure")); + // The first batch committed, so the cursor is real and a later run resumes from it. + Assert.That(checkpoint.Cursor, Is.EqualTo("b")); + Assert.That(checkpoint.CopiedCount, Is.EqualTo(2)); + } + } + + [Test] + public async Task The_halt_is_durable_so_the_health_check_and_the_guard_can_read_it() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2, FailOnCallNumber = 1 }; + var engine = BuildEngine(source, checkpointStore, target); + + await engine.RunCategoryAsync(category); + + var persisted = await checkpointStore.Read(category.Id); + using (Assert.EnterMultipleScope()) + { + Assert.That(persisted!.State, Is.EqualTo(MigrationCategoryState.Halted)); + Assert.That(persisted.LastError, Is.Not.Null); + } + } + + [Test] + public void A_cancelled_run_is_a_shutdown_rather_than_a_failure_and_is_not_swallowed() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + using var stopping = new CancellationTokenSource(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2, StopOnCall = (1, stopping) }; + var engine = BuildEngine(source, checkpointStore, target); + + Assert.ThrowsAsync(() => engine.RunCategoryAsync(category, stopping.Token)); + } + + [Test] + public async Task A_halted_category_is_re_attempted_on_the_next_run_and_resumes_from_its_cursor() + { + // A halt that no restart can clear would leave abandoning the category as the only way out of + // a fault the customer has already repaired. + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b"), Row("c"), Row("d")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2, FailOnCallNumber = 2 }; + var firstRun = BuildEngine(source, checkpointStore, target); + var halted = await firstRun.RunCategoryAsync(category); + Assert.That(halted.State, Is.EqualTo(MigrationCategoryState.Halted)); + + target.FailOnCallNumber = null; + var secondRun = BuildEngine(source, checkpointStore, target); + var finished = await secondRun.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(finished.State, Is.EqualTo(MigrationCategoryState.Complete)); + Assert.That(finished.LastError, Is.Null, "a cleared halt does not leave a stale error on the row"); + Assert.That(finished.CopiedCount, Is.EqualTo(4)); + var writtenIds = target.WrittenRows(category.Id).Select(r => r.SourceId).ToArray(); + Assert.That(writtenIds, Is.EquivalentTo(new[] { "a", "b", "c", "d" })); + Assert.That(writtenIds.Distinct().Count(), Is.EqualTo(writtenIds.Length), "no duplicates across the halt"); + } + } + + [Test] + public async Task Body_skips_in_a_batch_whose_write_fails_are_counted_once_across_the_restart() + { + var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("msg-1"), Row("msg-2")); + // msg-1's body is unreadable on both runs: every attempt fails on each. + for (var attempt = 0; attempt < 2 * MigrationEngine.MaxBodyReadAttempts; attempt++) + { + source.QueueBodyAttempt("msg-1", () => throw new InvalidOperationException("body store unreachable")); + } + source.SetBody("msg-2", new MigrationBody(new byte[] { 2 }, "text/plain")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2, FailOnCallNumber = 1 }; + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = TimeSpan.Zero }; + var firstRun = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + var halted = await firstRun.RunCategoryAsync(category); + + target.FailOnCallNumber = null; + var secondRun = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + var finished = await secondRun.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(halted.State, Is.EqualTo(MigrationCategoryState.Halted)); + // The failed batch never committed, so its skip is recorded only when the batch is read again. + Assert.That(halted.SkippedCount, Is.Zero); + Assert.That(finished.State, Is.EqualTo(MigrationCategoryState.CompleteWithErrors)); + Assert.That(finished.SkippedCount, Is.EqualTo(1)); + Assert.That(target.WrittenRows(category.Id).Select(r => r.SourceId), Is.EqualTo(new[] { "msg-2" })); + } + } + + [Test] + public async Task An_abandoned_category_is_left_exactly_as_it_is() + { + // Abandoned is the one end a person chooses: this category will never be copied, and the + // guard and the health check both treat it as settled. A later run must not quietly restart it. + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var abandoned = new MigrationCheckpoint(category.Id, true, MigrationCategoryState.Abandoned, "a", 1, 3, 4, null, DateTime.UtcNow, DateTime.UtcNow, null, DateTime.UtcNow, "Halted: the body store was unreachable"); + await checkpointStore.Upsert(abandoned); + var target = new InMemoryMigrationTarget(checkpointStore); + var engine = BuildEngine(source, checkpointStore, target); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint, Is.EqualTo(abandoned)); + Assert.That(target.WrittenRows(category.Id), Is.Empty); + } + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs new file mode 100644 index 0000000000..fb114a7409 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs @@ -0,0 +1,84 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; +using ServiceControl.UnitTests.Migration.Fakes; + +[TestFixture] +class MigrationEngineHaltTests +{ + static MigrationRow Row(string id) => new(id, new object(), new Dictionary()); + + [Test] + public async Task A_systemic_failure_halts_the_category_partway_through() + { + var category = MigrationCategoryRegistry.Find("ArchivedAndResolvedFailedMessages")!; + var source = new InMemoryMigrationSource(); + // Every 5th of 1,000 rows is rejected: a steady 20% spread evenly rather than clustered at the + // start, past both the 5% proportion and the 100-row floor. + var rows = Enumerable.Range(1, 1_000).Select(i => Row($"row-{i}")).ToArray(); + source.Seed(category.Id, rows); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 100 }; + foreach (var i in Enumerable.Range(1, 1_000).Where(i => i % 5 == 0)) + { + target.RejectKey($"row-{i}", "Rejected"); + } + var options = new MigrationEngineOptions(TimeSpan.Zero, HaltThresholdPercent: 5, HaltThresholdMinimum: 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Halted)); + Assert.That(checkpoint.LastError, Does.Contain("Halted")); + Assert.That(checkpoint.CompletedAt, Is.Not.Null); + // Stopped partway: the 1,000th row was never reached. + Assert.That(target.WrittenRows(category.Id).Count, Is.LessThan(800)); + } + } + + [Test] + public async Task Rows_already_present_in_the_target_never_count_toward_the_halt_threshold() + { + var category = MigrationCategoryRegistry.Find("ArchivedAndResolvedFailedMessages")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, [.. Enumerable.Range(1, 1_000).Select(i => Row($"row-{i}"))]); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 100 }; + foreach (var i in Enumerable.Range(1, 1_000).Where(i => i % 5 == 0)) + { + target.SeedExistingKey($"row-{i}"); + } + var options = new MigrationEngineOptions(TimeSpan.Zero, HaltThresholdPercent: 5, HaltThresholdMinimum: 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); + Assert.That(checkpoint.CopiedCount, Is.EqualTo(800)); + Assert.That(checkpoint.AlreadyPresentCount, Is.EqualTo(200)); + } + } + + [Test] + public void Halted_is_distinct_from_Complete_and_CompleteWithErrors() + { + using (Assert.EnterMultipleScope()) + { + Assert.That(MigrationCategoryState.Halted, Is.Not.EqualTo(MigrationCategoryState.Complete)); + Assert.That(MigrationCategoryState.Halted, Is.Not.EqualTo(MigrationCategoryState.CompleteWithErrors)); + Assert.That(MigrationCategoryState.CompleteWithErrors, Is.Not.EqualTo(MigrationCategoryState.Complete)); + } + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineOptionsTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineOptionsTests.cs new file mode 100644 index 0000000000..eb296bfd71 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineOptionsTests.cs @@ -0,0 +1,80 @@ +namespace ServiceControl.UnitTests.Migration; + +using System; +using NUnit.Framework; +using ServiceControl.Configuration; +using ServiceControl.Persistence.DataMigration; + +[TestFixture] +[NonParallelizable] +class MigrationEngineOptionsTests +{ + static readonly SettingsRootNamespace Namespace = new("ServiceControl"); + + [TearDown] + public void ClearEnvironmentVariables() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_THROTTLEPAUSEMILLISECONDS", null); + Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_HALTTHRESHOLDPERCENT", null); + Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_HALTTHRESHOLDMINIMUM", null); + Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_OPTIONALCATEGORIES", null); + } + + [Test] + public void Defaults_match_the_contract_when_nothing_is_configured() + { + var options = MigrationEngineOptions.FromSettings(Namespace); + + using (Assert.EnterMultipleScope()) + { + // At one second a million-row copy in 50-row batches spends five and a half hours paused + // before it does any work at all. + Assert.That(options.ThrottlePause, Is.EqualTo(TimeSpan.FromMilliseconds(100))); + Assert.That(options.HaltThresholdPercent, Is.EqualTo(5)); + Assert.That(options.HaltThresholdMinimum, Is.EqualTo(100)); + Assert.That(options.SelectedOptionalCategoryIds, Is.Empty); + Assert.That(options.BodyRetryBackoff, Is.EqualTo(TimeSpan.FromMilliseconds(200))); + } + } + + [Test] + public void Reads_configured_values_from_environment_variables() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_THROTTLEPAUSEMILLISECONDS", "2500"); + Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_HALTTHRESHOLDPERCENT", "10"); + Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_HALTTHRESHOLDMINIMUM", "50"); + Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_OPTIONALCATEGORIES", "EventLog, CustomChecks"); + + var options = MigrationEngineOptions.FromSettings(Namespace); + + using (Assert.EnterMultipleScope()) + { + Assert.That(options.ThrottlePause, Is.EqualTo(TimeSpan.FromMilliseconds(2500))); + Assert.That(options.HaltThresholdPercent, Is.EqualTo(10)); + Assert.That(options.HaltThresholdMinimum, Is.EqualTo(50)); + Assert.That(options.SelectedOptionalCategoryIds, Is.EquivalentTo(new[] { "EventLog", "CustomChecks" })); + } + } + + [Test] + public void Refuses_an_unknown_optional_category_id() + { + Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_OPTIONALCATEGORIES", "NoSuchCategory"); + + var ex = Assert.Throws(() => MigrationEngineOptions.FromSettings(Namespace)); + + Assert.That(ex.Message, Does.Contain("NoSuchCategory")); + } + + [Test] + public void Refuses_a_required_category_id_named_as_optional() + { + // EndpointSettings is required, not optional: naming it here is a customer mistake, not + // a valid way to force it. Required categories are never a matter of configuration. + Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_OPTIONALCATEGORIES", "EndpointSettings"); + + var ex = Assert.Throws(() => MigrationEngineOptions.FromSettings(Namespace)); + + Assert.That(ex.Message, Does.Contain("EndpointSettings")); + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs new file mode 100644 index 0000000000..abf41f8ac9 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs @@ -0,0 +1,137 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; +using ServiceControl.UnitTests.Migration.Fakes; + +[TestFixture] +class MigrationEngineOrderingTests +{ + static MigrationRow Row(string id) => new(id, new object(), new Dictionary()); + + static MigrationEngine BuildEngine(InMemoryMigrationSource source, InMemoryMigrationCheckpointStore checkpointStore, InMemoryMigrationTarget target) => + new(source, target, checkpointStore, new FakeTimeProvider(), new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []), NullLogger.Instance); + + [Test] + public async Task LicensingThroughput_does_not_start_before_LicensingEndpoints_completes() + { + var throughputCategory = MigrationCategoryRegistry.Find("LicensingThroughput")!; + var source = new InMemoryMigrationSource(); + source.Seed(throughputCategory.Id, Row("t-1")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var engine = BuildEngine(source, checkpointStore, target); + + // LicensingEndpoints has never run: no checkpoint row for it at all. + var checkpoint = await engine.RunCategoryAsync(throughputCategory); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.NotStarted)); + Assert.That(target.WrittenRows(throughputCategory.Id), Is.Empty); + } + } + + [Test] + public async Task A_blocked_category_says_so_on_its_checkpoint_row_instead_of_returning_in_silence() + { + var throughputCategory = MigrationCategoryRegistry.Find("LicensingThroughput")!; + var source = new InMemoryMigrationSource(); + source.Seed(throughputCategory.Id, Row("t-1")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var engine = BuildEngine(source, checkpointStore, target); + + await engine.RunCategoryAsync(throughputCategory); + + var persisted = await checkpointStore.Read(throughputCategory.Id); + using (Assert.EnterMultipleScope()) + { + // A row exists, so status can print it. Without one, an operator cannot tell a category + // waiting on another from a category nobody asked for. + Assert.That(persisted, Is.Not.Null); + Assert.That(persisted!.Selected, Is.True); + Assert.That(persisted.State, Is.EqualTo(MigrationCategoryState.NotStarted)); + Assert.That(persisted.LastError, Does.Contain("LicensingEndpoints")); + } + } + + [Test] + public async Task EndpointSettings_does_not_start_before_KnownEndpoints_completes() + { + // Not a foreign key: settings wait for known endpoints, and a halted required category keeps + // the host, and with it the heartbeat sync, closed. + var settingsCategory = MigrationCategoryRegistry.Find("EndpointSettings")!; + var source = new InMemoryMigrationSource(); + source.Seed(settingsCategory.Id, Row("EndpointSettings/1")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var engine = BuildEngine(source, checkpointStore, target); + + var checkpoint = await engine.RunCategoryAsync(settingsCategory); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.NotStarted)); + Assert.That(target.WrittenRows(settingsCategory.Id), Is.Empty); + } + } + + [Test] + public async Task GroupComments_does_not_start_before_the_archive_completes() + { + var comments = MigrationCategoryRegistry.Find("GroupComments")!; + var source = new InMemoryMigrationSource(); + source.Seed(comments.Id, Row("GroupComment/g-1")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + await checkpointStore.Upsert(new MigrationCheckpoint("ArchivedAndResolvedFailedMessages", true, MigrationCategoryState.InProgress, "m-500", 500, 0, null, null, DateTime.UtcNow, DateTime.UtcNow, null, null, null)); + var target = new InMemoryMigrationTarget(checkpointStore); + var engine = BuildEngine(source, checkpointStore, target); + + var checkpoint = await engine.RunCategoryAsync(comments); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.NotStarted)); + Assert.That(target.WrittenRows(comments.Id), Is.Empty); + } + } + + [Test] + public async Task LicensingThroughput_proceeds_once_LicensingEndpoints_is_Complete() + { + var throughputCategory = MigrationCategoryRegistry.Find("LicensingThroughput")!; + var source = new InMemoryMigrationSource(); + source.Seed(throughputCategory.Id, Row("t-1")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + await checkpointStore.Upsert(new MigrationCheckpoint("LicensingEndpoints", true, MigrationCategoryState.Complete, "e-1", 1, 0, 1, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null, null)); + var target = new InMemoryMigrationTarget(checkpointStore); + var engine = BuildEngine(source, checkpointStore, target); + + var checkpoint = await engine.RunCategoryAsync(throughputCategory); + + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); + } + + [Test] + public async Task LicensingThroughput_proceeds_when_LicensingEndpoints_is_CompleteWithErrors() + { + var throughputCategory = MigrationCategoryRegistry.Find("LicensingThroughput")!; + var source = new InMemoryMigrationSource(); + source.Seed(throughputCategory.Id, Row("t-1")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + await checkpointStore.Upsert(new MigrationCheckpoint("LicensingEndpoints", true, MigrationCategoryState.CompleteWithErrors, "e-1", 9, 1, 10, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null, null)); + var target = new InMemoryMigrationTarget(checkpointStore); + var engine = BuildEngine(source, checkpointStore, target); + + var checkpoint = await engine.RunCategoryAsync(throughputCategory); + + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs new file mode 100644 index 0000000000..e0b5fd13ed --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs @@ -0,0 +1,60 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; +using ServiceControl.UnitTests.Migration.Fakes; + +[TestFixture] +class MigrationEngineResumeTests +{ + static MigrationRow Row(string id) => new(id, new object(), new Dictionary()); + + [Test] + public async Task Restarting_after_a_mid_category_stop_produces_no_duplicates_and_no_gaps() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + var allIds = Enumerable.Range(1, 6).Select(i => $"row-{i}").ToArray(); + source.Seed(category.Id, [.. allIds.Select(Row)]); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + using var stopping = new CancellationTokenSource(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2, StopOnCall = (3, stopping) }; + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var firstEngine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + // Batches are 2 rows each; the host stops on the 3rd call to Write, so exactly 2 batches (4 rows) land. + Assert.ThrowsAsync(() => firstEngine.RunCategoryAsync(category, stopping.Token)); + + var afterStop = await checkpointStore.Read(category.Id); + using (Assert.EnterMultipleScope()) + { + // Still InProgress, not Halted: a cancelled run is a shutdown, not a failure. + Assert.That(afterStop!.State, Is.EqualTo(MigrationCategoryState.InProgress)); + Assert.That(afterStop.CopiedCount, Is.EqualTo(4)); + Assert.That(afterStop.Cursor, Is.EqualTo("row-4")); + Assert.That(target.WrittenRows(category.Id), Has.Count.EqualTo(4)); + } + + // Restart: same source, same target, same checkpoint store, nothing stopping it this time. + target.StopOnCall = null; + var secondEngine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + var finalCheckpoint = await secondEngine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(finalCheckpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); + Assert.That(finalCheckpoint.CopiedCount, Is.EqualTo(6)); + var writtenIds = target.WrittenRows(category.Id).Select(r => r.SourceId).ToArray(); + Assert.That(writtenIds, Is.EquivalentTo(allIds), "no gaps"); + Assert.That(writtenIds.Distinct().Count(), Is.EqualTo(writtenIds.Length), "no duplicates"); + } + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineRunCategoriesTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineRunCategoriesTests.cs new file mode 100644 index 0000000000..8503b3c257 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineRunCategoriesTests.cs @@ -0,0 +1,67 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; +using ServiceControl.UnitTests.Migration.Fakes; + +[TestFixture] +class MigrationEngineRunCategoriesTests +{ + static MigrationRow Row(string id) => new(id, new object(), new Dictionary()); + + [Test] + public async Task Runs_every_category_it_is_given_in_the_order_it_is_given_them() + { + var source = new InMemoryMigrationSource(); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), + new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []), NullLogger.Instance); + var ordered = engine.SelectCategories(MigrationCategoryKind.Required).Take(2).ToArray(); + foreach (var category in ordered) + { + source.Seed(category.Id, Row($"{category.Id}-1")); + } + + var results = await engine.RunCategories(ordered); + + using (Assert.EnterMultipleScope()) + { + // KnownEndpoints then EndpointSettings, the order the registry declares, whichever order + // the caller passed them in. + Assert.That(results.Select(c => c.CategoryId), Is.EqualTo(new[] { "KnownEndpoints", "EndpointSettings" })); + Assert.That(results.Select(c => c.State), Is.All.EqualTo(MigrationCategoryState.Complete)); + } + } + + [Test] + public async Task One_category_halting_does_not_stop_the_ones_after_it() + { + // Everything that can still be copied is copied, and the guard decides what an incomplete migration + // may do. Stopping at the first halt would leave later categories untouched with no reason recorded. + var source = new InMemoryMigrationSource(); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 1, FailOnCallNumber = 1 }; + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), + new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []), NullLogger.Instance); + var first = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var second = MigrationCategoryRegistry.Find("MessageRedirects")!; + source.Seed(first.Id, Row("k-1")); + source.Seed(second.Id, Row("r-1")); + + var results = await engine.RunCategories([first, second]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0].State, Is.EqualTo(MigrationCategoryState.Halted)); + Assert.That(results[1].State, Is.EqualTo(MigrationCategoryState.Complete)); + } + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs new file mode 100644 index 0000000000..1319ae20b3 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs @@ -0,0 +1,96 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; +using ServiceControl.UnitTests.Migration.Fakes; + +[TestFixture] +class MigrationEngineSkipReasonTests +{ + static MigrationRow Row(string id) => new(id, new object(), new Dictionary()); + + [Test] + public async Task Reasons_the_target_reports_add_up_across_batches_on_the_checkpoint() + { + var category = MigrationCategoryRegistry.Find(MigrationCategoryIds.KnownEndpoints)!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b"), Row("c"), Row("d")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 3 }; + target.RejectKey("a", "KeyTooLong"); + target.RejectKey("b", "Unparseable"); + target.RejectKey("d", "KeyTooLong"); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.SkippedCount, Is.EqualTo(3)); + Assert.That(checkpoint.SkipReasons, Is.EquivalentTo(new Dictionary { ["KeyTooLong"] = 2, ["Unparseable"] = 1 })); + Assert.That((await checkpointStore.Read(category.Id))!.SkipReasons, Is.EquivalentTo(new Dictionary { ["KeyTooLong"] = 2, ["Unparseable"] = 1 })); + } + } + + [Test] + public async Task A_message_whose_body_is_never_read_is_counted_under_BodyUnreadable() + { + var category = MigrationCategoryRegistry.Find(MigrationCategoryIds.UnresolvedAndRetryIssuedFailedMessages)!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("msg-1"), Row("msg-2")); + for (var attempt = 0; attempt < MigrationEngine.MaxBodyReadAttempts; attempt++) + { + source.QueueBodyAttempt("msg-1", () => throw new InvalidOperationException("down")); + } + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = TimeSpan.Zero }; + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + Assert.That(checkpoint.SkipReasons, Is.EquivalentTo(new Dictionary { [nameof(MigrationSkipReason.BodyUnreadable)] = 1 })); + } + + [Test] + public async Task A_target_whose_skip_reasons_do_not_add_up_to_its_skips_halts_the_category() + { + var category = MigrationCategoryRegistry.Find(MigrationCategoryIds.KnownEndpoints)!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new UnexplainedSkipTarget(checkpointStore); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Halted)); + Assert.That(checkpoint.LastError, Does.Contain("reported 1 skipped").And.Contain("reasons for 0")); + Assert.That(checkpoint.Cursor, Is.EqualTo("a"), "the target committed the batch, so a restart must carry on after it"); + } + } + + sealed class UnexplainedSkipTarget(IMigrationCheckpointStore checkpointStore) : IMigrationTarget + { + public int BatchSizeFor(MigrationCategory category) => 10; + + public async Task Write(MigrationCategory category, MigrationBatch batch, MigrationCheckpoint checkpointAfterBatch, CancellationToken cancellationToken = default) + { + await checkpointStore.Upsert(checkpointAfterBatch, cancellationToken); + return new MigrationWriteResult(0, batch.Rows.Count, []); + } + + public Task Count(MigrationCategory category, CancellationToken cancellationToken = default) => Task.FromResult(0L); + } +} diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineThrottleTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineThrottleTests.cs new file mode 100644 index 0000000000..44bda6a340 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineThrottleTests.cs @@ -0,0 +1,62 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Time.Testing; +using NUnit.Framework; +using ServiceControl.Persistence.DataMigration; +using ServiceControl.UnitTests.Migration.Fakes; + +[TestFixture] +class MigrationEngineThrottleTests +{ + static MigrationRow Row(string id) => new(id, new object(), new Dictionary()); + + [Test] + public async Task Optional_categories_pause_between_batches_for_the_configured_duration() + { + var category = MigrationCategoryRegistry.Find("EventLog")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b"), Row("c")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 1 }; + var timeProvider = new FakeTimeProvider(); + var pause = TimeSpan.FromSeconds(1); + var options = new MigrationEngineOptions(pause, 5, 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, timeProvider, options, NullLogger.Instance); + + var runTask = engine.RunCategoryAsync(category); + await Task.Delay(50); + Assert.That(runTask.IsCompleted, Is.False, "the first inter-batch pause should still be pending"); + + timeProvider.Advance(pause); + await Task.Delay(50); + Assert.That(runTask.IsCompleted, Is.False, "the second inter-batch pause should still be pending"); + + timeProvider.Advance(pause); + var checkpoint = await runTask.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.That(checkpoint.CopiedCount, Is.EqualTo(3)); + } + + [Test] + public async Task Required_categories_never_pause() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b"), Row("c")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 1 }; + // A FakeTimeProvider that is never advanced: if the engine tried to pause, this would hang + // until the test runner's own timeout, which WaitAsync turns into a clear failure instead. + var options = new MigrationEngineOptions(TimeSpan.FromSeconds(1), 5, 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category).WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.That(checkpoint.CopiedCount, Is.EqualTo(3)); + } +} diff --git a/src/ServiceControl/Hosting/Commands/MigrationSourceReportCommand.cs b/src/ServiceControl/Hosting/Commands/MigrationSourceReportCommand.cs index ee2743bb8b..ce0ac0501b 100644 --- a/src/ServiceControl/Hosting/Commands/MigrationSourceReportCommand.cs +++ b/src/ServiceControl/Hosting/Commands/MigrationSourceReportCommand.cs @@ -1,13 +1,12 @@ namespace ServiceControl.Hosting.Commands { using System; - using System.Collections.Generic; + using System.Linq; using System.Threading; using System.Threading.Tasks; using Particular.ServiceControl.Hosting; using ServiceBus.Management.Infrastructure.Settings; using ServiceControl.Persistence; - using ServiceControl.Persistence.DataMigration; class MigrationSourceReportCommand : AbstractCommand { @@ -19,37 +18,24 @@ public override async Task Execute(HostArguments args, Settings settings, Cancel Console.Out.WriteLine("ServiceControl migration source report"); Console.Out.WriteLine(); - Console.Out.WriteLine($"Source persistence : {settings.MigrationSourcePersistenceType} ({(description.Embedded ? "embedded" : "external")})"); - Console.Out.WriteLine($"Server URL : {description.ServerUrl}"); - Console.Out.WriteLine($"Server version : {description.ServerVersion}"); - Console.Out.WriteLine($"Primary database : {description.PrimaryDatabase} (from ServiceControl/RavenDB/DatabaseName)"); - Console.Out.WriteLine($"Throughput database : {description.ThroughputDatabase} (from LicensingComponent/RavenDB/ThroughputDatabaseName)"); + Console.Out.WriteLine($"{"Source persistence",-20}: {settings.MigrationSourcePersistenceType}"); + Console.Out.WriteLine($"{"Version",-20}: {description.Version}"); - await PrintCollections(source, MigrationSourceDatabase.Primary, description.PrimaryDatabase, cancellationToken); - await PrintCollections(source, MigrationSourceDatabase.Throughput, description.ThroughputDatabase, cancellationToken); - } - - static async Task PrintCollections(IMigrationSource source, MigrationSourceDatabase database, string databaseName, CancellationToken cancellationToken) - { - var counted = new SortedDictionary(StringComparer.Ordinal); - - foreach (var collection in await source.CountCollections(database, cancellationToken)) + foreach (var fact in description.Facts) { - counted[collection.Key] = collection.Value; + var origin = fact.SettingKey is null ? string.Empty : $" (from {fact.SettingKey})"; + Console.Out.WriteLine($"{fact.Label,-20}: {fact.Value}{origin}"); } - Console.Out.WriteLine(); - Console.Out.WriteLine($"Collections in {databaseName}:"); - - if (counted.Count == 0) + foreach (var scope in (await source.Inventory(cancellationToken)).GroupBy(entry => entry.Scope)) { - Console.Out.WriteLine(" (none)"); - return; - } + Console.Out.WriteLine(); + Console.Out.WriteLine($"{scope.Key}:"); - foreach (var collection in counted) - { - Console.Out.WriteLine($" {collection.Key,-42}{collection.Value,12:N0}"); + foreach (var entry in scope.OrderBy(entry => entry.Name, StringComparer.Ordinal)) + { + Console.Out.WriteLine($" {entry.Name,-42}{entry.Count,12:N0}"); + } } } } diff --git a/src/ServiceControl/Hosting/Help.txt b/src/ServiceControl/Hosting/Help.txt index 8bceaf1eb6..3c3d0d5774 100644 --- a/src/ServiceControl/Hosting/Help.txt +++ b/src/ServiceControl/Hosting/Help.txt @@ -27,11 +27,12 @@ MIGRATION SOURCE REPORT ServiceControl.exe --migration-source-report -Reports what a RavenDB to SQL migration would read: whether the old database is embedded or on its -own server, which server, both database names with the setting each came from, and a row count for -every collection. +Reports what a migration would read from the source persistence named by +ServiceControl/Migration/SourcePersistenceType: the facts the source reports about itself, with the +setting each came from, and a row count for everything it holds. The source reads the instance's own +RavenDB settings, so keep them in place when switching PersistenceType. -An EXTERNAL RavenDB source can be reported on while ServiceControl is running. An EMBEDDED source +For a RavenDB source: an EXTERNAL server can be reported on while ServiceControl is running. An EMBEDDED source cannot: ServiceControl starts its own RavenDB process against that data directory, and a second one cannot attach to it. Stop the ServiceControl service first, run the report, and start it again. An instance that does not ship the RavenDB server, such as the container image, cannot report on an diff --git a/src/ServiceControl/Hosting/HostArguments.cs b/src/ServiceControl/Hosting/HostArguments.cs index c8c0b694e0..0bab996629 100644 --- a/src/ServiceControl/Hosting/HostArguments.cs +++ b/src/ServiceControl/Hosting/HostArguments.cs @@ -66,7 +66,7 @@ public HostArguments(string[] args) { { "migration-source-report", - "Report what a migration would read from the old RavenDB database, without changing it", + "Report what a migration would read from the source persistence, without changing it", s => Command = typeof(MigrationSourceReportCommand) } }; diff --git a/src/ServiceControl/Infrastructure/Settings/Settings.cs b/src/ServiceControl/Infrastructure/Settings/Settings.cs index 5c9c6de8ad..9b39e03426 100644 --- a/src/ServiceControl/Infrastructure/Settings/Settings.cs +++ b/src/ServiceControl/Infrastructure/Settings/Settings.cs @@ -16,6 +16,7 @@ using ServiceControl.Infrastructure.Settings; using ServiceControl.Infrastructure.WebApi; using ServiceControl.Persistence; + using ServiceControl.Persistence.DataMigration; using ServiceControl.Transports; using ServicePulse; using JsonSerializer = System.Text.Json.JsonSerializer; @@ -185,7 +186,7 @@ public string InstanceId public string TransportType { get; set; } public string PersistenceType { get; private set; } - public string MigrationSourcePersistenceType => SettingsReader.Read(SettingsRootNamespace, "Migration/SourcePersistenceType", "RavenDB"); + public string MigrationSourcePersistenceType => SettingsReader.Read(SettingsRootNamespace, MigrationSettings.SourcePersistenceTypeKey, MigrationSettings.DefaultSourcePersistenceType); public string ErrorLogQueue { get; set; } public string ErrorQueue { get; set; } diff --git a/src/ServiceControl/Persistence/PersistenceFactory.cs b/src/ServiceControl/Persistence/PersistenceFactory.cs index 9d4f255e7f..5aac4cdda8 100644 --- a/src/ServiceControl/Persistence/PersistenceFactory.cs +++ b/src/ServiceControl/Persistence/PersistenceFactory.cs @@ -35,12 +35,10 @@ public static IMigrationSource CreateMigrationSource(Settings settings) if (persistenceConfiguration is not IMigrationSourceFactory sourceFactory) { - throw new Exception($"The '{persistenceType}' persistence cannot be read as a migration source. Set ServiceControl/Migration/SourcePersistenceType to the persistence that holds the data being migrated away from."); + throw new Exception($"The '{persistenceType}' persistence cannot be read as a migration source. Set {Settings.SettingsRootNamespace}/{MigrationSettings.SourcePersistenceTypeKey} to the persistence that holds the data being migrated away from."); } - // Not PersisterSpecificSettings: it is null here, and a host that has populated it put the - // target's connection string in it. - return sourceFactory.CreateSource(persistenceConfiguration.CreateSettings(Settings.SettingsRootNamespace)); + return sourceFactory.CreateSource(Settings.SettingsRootNamespace); } public static async Task OpenMigrationSource(Settings settings, CancellationToken cancellationToken = default) From 259cb82830cbe461e9d89f1f83c08e3f12132803 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Tue, 15 Sep 2026 14:36:34 +0800 Subject: [PATCH 07/15] Clarify migration overview by detailing additional skip conditions for SQL migration from RavenDB --- docs/migration/ravendb-to-sql-migration-overview.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/migration/ravendb-to-sql-migration-overview.md b/docs/migration/ravendb-to-sql-migration-overview.md index 277a53c783..d9fa6454f2 100644 --- a/docs/migration/ravendb-to-sql-migration-overview.md +++ b/docs/migration/ravendb-to-sql-migration-overview.md @@ -187,6 +187,10 @@ flowchart TB - A failed message with no processing attempts recorded against it. The SQL model keeps the newest attempt and derives the failure time, the failing endpoint and the exception from it, all of which are required columns, so a message with nothing to derive them from cannot be written at all rather than being written blank. - A failed message whose body cannot be read after three attempts. **The whole message is skipped, not just its body**, because a message with no body is worse than no message. - A subscription whose message type or transport address exceeds 200 characters. The target key columns are capped at 200 characters, so it cannot be stored at all. +- An archived or resolved failed message, or an event log item, already past its retention period. SQL's retention clean-up would delete it on its first pass, so it is counted rather than copied only to be deleted. +- A group comment whose failure group has no failed messages in SQL once the messages are copied. SQL's clean-up removes such a comment, where RavenDB never expired one. +- Endpoint settings for an endpoint ServiceControl does not know. ServiceControl removes those settings shortly after it starts. +- A row missing a value SQL requires, such as a known endpoint with no name or host, or a failed message with no failing endpoint address. An empty group comment is left behind the same way, because ServiceControl never stores one. **Things that change shape, and are not counted as skips at all.** The dry run counts these before anything moves, so they are a number you see in advance rather than a discovery afterwards. They are also the ones to read twice: @@ -240,6 +244,7 @@ That window closes the moment ServiceControl opens. From then on new failed mess - Deciding whether a row is past the target's retention cutoff needs two retention periods: the source's reverses `@expires` back into the status-change instant, and the target's current one decides whether that instant is past the cutoff. - A bad row does not stop the copy. Its category finishes in a separate complete-with-errors state. - The halt threshold is proportional with an absolute floor, and a category halts only when both are exceeded. Proportional alone halts a three-row category on one bad row; absolute alone lets ten thousand failures pass on a five-million-row table as "only 0.2%". +- Rows left behind because SQL would remove them anyway (past retention, orphaned group comments, settings for unknown endpoints) are counted and reported, but never halt a category. - Verification therefore cannot treat any count difference as a fault. It accounts for every skip rule, or it reports every successful migration as broken. ## Dry run From 818b0e7de25f698bb88e14abeb4f46d7e3b5240f Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Tue, 15 Sep 2026 16:13:15 +0800 Subject: [PATCH 08/15] PR review fixes --- .github/workflows/ci.yml | 4 +- .../ravendb-to-sql-migration-instructions.md | 4 +- .../ravendb-to-sql-migration-overview.md | 6 +- .../ServiceControl.Migration.Tests.csproj | 6 +- .../TwoPersistersInOneProcessTests.cs | 15 +--- .../RavenPersistenceConfiguration.cs | 2 +- .../ReadOnlySourceLifecycleTests.cs | 23 +---- .../DataMigration/HaltThreshold.cs | 3 +- .../DataMigration/MigrationEngine.cs | 75 ++++++++-------- .../DataMigration/MigrationEngineOptions.cs | 6 +- .../Migration/Fakes/CapturingLogger.cs | 19 ++++ .../Fakes/InMemoryMigrationSource.cs | 19 ++-- .../Fakes/InMemoryMigrationSourceTests.cs | 12 ++- .../Fakes/InMemoryMigrationTarget.cs | 4 +- .../Migration/MigrationContractShapeTests.cs | 38 +------- .../MigrationEngineBodyRetryTests.cs | 64 +++++++++---- .../MigrationEngineCategorySelectionTests.cs | 8 -- .../MigrationEngineFailurePathTests.cs | 89 +++++++++++++++++-- .../Migration/MigrationEngineHaltTests.cs | 25 +++++- .../Migration/MigrationEngineOptionsTests.cs | 2 - .../Migration/MigrationEngineOrderingTests.cs | 53 +++-------- .../Migration/MigrationEngineResumeTests.cs | 74 +++++++++++++++ .../MigrationEngineRunCategoriesTests.cs | 11 ++- .../MigrationEngineSkipReasonTests.cs | 5 +- .../Migration/MigrationEngineThrottleTests.cs | 52 +++++++++-- src/ServiceControl/Hosting/HostArguments.cs | 2 +- 26 files changed, 384 insertions(+), 237 deletions(-) create mode 100644 src/ServiceControl.UnitTests/Migration/Fakes/CapturingLogger.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e5d6fda64..c0419fceb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,14 +128,14 @@ jobs: # purpose: with the VM and the build competing for the runner's memory, jobs have died with "The # hosted runner lost communication with the server". - name: Setup WSL - if: contains(fromJSON('["RabbitMQ", "SqlServer", "PostgreSql", "IBMMQ", "Migration"]'), matrix.test-category) + if: contains(fromJSON('["RabbitMQ", "SqlServer", "PostgreSql", "IBMMQ"]'), matrix.test-category) uses: Particular/setup-wsl-action@v1.2.0 with: # The action defaults to 4GB. The runner has 16GB, so give the VM real headroom. memory: 8GB - name: Setup SQL Server uses: Particular/install-sql-server-action@v3.0.0 - if: contains(fromJSON('["SqlServer", "Migration"]'), matrix.test-category) + if: matrix.test-category == 'SqlServer' with: connection-string-env-var: ServiceControl_Persistence_SqlServer_ConnectionString catalog: ServiceControl diff --git a/docs/migration/ravendb-to-sql-migration-instructions.md b/docs/migration/ravendb-to-sql-migration-instructions.md index af849a9e43..7b3fc8c7ae 100644 --- a/docs/migration/ravendb-to-sql-migration-instructions.md +++ b/docs/migration/ravendb-to-sql-migration-instructions.md @@ -3,7 +3,7 @@ This page covers what you can run today. How the migration works, and what is planned, is in the [migration overview](ravendb-to-sql-migration-overview.md) and the [system design diagram](migration-system-design-diagram.png). > [!NOTE] -> Copying data is not built yet. The one migration command available is the source report, which reads the RavenDB database and changes nothing. +> Copying data is not built yet. The one migration command available is the source report. It sends RavenDB only reads, but loading a database lets RavenDB's own expiration, its automatic deletion of documents past their retention date, run against it. If you are keeping the RavenDB database as a fallback, back it up before you run the report, as [Goals](ravendb-to-sql-migration-overview.md#goals) explains. ## Before you start @@ -38,7 +38,7 @@ From source, build `src/ServiceControl` and run the same command from its output The report prints the RavenDB server version, whether the source is embedded or external and where it is, both database names with the setting each came from, and a row count for every collection in both databases. -- **External server:** run it while ServiceControl is running. It only reads. +- **External server:** run it while ServiceControl is running. It sends only reads, and the note above about expiration applies to a server you are keeping as a fallback. - **Embedded database:** stop the ServiceControl service, run the report, then start the service again. The report starts its own RavenDB process against the data directory, which cannot happen while the instance holds it. - **Container with an embedded database:** not supported, because the container image does not ship the RavenDB server. Point the instance at an external RavenDB server instead. diff --git a/docs/migration/ravendb-to-sql-migration-overview.md b/docs/migration/ravendb-to-sql-migration-overview.md index d9fa6454f2..3f04217729 100644 --- a/docs/migration/ravendb-to-sql-migration-overview.md +++ b/docs/migration/ravendb-to-sql-migration-overview.md @@ -4,6 +4,8 @@ A customer can already point ServiceControl at SQL Server or PostgreSQL. They cannot bring their existing data with them. +This covers the error instance only. The audit instance has no SQL persister, so a customer who finishes this migration still runs RavenDB for audit. + ## Strategy - Switch over first, and copy only what has to be copied. Retention does most of the work: error retention is between 5 and 45 days and event retention is shorter, so most of the source ages out on its own within weeks. That is why archived and resolved messages are optional rather than required. Retention would have deleted them anyway. @@ -243,7 +245,7 @@ That window closes the moment ServiceControl opens. From then on new failed mess - A body is read up to three times before the message is skipped whole, and the exhausted attempts count toward the halt threshold. - Deciding whether a row is past the target's retention cutoff needs two retention periods: the source's reverses `@expires` back into the status-change instant, and the target's current one decides whether that instant is past the cutoff. - A bad row does not stop the copy. Its category finishes in a separate complete-with-errors state. -- The halt threshold is proportional with an absolute floor, and a category halts only when both are exceeded. Proportional alone halts a three-row category on one bad row; absolute alone lets ten thousand failures pass on a five-million-row table as "only 0.2%". +- The halt threshold is proportional with an absolute floor, and a category halts only when both are exceeded. Proportional alone halts a three-row category on one bad row; absolute alone halts a five-million-row table on its 101st failure at the default floor of 100. Together, a large category keeps going through losses under the percentage and finishes complete with errors, so ten thousand skipped rows out of five million do not halt it. - Rows left behind because SQL would remove them anyway (past retention, orphaned group comments, settings for unknown endpoints) are counted and reported, but never halt a category. - Verification therefore cannot treat any count difference as a fault. It accounts for every skip rule, or it reports every successful migration as broken. @@ -274,7 +276,7 @@ It reports no duration for the optional categories, and nothing about load on th `--migration-source-report`, `--migration-verify` and `--migration-dry-run` all open the RavenDB source. **On an embedded source that means stopping the ServiceControl service first**, because a second RavenDB process cannot attach to a data directory the first one holds. Plan the dry run as part of the outage rather than as something you run the day before while the instance keeps serving traffic. On an external source, a container or RavenDB Cloud, all three run against a live instance with no interruption. -One deployment shape they cannot help at all: all three need shell access to the host, so a containerised instance has no easy way to run them, and a containerised instance cannot use an embedded source either. +A containerised instance runs all three as a one-off `docker run` of the same image with the command's flag, against an external RavenDB server, as the [instructions](ravendb-to-sql-migration-instructions.md#report-on-the-source) show for the source report. It cannot use an embedded source, because the image does not ship the RavenDB server. `--migration-status` is the exception and is deliberately so: it reads only the checkpoint table in SQL and never opens the source, so it works on every source shape at any time, including during the background copy. It is the command to use for watching progress. diff --git a/src/ServiceControl.Migration.Tests/ServiceControl.Migration.Tests.csproj b/src/ServiceControl.Migration.Tests/ServiceControl.Migration.Tests.csproj index 8b9f287883..da4cda3938 100644 --- a/src/ServiceControl.Migration.Tests/ServiceControl.Migration.Tests.csproj +++ b/src/ServiceControl.Migration.Tests/ServiceControl.Migration.Tests.csproj @@ -5,10 +5,8 @@ Migration - + diff --git a/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs b/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs index 61958afec2..ba677f8b42 100644 --- a/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs +++ b/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs @@ -11,7 +11,6 @@ namespace ServiceControl.Migration.Tests; using ServiceBus.Management.Infrastructure.Settings; using ServiceControl.Infrastructure; using ServiceControl.Persistence; -using ServiceControl.Persistence.DataMigration; [TestFixture] [NonParallelizable] @@ -28,8 +27,7 @@ public void SetUp() // Both persistence configurations read ErrorRetentionPeriod through the settings reader rather // than off the Settings object, so the constructor argument below does not satisfy either of them. SetVariable("SERVICECONTROL_ERRORRETENTIONPERIOD", "10.00:00:00"); - // CI's install-sql-server-action publishes only this variable and creates the catalog - // ServiceControl; a developer machine has neither, so the local default is the fallback. + // Registering the SQL Server persistence never connects, so any connection string satisfies it. SetVariable("SERVICECONTROL_DATABASE_CONNECTIONSTRING", Environment.GetEnvironmentVariable("ServiceControl_Persistence_SqlServer_ConnectionString") ?? "Server=localhost;Database=ServiceControl;Trusted_Connection=True;TrustServerCertificate=True"); @@ -76,21 +74,14 @@ public async Task A_source_and_a_target_load_side_by_side_and_share_one_type_ide "The target persister must be isolated too, otherwise only one half of the pairing is being tested."); Assert.That(sourceContext, Is.Not.SameAs(targetContext), "Source and target must land in separate contexts. That separation is the whole feature, and nothing else here asserts it."); - Assert.That(source, Is.InstanceOf(), - "A plugin-context type must still cast to the host's copy of the interface."); Assert.That(settings.PersisterSpecificSettings, Is.SameAs(targetSettings), "Opening a source must leave the target's settings object exactly where it was."); }); var description = await source.Describe(); - Assert.Multiple(() => - { - Assert.That(AssemblyLoadContext.GetLoadContext(description.GetType().Assembly), Is.SameAs(AssemblyLoadContext.Default), - "A shared type produced inside the plugin context must arrive as the host's own type."); - Assert.That(description.Facts.Single(fact => fact.Label == "Primary database").Value, Is.EqualTo(MigrationSourceServer.PrimaryDatabase), - "The source read its own RavenDB settings rather than the target's."); - }); + Assert.That(description.Facts.Single(fact => fact.Label == "Primary database").Value, Is.EqualTo(MigrationSourceServer.PrimaryDatabase), + "The source read its own RavenDB settings rather than the target's."); } [Test] diff --git a/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs b/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs index c3e0bd7f40..c0a2dd3cea 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs @@ -1,4 +1,4 @@ -namespace ServiceControl.Persistence.RavenDB +namespace ServiceControl.Persistence.RavenDB { using System; using System.IO; diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/ReadOnlySourceLifecycleTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/ReadOnlySourceLifecycleTests.cs index 85b8fbd919..859e02c505 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/ReadOnlySourceLifecycleTests.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/ReadOnlySourceLifecycleTests.cs @@ -229,23 +229,6 @@ public async Task Opening_the_source_twice_is_refused() Assert.That(exception.Message, Does.Contain("already open"), "A second Open would abandon the first store, and on the embedded path a running server process with it."); } - [Test] - public async Task An_embedded_open_that_cannot_start_leaves_nothing_behind() - { - sourceSettings.ConnectionString = null; - sourceSettings.DatabasePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n")); - - var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); - - Assert.CatchAsync(async () => await lifecycle.Open()); - - var afterFailure = Assert.Throws(() => _ = lifecycle.DocumentStore); - - Assert.That(afterFailure.Message, Does.Contain("is not open"), "A failed embedded open must leave no store, or the caller cannot tell an unopened source from a half-open one."); - - await lifecycle.DisposeAsync(); - } - [Test] public async Task Generating_an_id_is_refused() { @@ -257,7 +240,7 @@ public async Task Generating_an_id_is_refused() Assert.CatchAsync(async () => await session.StoreAsync(new FailedMessage { UniqueMessageId = "hilo", Status = FailedMessageStatus.Unresolved })); - Assert.That(await CountDocuments(), Is.EqualTo(1), "HiLo writes an id range to the source before SaveChanges is reached, so a guard that only sees SaveChanges lets it through."); + Assert.That(await CountDocuments(), Is.EqualTo(1), "HiLo reserves an id range with a request of its own before SaveChanges, so only the request hook can refuse it."); } [Test] @@ -270,7 +253,7 @@ public async Task A_patch_against_the_source_is_refused() await lifecycle.DocumentStore.Operations.ForDatabase(databaseName).SendAsync( new PatchOperation("FailedMessages/abc", null, new PatchRequest { Script = "this.Status = 1;" }))); - Assert.That(await LoadStatus("FailedMessages/abc"), Is.EqualTo(FailedMessageStatus.Archived), "A patch never goes through a session, so it bypasses OnBeforeStore entirely. This is the write style the RavenDB persister itself uses."); + Assert.That(await LoadStatus("FailedMessages/abc"), Is.EqualTo(FailedMessageStatus.Archived), "A patch is a request of its own with no session, so only the request hook can refuse it; the RavenDB persister writes this way."); } [Test] @@ -314,7 +297,7 @@ public async Task Deleting_an_untracked_document_is_refused() Assert.CatchAsync(async () => await session.SaveChangesAsync()); - Assert.That(await CountDocuments(), Is.EqualTo(1), "Delete by id on an untracked document is deferred, so it never raises OnBeforeDelete."); + Assert.That(await CountDocuments(), Is.EqualTo(1), "A delete by id of an untracked document only goes out at SaveChanges, as a batch request the hook must refuse."); } [Test] diff --git a/src/ServiceControl.Persistence/DataMigration/HaltThreshold.cs b/src/ServiceControl.Persistence/DataMigration/HaltThreshold.cs index 3c098fffdc..92d6a73ce7 100644 --- a/src/ServiceControl.Persistence/DataMigration/HaltThreshold.cs +++ b/src/ServiceControl.Persistence/DataMigration/HaltThreshold.cs @@ -2,8 +2,7 @@ namespace ServiceControl.Persistence.DataMigration; public static class HaltThreshold { - // Both must be exceeded: the floor protects a tiny category from one bad row, and the - // proportion protects a huge one from grinding through thousands of failures as "only a fraction". + // Both must be exceeded: the floor ignores a few bad rows in a small category, the percentage a small share of a large one. public static bool Exceeded(long skippedCount, long totalCount, int percentThreshold, int minimumFloor) { if (skippedCount <= minimumFloor || totalCount == 0) diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs b/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs index c9ce5d9fc7..fb9c08c696 100644 --- a/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs +++ b/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs @@ -55,14 +55,15 @@ public async Task RunCategoryAsync(MigrationCategory catego var predecessor = await checkpointStore.Read(mustFollowId, cancellationToken); if (predecessor is not { State: MigrationCategoryState.Complete or MigrationCategoryState.CompleteWithErrors or MigrationCategoryState.Abandoned }) { + var predecessorState = predecessor?.State.ToString() ?? "not started"; var blocked = checkpoint with { Selected = true, - LastError = $"Blocked: {category.Id} must follow {mustFollowId}, which is {predecessor?.State.ToString() ?? "not started"}" + LastError = $"Blocked: {category.Id} must follow {mustFollowId}, which is {predecessorState}" }; await checkpointStore.Upsert(blocked, cancellationToken); logger.LogWarning("Category {CategoryId} did not run: it must follow {PredecessorId}, which is {PredecessorState}", - category.Id, mustFollowId, predecessor?.State.ToString() ?? "not started"); + category.Id, mustFollowId, predecessorState); return blocked; } } @@ -74,12 +75,12 @@ public async Task RunCategoryAsync(MigrationCategory catego Selected = true, State = MigrationCategoryState.InProgress, StartedAt = checkpoint.StartedAt ?? timeProvider.GetUtcNow().UtcDateTime, + CompletedAt = null, LastError = null }; await checkpointStore.Upsert(checkpoint, cancellationToken); } - var batchSize = target.BatchSizeFor(category); var isFirstBatch = true; // Per run, not the persisted totals: the skips that tripped a halt stay on the row, so // counting them again would re-halt a restart whose cause has been fixed. @@ -88,6 +89,7 @@ public async Task RunCategoryAsync(MigrationCategory catego try { + var batchSize = target.BatchSizeFor(category); await foreach (var batch in source.Read(category, checkpoint.Cursor, batchSize, cancellationToken).WithCancellation(cancellationToken)) { if (!isFirstBatch && category.Kind == MigrationCategoryKind.Optional) @@ -102,13 +104,13 @@ public async Task RunCategoryAsync(MigrationCategory catego var bodySkips = 0; if (category.CarriesBodies) { - var (withBodies, failedIds) = await FetchBodiesWithRetry(category, batch, cancellationToken); + var (withBodies, failed) = await FetchBodiesWithRetry(category, batch, cancellationToken); batchToWrite = withBodies; - bodySkips = failedIds.Count; + bodySkips = failed.Count; - foreach (var id in failedIds) + foreach (var (id, lastAttemptError) in failed) { - logger.LogWarning("Skipped {SourceId} in category {CategoryId}: body unreadable after {MaxAttempts} attempts", id, category.Id, MaxBodyReadAttempts); + logger.LogWarning(lastAttemptError, "Skipped {SourceId} in category {CategoryId}: body unreadable after {MaxAttempts} attempts", id, category.Id, MaxBodyReadAttempts); } } @@ -128,7 +130,7 @@ public async Task RunCategoryAsync(MigrationCategory catego { CopiedCount = checkpoint.CopiedCount + result.Copied, SkippedCount = checkpointAfterBatch.SkippedCount + result.Skipped, - AlreadyPresentCount = checkpoint.AlreadyPresentCount + result.AlreadyPresent, + AlreadyPresentCount = checkpointAfterBatch.AlreadyPresentCount + result.AlreadyPresent, SkipReasons = AddSkipReasons(checkpointAfterBatch.SkipReasons, result.SkipReasons) }; @@ -148,50 +150,41 @@ public async Task RunCategoryAsync(MigrationCategory catego if (HaltThreshold.Exceeded(skippedThisRun, processedThisRun, options.HaltThresholdPercent, options.HaltThresholdMinimum)) { - checkpoint = checkpoint with - { - State = MigrationCategoryState.Halted, - CompletedAt = timeProvider.GetUtcNow().UtcDateTime, - LastError = $"Halted: {skippedThisRun} of {processedThisRun} rows skipped in this run exceeds the configured threshold of {options.HaltThresholdPercent}% and {options.HaltThresholdMinimum} rows. Fix the cause and restart to resume from the cursor, or abandon the category to accept the loss." - }; - await checkpointStore.Upsert(checkpoint, cancellationToken); - logger.LogError("Category {CategoryId} halted at cursor {Cursor}: {LastError}", category.Id, checkpoint.Cursor, checkpoint.LastError); - return checkpoint; + var reason = $"Halted: {skippedThisRun} of {processedThisRun} rows skipped in this run exceeds the configured threshold of {options.HaltThresholdPercent}% and {options.HaltThresholdMinimum} rows. Fix the cause and restart to resume from the cursor, or abandon the category to accept the loss."; + logger.LogError("Category {CategoryId} halted at cursor {Cursor}: {LastError}", category.Id, checkpoint.Cursor, reason); + return await Settle(checkpoint with { State = MigrationCategoryState.Halted, LastError = reason }, cancellationToken); } } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // The host is stopping. The category stays InProgress and the next start resumes from - // the cursor the last committed batch left behind. + // The target stored the last committed batch with every row counted as copied, so save its real + // split. The category stays InProgress and the next start resumes from the cursor. + await checkpointStore.Upsert(checkpoint, CancellationToken.None); throw; } catch (Exception ex) { - checkpoint = checkpoint with - { - State = MigrationCategoryState.Halted, - CompletedAt = timeProvider.GetUtcNow().UtcDateTime, - LastError = $"{ex.GetType().Name} at cursor {checkpoint.Cursor ?? "the start"}: {ex.Message}" - }; - await checkpointStore.Upsert(checkpoint, cancellationToken); + var reason = $"{ex.GetType().Name} at cursor {checkpoint.Cursor ?? "the start"}: {ex.Message}"; logger.LogError(ex, "Category {CategoryId} halted at cursor {Cursor}", category.Id, checkpoint.Cursor); - return checkpoint; + return await Settle(checkpoint with { State = MigrationCategoryState.Halted, LastError = reason }, cancellationToken); } - checkpoint = checkpoint with - { - State = checkpoint.SkippedCount > 0 ? MigrationCategoryState.CompleteWithErrors : MigrationCategoryState.Complete, - CompletedAt = timeProvider.GetUtcNow().UtcDateTime - }; - await checkpointStore.Upsert(checkpoint, cancellationToken); - return checkpoint; + return await Settle(checkpoint with { State = checkpoint.SkippedCount > 0 ? MigrationCategoryState.CompleteWithErrors : MigrationCategoryState.Complete }, cancellationToken); } - async Task<(MigrationBatch Batch, IReadOnlyList FailedIds)> FetchBodiesWithRetry(MigrationCategory category, MigrationBatch batch, CancellationToken cancellationToken) + // Halts log before settling: the store shares the target's database, so a failed save would hide the cause. + async Task Settle(MigrationCheckpoint settled, CancellationToken cancellationToken) + { + settled = settled with { CompletedAt = timeProvider.GetUtcNow().UtcDateTime }; + await checkpointStore.Upsert(settled, cancellationToken); + return settled; + } + + async Task<(MigrationBatch Batch, IReadOnlyList<(string SourceId, Exception LastAttemptError)> Failed)> FetchBodiesWithRetry(MigrationCategory category, MigrationBatch batch, CancellationToken cancellationToken) { var survivors = new List(batch.Rows.Count); - var failed = new List(); + var failed = new List<(string SourceId, Exception LastAttemptError)>(); foreach (var row in batch.Rows) { @@ -202,6 +195,7 @@ public async Task RunCategoryAsync(MigrationCategory catego } MigrationBody? body = null; + Exception? lastAttemptError = null; var succeeded = false; for (var attempt = 1; attempt <= MaxBodyReadAttempts && !succeeded; attempt++) @@ -217,8 +211,9 @@ public async Task RunCategoryAsync(MigrationCategory catego // recording the message as permanently unreadable would lose a row to a restart. throw; } - catch (Exception ex) + catch (Exception ex) when (!IsDefect(ex)) { + lastAttemptError = ex; logger.LogWarning(ex, "Attempt {Attempt} to read the body for {SourceId} failed", attempt, row.SourceId); if (attempt < MaxBodyReadAttempts) { @@ -233,13 +228,17 @@ public async Task RunCategoryAsync(MigrationCategory catego } else { - failed.Add(row.SourceId); + failed.Add((row.SourceId, lastAttemptError!)); } } return (batch with { Rows = survivors }, failed); } + // These fail the same way on every attempt, so retrying would only turn a code defect into skipped messages. + static bool IsDefect(Exception exception) => + exception is NotSupportedException or NotImplementedException or InvalidOperationException or ArgumentException or NullReferenceException or InvalidCastException; + static IReadOnlyDictionary? AddSkipReasons(IReadOnlyDictionary? totals, IReadOnlyDictionary? additions) { if (additions is not { Count: > 0 }) diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationEngineOptions.cs b/src/ServiceControl.Persistence/DataMigration/MigrationEngineOptions.cs index 0b5a6e3ab3..8433836893 100644 --- a/src/ServiceControl.Persistence/DataMigration/MigrationEngineOptions.cs +++ b/src/ServiceControl.Persistence/DataMigration/MigrationEngineOptions.cs @@ -5,11 +5,7 @@ namespace ServiceControl.Persistence.DataMigration; using System.Linq; using ServiceControl.Configuration; -/// The engine's tuning, read from settings once at startup. -/// How long to wait between batches of optional data, so normal work isn't slowed down. Zero means don't wait. -/// A category halts when more than this percent of the rows handled in this run were skipped, and the minimum below is also passed. -/// A category never halts until more than this many rows were skipped in this run, so a few bad rows can't stop a small category. -/// The optional categories to copy. Required categories are always copied. +/// The engine's tuning: the pause between optional batches, when a category halts, and which optional categories to copy. public sealed record MigrationEngineOptions( TimeSpan ThrottlePause, int HaltThresholdPercent, diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/CapturingLogger.cs b/src/ServiceControl.UnitTests/Migration/Fakes/CapturingLogger.cs new file mode 100644 index 0000000000..24aee91e82 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/Fakes/CapturingLogger.cs @@ -0,0 +1,19 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration.Fakes; + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using ServiceControl.Persistence.DataMigration; + +public sealed class CapturingLogger : ILogger +{ + public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + Entries.Add((logLevel, formatter(state, exception), exception)); +} diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSource.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSource.cs index ad411cc65b..bf5d522856 100644 --- a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSource.cs +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSource.cs @@ -12,7 +12,8 @@ namespace ServiceControl.UnitTests.Migration.Fakes; public sealed class InMemoryMigrationSource : IMigrationSource { readonly Dictionary> rowsByCategory = []; - readonly Dictionary>> bodyAttempts = []; + readonly Dictionary bodyFailures = []; + readonly Dictionary bodyReadAttempts = []; readonly Dictionary bodies = []; public MigrationSourceDescription Description { get; set; } = new("in-memory", [new MigrationSourceFact("Store", "in memory")]); @@ -21,14 +22,9 @@ public sealed class InMemoryMigrationSource : IMigrationSource public void SetBody(string sourceId, MigrationBody? body) => bodies[sourceId] = body; - public void QueueBodyAttempt(string sourceId, Func attempt) - { - if (!bodyAttempts.TryGetValue(sourceId, out var queue)) - { - bodyAttempts[sourceId] = queue = new Queue>(); - } - queue.Enqueue(attempt); - } + public void FailBodyReads(string sourceId, int times, Exception failure) => bodyFailures[sourceId] = (times, failure); + + public int BodyReadAttempts(string sourceId) => bodyReadAttempts.GetValueOrDefault(sourceId); public Task Open(CancellationToken cancellationToken = default) => Task.CompletedTask; @@ -73,9 +69,10 @@ public async IAsyncEnumerable Read( { await Task.Yield(); - if (bodyAttempts.TryGetValue(sourceId, out var queue) && queue.Count > 0) + var attempt = bodyReadAttempts[sourceId] = BodyReadAttempts(sourceId) + 1; + if (bodyFailures.TryGetValue(sourceId, out var failures) && attempt <= failures.Times) { - return queue.Dequeue()(); + throw failures.Failure; } return bodies.GetValueOrDefault(sourceId); diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSourceTests.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSourceTests.cs index 6d7b2cda2b..23492909d4 100644 --- a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSourceTests.cs +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSourceTests.cs @@ -63,16 +63,20 @@ public void A_cursor_the_source_never_issued_throws_rather_than_starting_again() } [Test] - public async Task ReadBody_returns_a_queued_attempt_then_falls_back_to_the_seeded_body() + public async Task ReadBody_fails_the_configured_number_of_times_then_returns_the_seeded_body() { var source = new InMemoryMigrationSource(); var finalBody = new MigrationBody(new byte[] { 9 }, "text/plain"); source.SetBody("msg-1", finalBody); - source.QueueBodyAttempt("msg-1", () => throw new System.Exception("transient")); + source.FailBodyReads("msg-1", times: 1, new TimeoutException("transient")); - Assert.ThrowsAsync(() => source.ReadBody(MigrationCategoryRegistry.Find(MigrationCategoryIds.UnresolvedAndRetryIssuedFailedMessages)!, "msg-1")); + Assert.ThrowsAsync(() => source.ReadBody(MigrationCategoryRegistry.Find(MigrationCategoryIds.UnresolvedAndRetryIssuedFailedMessages)!, "msg-1")); var secondAttempt = await source.ReadBody(MigrationCategoryRegistry.Find(MigrationCategoryIds.UnresolvedAndRetryIssuedFailedMessages)!, "msg-1"); - Assert.That(secondAttempt, Is.EqualTo(finalBody)); + using (Assert.EnterMultipleScope()) + { + Assert.That(secondAttempt, Is.EqualTo(finalBody)); + Assert.That(source.BodyReadAttempts("msg-1"), Is.EqualTo(2)); + } } } diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs index 68bbe3139f..436996e84e 100644 --- a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs @@ -14,6 +14,7 @@ public sealed class InMemoryMigrationTarget(IMigrationCheckpointStore checkpoint readonly Dictionary rejectedKeys = []; public int DefaultBatchSize { get; set; } = 3; + public string NoBatchSizeFor { get; set; } public int? FailOnCallNumber { get; set; } public (int CallNumber, CancellationTokenSource Source)? StopOnCall { get; set; } int callCount; @@ -25,7 +26,8 @@ public sealed class InMemoryMigrationTarget(IMigrationCheckpointStore checkpoint public IReadOnlyList WrittenRows(string categoryId) => writtenRowsByCategory.TryGetValue(categoryId, out var rows) ? rows : []; - public int BatchSizeFor(MigrationCategory category) => DefaultBatchSize; + public int BatchSizeFor(MigrationCategory category) => + category.Id == NoBatchSizeFor ? throw new InvalidOperationException($"No batch size is mapped for category {category.Id}") : DefaultBatchSize; public async Task Write( MigrationCategory category, diff --git a/src/ServiceControl.UnitTests/Migration/MigrationContractShapeTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationContractShapeTests.cs index 2c662bffbe..b6a21b7f85 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationContractShapeTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationContractShapeTests.cs @@ -18,17 +18,6 @@ public void MigrationCategory_carries_no_fact_about_where_a_store_keeps_its_rows "a source database or target table belongs in that store's own adapter, where a different store pair can map it differently"); } - [Test] - public void Abandoned_is_a_state_of_its_own_and_not_a_kind_of_complete() - { - using (Assert.EnterMultipleScope()) - { - Assert.That(MigrationCategoryState.Abandoned, Is.Not.EqualTo(MigrationCategoryState.Complete)); - Assert.That(MigrationCategoryState.Abandoned, Is.Not.EqualTo(MigrationCategoryState.CompleteWithErrors)); - Assert.That(MigrationCategoryState.Abandoned, Is.Not.EqualTo(MigrationCategoryState.Halted)); - } - } - [Test] public void Category_states_keep_the_integers_stored_checkpoints_already_hold() { @@ -54,25 +43,7 @@ public void MigrationRow_body_defaults_to_null() } [Test] - public void MigrationRow_can_carry_a_body() - { - var body = new MigrationBody(new byte[] { 1, 2, 3 }, "text/plain"); - var row = new MigrationRow("id-1", new object(), new Dictionary(), body); - - Assert.That(row.Body, Is.EqualTo(body)); - } - - [Test] - public void MigrationBatch_groups_rows_under_one_cursor() - { - var rows = new[] { new MigrationRow("id-1", new object(), new Dictionary()) }; - var batch = new MigrationBatch(rows, Cursor: "id-1"); - - Assert.That(batch.Cursor, Is.EqualTo("id-1")); - } - - [Test] - public void MigrationCheckpoint_starts_with_no_cursor_and_zero_counts() + public void MigrationCheckpoint_AlreadyPresentCount_defaults_to_zero() { var checkpoint = new MigrationCheckpoint( CategoryId: "EndpointSettings", @@ -89,11 +60,6 @@ public void MigrationCheckpoint_starts_with_no_cursor_and_zero_counts() AbandonedAt: null, LastError: null); - using (Assert.EnterMultipleScope()) - { - Assert.That(checkpoint.Cursor, Is.Null); - Assert.That(checkpoint.CopiedCount, Is.Zero); - Assert.That(checkpoint.AlreadyPresentCount, Is.Zero); - } + Assert.That(checkpoint.AlreadyPresentCount, Is.Zero); } } diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineBodyRetryTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineBodyRetryTests.cs index a2680372f8..cfac8aa11a 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineBodyRetryTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineBodyRetryTests.cs @@ -24,10 +24,7 @@ public async Task A_body_that_fails_until_the_last_attempt_then_succeeds_is_writ source.Seed(category.Id, Row("msg-1")); var body = new MigrationBody(new byte[] { 1 }, "text/plain"); source.SetBody("msg-1", body); - for (var attempt = 1; attempt < MigrationEngine.MaxBodyReadAttempts; attempt++) - { - source.QueueBodyAttempt("msg-1", () => throw new InvalidOperationException("blip")); - } + source.FailBodyReads("msg-1", MigrationEngine.MaxBodyReadAttempts - 1, new TimeoutException("blip")); var checkpointStore = new InMemoryMigrationCheckpointStore(); var target = new InMemoryMigrationTarget(checkpointStore); // Zeroed because FakeTimeProvider is never advanced here: a 200 ms Task.Delay against a clock @@ -42,9 +39,53 @@ public async Task A_body_that_fails_until_the_last_attempt_then_succeeds_is_writ Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); Assert.That(checkpoint.SkippedCount, Is.Zero); Assert.That(target.WrittenRows(category.Id).Single().Body, Is.EqualTo(body)); + Assert.That(source.BodyReadAttempts("msg-1"), Is.EqualTo(MigrationEngine.MaxBodyReadAttempts)); + } + } + + [Test] + public async Task A_body_read_that_fails_as_a_defect_halts_the_category_on_the_first_attempt_without_skipping_the_message() + { + var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("msg-1")); + source.FailBodyReads("msg-1", MigrationEngine.MaxBodyReadAttempts, new NotSupportedException("this source cannot read bodies")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = TimeSpan.Zero }; + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Halted)); + Assert.That(checkpoint.LastError, Does.Contain(nameof(NotSupportedException))); + Assert.That(source.BodyReadAttempts("msg-1"), Is.EqualTo(1)); + Assert.That(checkpoint.SkippedCount, Is.Zero); + Assert.That(checkpoint.SkipReasons, Is.Null); } } + [Test] + public async Task The_skip_warning_for_an_unreadable_body_carries_the_last_attempts_exception() + { + var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("msg-1")); + var failure = new TimeoutException("body store unreachable"); + source.FailBodyReads("msg-1", MigrationEngine.MaxBodyReadAttempts, failure); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = TimeSpan.Zero }; + var logger = new CapturingLogger(); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, logger); + + await engine.RunCategoryAsync(category); + + Assert.That(logger.Entries.Single(e => e.Message.StartsWith("Skipped msg-1")).Exception, Is.SameAs(failure)); + } + [Test] public async Task A_body_the_source_already_attached_is_written_without_reading_it_again() { @@ -52,10 +93,7 @@ public async Task A_body_the_source_already_attached_is_written_without_reading_ var source = new InMemoryMigrationSource(); var attached = new MigrationBody(new byte[] { 7 }, "text/plain"); source.Seed(category.Id, Row("msg-1") with { Body = attached }); - for (var attempt = 0; attempt < MigrationEngine.MaxBodyReadAttempts; attempt++) - { - source.QueueBodyAttempt("msg-1", () => throw new InvalidOperationException("a body read nobody needed")); - } + source.FailBodyReads("msg-1", MigrationEngine.MaxBodyReadAttempts, new TimeoutException("a body read nobody needed")); var checkpointStore = new InMemoryMigrationCheckpointStore(); var target = new InMemoryMigrationTarget(checkpointStore); var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = TimeSpan.Zero }; @@ -76,10 +114,7 @@ public async Task Exhausted_attempts_skip_the_whole_message_and_count_toward_the var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; var source = new InMemoryMigrationSource(); source.Seed(category.Id, Row("msg-1"), Row("msg-2")); - for (var attempt = 0; attempt < MigrationEngine.MaxBodyReadAttempts; attempt++) - { - source.QueueBodyAttempt("msg-1", () => throw new InvalidOperationException("down")); - } + source.FailBodyReads("msg-1", MigrationEngine.MaxBodyReadAttempts, new TimeoutException("down")); source.SetBody("msg-2", new MigrationBody(new byte[] { 2 }, "text/plain")); var checkpointStore = new InMemoryMigrationCheckpointStore(); var target = new InMemoryMigrationTarget(checkpointStore); @@ -107,10 +142,7 @@ public async Task A_body_store_outage_across_many_messages_halts_the_category() // Every body read fails every attempt: a down body store, not a per-message fluke. foreach (var id in ids) { - for (var attempt = 0; attempt < MigrationEngine.MaxBodyReadAttempts; attempt++) - { - source.QueueBodyAttempt(id, () => throw new InvalidOperationException("body store unreachable")); - } + source.FailBodyReads(id, MigrationEngine.MaxBodyReadAttempts, new TimeoutException("body store unreachable")); } var checkpointStore = new InMemoryMigrationCheckpointStore(); var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 100 }; diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs index 82d806a947..d48c44dc35 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs @@ -53,12 +53,4 @@ public void A_category_removed_from_configuration_leaves_its_checkpoint_row_unto Assert.That(checkpointStore.Read("EventLog").GetAwaiter().GetResult(), Is.EqualTo(previousRun)); } } - - [Test] - public void A_category_added_to_configuration_is_selected_on_the_next_run() - { - var engine = BuildEngine(["CustomChecks"], out _); - - Assert.That(engine.SelectCategories(MigrationCategoryKind.Optional).Select(c => c.Id), Is.EqualTo(new[] { "CustomChecks" })); - } } diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs index 4c42966148..ba354ffeca 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs @@ -6,6 +6,7 @@ namespace ServiceControl.UnitTests.Migration; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using NUnit.Framework; @@ -90,12 +91,21 @@ public async Task A_halted_category_is_re_attempted_on_the_next_run_and_resumes_ var halted = await firstRun.RunCategoryAsync(category); Assert.That(halted.State, Is.EqualTo(MigrationCategoryState.Halted)); + // Stopped on its first write, so the saved row is the restarted one rather than the completed one. target.FailOnCallNumber = null; - var secondRun = BuildEngine(source, checkpointStore, target); - var finished = await secondRun.RunCategoryAsync(category); + using var stopping = new CancellationTokenSource(); + target.StopOnCall = (3, stopping); + Assert.ThrowsAsync(() => BuildEngine(source, checkpointStore, target).RunCategoryAsync(category, stopping.Token)); + var restarted = await checkpointStore.Read(category.Id); + + target.StopOnCall = null; + var lastRun = BuildEngine(source, checkpointStore, target); + var finished = await lastRun.RunCategoryAsync(category); using (Assert.EnterMultipleScope()) { + Assert.That(restarted!.State, Is.EqualTo(MigrationCategoryState.InProgress)); + Assert.That(restarted.CompletedAt, Is.Null, "a copy running again does not keep the finish time its halt recorded"); Assert.That(finished.State, Is.EqualTo(MigrationCategoryState.Complete)); Assert.That(finished.LastError, Is.Null, "a cleared halt does not leave a stale error on the row"); Assert.That(finished.CopiedCount, Is.EqualTo(4)); @@ -112,10 +122,7 @@ public async Task Body_skips_in_a_batch_whose_write_fails_are_counted_once_acros var source = new InMemoryMigrationSource(); source.Seed(category.Id, Row("msg-1"), Row("msg-2")); // msg-1's body is unreadable on both runs: every attempt fails on each. - for (var attempt = 0; attempt < 2 * MigrationEngine.MaxBodyReadAttempts; attempt++) - { - source.QueueBodyAttempt("msg-1", () => throw new InvalidOperationException("body store unreachable")); - } + source.FailBodyReads("msg-1", 2 * MigrationEngine.MaxBodyReadAttempts, new TimeoutException("body store unreachable")); source.SetBody("msg-2", new MigrationBody(new byte[] { 2 }, "text/plain")); var checkpointStore = new InMemoryMigrationCheckpointStore(); var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2, FailOnCallNumber = 1 }; @@ -160,4 +167,74 @@ public async Task An_abandoned_category_is_left_exactly_as_it_is() Assert.That(target.WrittenRows(category.Id), Is.Empty); } } + + [Test] + public async Task A_target_with_no_batch_size_for_a_category_halts_it_and_the_next_category_still_runs() + { + var source = new InMemoryMigrationSource(); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var unmapped = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var next = MigrationCategoryRegistry.Find("MessageRedirects")!; + source.Seed(unmapped.Id, Row("k-1")); + source.Seed(next.Id, Row("r-1")); + var target = new InMemoryMigrationTarget(checkpointStore) { NoBatchSizeFor = unmapped.Id }; + var engine = BuildEngine(source, checkpointStore, target); + + var results = await engine.RunCategories([unmapped, next]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results[0].State, Is.EqualTo(MigrationCategoryState.Halted)); + Assert.That(results[0].LastError, Does.Contain("No batch size")); + Assert.That(results[1].State, Is.EqualTo(MigrationCategoryState.Complete)); + } + } + + [Test] + public void A_halt_whose_save_fails_still_logs_the_exception_that_caused_it() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a")); + var checkpointStore = new HaltSaveFailsCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { FailOnCallNumber = 1 }; + var logger = new CapturingLogger(); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []), logger); + + Assert.ThrowsAsync(() => engine.RunCategoryAsync(category)); + + Assert.That(logger.Entries.Where(e => e.Level == LogLevel.Error).Select(e => e.Exception?.Message), Does.Contain("Simulated failure on write 1")); + } + + [Test] + public void A_threshold_halt_whose_save_fails_still_logs_why_it_halted() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a")); + var checkpointStore = new HaltSaveFailsCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + target.RejectKey("a", "Rejected"); + var logger = new CapturingLogger(); + // A floor of zero lets the one rejected row halt the category. + var options = new MigrationEngineOptions(TimeSpan.Zero, HaltThresholdPercent: 5, HaltThresholdMinimum: 0, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, logger); + + Assert.ThrowsAsync(() => engine.RunCategoryAsync(category)); + + Assert.That(logger.Entries.Where(e => e.Level == LogLevel.Error).Select(e => e.Message), Has.Some.Contains("Halted: 1 of 1 rows skipped")); + } + + // The store shares the target's database, which has become unreachable by the time the halt is saved. + sealed class HaltSaveFailsCheckpointStore : IMigrationCheckpointStore + { + readonly InMemoryMigrationCheckpointStore saved = new(); + + public Task> ReadAll(CancellationToken cancellationToken = default) => saved.ReadAll(cancellationToken); + + public Task Read(string categoryId, CancellationToken cancellationToken = default) => saved.Read(categoryId, cancellationToken); + + public Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default) => + checkpoint.State == MigrationCategoryState.Halted ? throw new TimeoutException("checkpoint store unreachable") : saved.Upsert(checkpoint, cancellationToken); + } } diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs index fb114a7409..21e0b59be4 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs @@ -72,13 +72,30 @@ public async Task Rows_already_present_in_the_target_never_count_toward_the_halt } [Test] - public void Halted_is_distinct_from_Complete_and_CompleteWithErrors() + public async Task A_restart_after_a_threshold_halt_counts_only_its_own_skips_and_keeps_the_earlier_ones() { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, [.. Enumerable.Range(1, 1_000).Select(i => Row($"row-{i}"))]); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var failingTarget = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 100 }; + foreach (var i in Enumerable.Range(1, 1_000).Where(i => i % 5 == 0)) + { + failingTarget.RejectKey($"row-{i}", "Rejected"); + } + var options = new MigrationEngineOptions(TimeSpan.Zero, HaltThresholdPercent: 5, HaltThresholdMinimum: 100, []); + var halted = await new MigrationEngine(source, failingTarget, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance).RunCategoryAsync(category); + + // The cause is fixed: the remaining rows now write cleanly. + var fixedTarget = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 100 }; + var finished = await new MigrationEngine(source, fixedTarget, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance).RunCategoryAsync(category); + using (Assert.EnterMultipleScope()) { - Assert.That(MigrationCategoryState.Halted, Is.Not.EqualTo(MigrationCategoryState.Complete)); - Assert.That(MigrationCategoryState.Halted, Is.Not.EqualTo(MigrationCategoryState.CompleteWithErrors)); - Assert.That(MigrationCategoryState.CompleteWithErrors, Is.Not.EqualTo(MigrationCategoryState.Complete)); + // 120 skips in 600 rows is the first point past both the floor and 5%. + Assert.That((halted.State, halted.CopiedCount, halted.SkippedCount), Is.EqualTo((MigrationCategoryState.Halted, 480L, 120L))); + Assert.That(finished.State, Is.EqualTo(MigrationCategoryState.CompleteWithErrors), "the skips still on the row must not halt a run that skips nothing"); + Assert.That((finished.CopiedCount, finished.SkippedCount), Is.EqualTo((880L, 120L)), "copied, skipped at the end"); } } } diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineOptionsTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineOptionsTests.cs index eb296bfd71..0ede2f09b3 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineOptionsTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineOptionsTests.cs @@ -27,8 +27,6 @@ public void Defaults_match_the_contract_when_nothing_is_configured() using (Assert.EnterMultipleScope()) { - // At one second a million-row copy in 50-row batches spends five and a half hours paused - // before it does any work at all. Assert.That(options.ThrottlePause, Is.EqualTo(TimeSpan.FromMilliseconds(100))); Assert.That(options.HaltThresholdPercent, Is.EqualTo(5)); Assert.That(options.HaltThresholdMinimum, Is.EqualTo(100)); diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs index abf41f8ac9..141864afc8 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs @@ -19,7 +19,7 @@ static MigrationEngine BuildEngine(InMemoryMigrationSource source, InMemoryMigra new(source, target, checkpointStore, new FakeTimeProvider(), new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []), NullLogger.Instance); [Test] - public async Task LicensingThroughput_does_not_start_before_LicensingEndpoints_completes() + public async Task LicensingThroughput_does_not_start_before_LicensingEndpoints_completes_and_says_so_on_its_checkpoint_row() { var throughputCategory = MigrationCategoryRegistry.Find("LicensingThroughput")!; var source = new InMemoryMigrationSource(); @@ -31,28 +31,11 @@ public async Task LicensingThroughput_does_not_start_before_LicensingEndpoints_c // LicensingEndpoints has never run: no checkpoint row for it at all. var checkpoint = await engine.RunCategoryAsync(throughputCategory); + var persisted = await checkpointStore.Read(throughputCategory.Id); using (Assert.EnterMultipleScope()) { Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.NotStarted)); Assert.That(target.WrittenRows(throughputCategory.Id), Is.Empty); - } - } - - [Test] - public async Task A_blocked_category_says_so_on_its_checkpoint_row_instead_of_returning_in_silence() - { - var throughputCategory = MigrationCategoryRegistry.Find("LicensingThroughput")!; - var source = new InMemoryMigrationSource(); - source.Seed(throughputCategory.Id, Row("t-1")); - var checkpointStore = new InMemoryMigrationCheckpointStore(); - var target = new InMemoryMigrationTarget(checkpointStore); - var engine = BuildEngine(source, checkpointStore, target); - - await engine.RunCategoryAsync(throughputCategory); - - var persisted = await checkpointStore.Read(throughputCategory.Id); - using (Assert.EnterMultipleScope()) - { // A row exists, so status can print it. Without one, an operator cannot tell a category // waiting on another from a category nobody asked for. Assert.That(persisted, Is.Not.Null); @@ -83,14 +66,15 @@ public async Task EndpointSettings_does_not_start_before_KnownEndpoints_complete } } - [Test] - public async Task GroupComments_does_not_start_before_the_archive_completes() + [TestCase(MigrationCategoryState.InProgress)] + [TestCase(MigrationCategoryState.Halted)] + public async Task GroupComments_does_not_start_before_the_archive_completes(MigrationCategoryState archiveState) { var comments = MigrationCategoryRegistry.Find("GroupComments")!; var source = new InMemoryMigrationSource(); source.Seed(comments.Id, Row("GroupComment/g-1")); var checkpointStore = new InMemoryMigrationCheckpointStore(); - await checkpointStore.Upsert(new MigrationCheckpoint("ArchivedAndResolvedFailedMessages", true, MigrationCategoryState.InProgress, "m-500", 500, 0, null, null, DateTime.UtcNow, DateTime.UtcNow, null, null, null)); + await checkpointStore.Upsert(new MigrationCheckpoint("ArchivedAndResolvedFailedMessages", true, archiveState, "m-500", 500, 0, null, null, DateTime.UtcNow, DateTime.UtcNow, null, null, null)); var target = new InMemoryMigrationTarget(checkpointStore); var engine = BuildEngine(source, checkpointStore, target); @@ -99,34 +83,21 @@ public async Task GroupComments_does_not_start_before_the_archive_completes() using (Assert.EnterMultipleScope()) { Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.NotStarted)); + Assert.That(checkpoint.LastError, Is.EqualTo($"Blocked: GroupComments must follow ArchivedAndResolvedFailedMessages, which is {archiveState}")); Assert.That(target.WrittenRows(comments.Id), Is.Empty); } } - [Test] - public async Task LicensingThroughput_proceeds_once_LicensingEndpoints_is_Complete() - { - var throughputCategory = MigrationCategoryRegistry.Find("LicensingThroughput")!; - var source = new InMemoryMigrationSource(); - source.Seed(throughputCategory.Id, Row("t-1")); - var checkpointStore = new InMemoryMigrationCheckpointStore(); - await checkpointStore.Upsert(new MigrationCheckpoint("LicensingEndpoints", true, MigrationCategoryState.Complete, "e-1", 1, 0, 1, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null, null)); - var target = new InMemoryMigrationTarget(checkpointStore); - var engine = BuildEngine(source, checkpointStore, target); - - var checkpoint = await engine.RunCategoryAsync(throughputCategory); - - Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); - } - - [Test] - public async Task LicensingThroughput_proceeds_when_LicensingEndpoints_is_CompleteWithErrors() + [TestCase(MigrationCategoryState.Complete)] + [TestCase(MigrationCategoryState.CompleteWithErrors)] + [TestCase(MigrationCategoryState.Abandoned)] + public async Task LicensingThroughput_proceeds_once_LicensingEndpoints_is_finished_or_abandoned(MigrationCategoryState endpointsState) { var throughputCategory = MigrationCategoryRegistry.Find("LicensingThroughput")!; var source = new InMemoryMigrationSource(); source.Seed(throughputCategory.Id, Row("t-1")); var checkpointStore = new InMemoryMigrationCheckpointStore(); - await checkpointStore.Upsert(new MigrationCheckpoint("LicensingEndpoints", true, MigrationCategoryState.CompleteWithErrors, "e-1", 9, 1, 10, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null, null)); + await checkpointStore.Upsert(new MigrationCheckpoint("LicensingEndpoints", true, endpointsState, "e-1", 1, 0, 1, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null, null)); var target = new InMemoryMigrationTarget(checkpointStore); var engine = BuildEngine(source, checkpointStore, target); diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs index e0b5fd13ed..fdc62cdd4e 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs @@ -57,4 +57,78 @@ public async Task Restarting_after_a_mid_category_stop_produces_no_duplicates_an Assert.That(writtenIds.Distinct().Count(), Is.EqualTo(writtenIds.Length), "no duplicates"); } } + + [Test] + public async Task A_graceful_stop_saves_the_real_split_of_the_last_committed_batch() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, [.. Enumerable.Range(1, 6).Select(i => Row($"row-{i}"))]); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + using var stopping = new CancellationTokenSource(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2, StopOnCall = (3, stopping) }; + // Both in the second batch, the last one to commit before the stop. + target.SeedExistingKey("row-3"); + target.RejectKey("row-4", "Rejected"); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var firstEngine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + Assert.ThrowsAsync(() => firstEngine.RunCategoryAsync(category, stopping.Token)); + var afterStop = await checkpointStore.Read(category.Id); + + target.StopOnCall = null; + var secondEngine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + var finalCheckpoint = await secondEngine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(afterStop!.State, Is.EqualTo(MigrationCategoryState.InProgress)); + Assert.That((afterStop.CopiedCount, afterStop.SkippedCount, afterStop.AlreadyPresentCount), Is.EqualTo((2L, 1L, 1L)), "copied, skipped, already present after the stop"); + Assert.That(finalCheckpoint.State, Is.EqualTo(MigrationCategoryState.CompleteWithErrors)); + Assert.That((finalCheckpoint.CopiedCount, finalCheckpoint.SkippedCount, finalCheckpoint.AlreadyPresentCount), Is.EqualTo((4L, 1L, 1L)), "copied, skipped, already present at the end"); + Assert.That(finalCheckpoint.SkipReasons, Is.EquivalentTo(new Dictionary { ["Rejected"] = 1 })); + } + } + + [Test] + public async Task A_hard_crash_after_a_committed_write_is_accepted_to_leave_that_batch_counted_as_copied_and_can_end_Complete() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, [.. Enumerable.Range(1, 4).Select(i => Row($"row-{i}"))]); + var committed = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(committed) { DefaultBatchSize = 2 }; + target.SeedExistingKey("row-3"); + target.RejectKey("row-4", "Rejected"); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var crashingEngine = new MigrationEngine(source, target, new CrashAfterCommitCheckpointStore(committed, crashAfterCursor: "row-4"), new FakeTimeProvider(), options, NullLogger.Instance); + await crashingEngine.RunCategoryAsync(category); + + var restartedEngine = new MigrationEngine(source, target, committed, new FakeTimeProvider(), options, NullLogger.Instance); + var finalCheckpoint = await restartedEngine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + // Accepted: closing this needs the target to save the real split with the rows. + Assert.That(finalCheckpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); + Assert.That((finalCheckpoint.CopiedCount, finalCheckpoint.SkippedCount, finalCheckpoint.AlreadyPresentCount), Is.EqualTo((4L, 0L, 0L)), "copied, skipped, already present at the end"); + Assert.That(target.WrittenRows(category.Id).Select(r => r.SourceId), Is.EqualTo(new[] { "row-1", "row-2" })); + } + } + + // Once the target has committed crashAfterCursor, the engine's own saves are lost, as if the process died there. + sealed class CrashAfterCommitCheckpointStore(IMigrationCheckpointStore committed, string crashAfterCursor) : IMigrationCheckpointStore + { + public Task> ReadAll(CancellationToken cancellationToken = default) => committed.ReadAll(cancellationToken); + + public Task Read(string categoryId, CancellationToken cancellationToken = default) => committed.Read(categoryId, cancellationToken); + + public async Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default) + { + if ((await committed.Read(checkpoint.CategoryId, cancellationToken))?.Cursor != crashAfterCursor) + { + await committed.Upsert(checkpoint, cancellationToken); + } + } + } } diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineRunCategoriesTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineRunCategoriesTests.cs index 8503b3c257..691f0a6f0b 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineRunCategoriesTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineRunCategoriesTests.cs @@ -24,19 +24,18 @@ public async Task Runs_every_category_it_is_given_in_the_order_it_is_given_them( var target = new InMemoryMigrationTarget(checkpointStore); var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []), NullLogger.Instance); - var ordered = engine.SelectCategories(MigrationCategoryKind.Required).Take(2).ToArray(); - foreach (var category in ordered) + // The reverse of registry order, so an engine that re-sorted by Order would run KnownEndpoints first. + MigrationCategory[] given = [MigrationCategoryRegistry.Find("MessageRedirects")!, MigrationCategoryRegistry.Find("KnownEndpoints")!]; + foreach (var category in given) { source.Seed(category.Id, Row($"{category.Id}-1")); } - var results = await engine.RunCategories(ordered); + var results = await engine.RunCategories(given); using (Assert.EnterMultipleScope()) { - // KnownEndpoints then EndpointSettings, the order the registry declares, whichever order - // the caller passed them in. - Assert.That(results.Select(c => c.CategoryId), Is.EqualTo(new[] { "KnownEndpoints", "EndpointSettings" })); + Assert.That(results.Select(c => c.CategoryId), Is.EqualTo(new[] { "MessageRedirects", "KnownEndpoints" })); Assert.That(results.Select(c => c.State), Is.All.EqualTo(MigrationCategoryState.Complete)); } } diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs index 1319ae20b3..2d2d0dda9f 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs @@ -46,10 +46,7 @@ public async Task A_message_whose_body_is_never_read_is_counted_under_BodyUnread var category = MigrationCategoryRegistry.Find(MigrationCategoryIds.UnresolvedAndRetryIssuedFailedMessages)!; var source = new InMemoryMigrationSource(); source.Seed(category.Id, Row("msg-1"), Row("msg-2")); - for (var attempt = 0; attempt < MigrationEngine.MaxBodyReadAttempts; attempt++) - { - source.QueueBodyAttempt("msg-1", () => throw new InvalidOperationException("down")); - } + source.FailBodyReads("msg-1", MigrationEngine.MaxBodyReadAttempts, new TimeoutException("down")); var checkpointStore = new InMemoryMigrationCheckpointStore(); var target = new InMemoryMigrationTarget(checkpointStore); var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = TimeSpan.Zero }; diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineThrottleTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineThrottleTests.cs index 44bda6a340..c4665da55f 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineThrottleTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineThrottleTests.cs @@ -3,6 +3,7 @@ namespace ServiceControl.UnitTests.Migration; using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; @@ -23,23 +24,28 @@ public async Task Optional_categories_pause_between_batches_for_the_configured_d source.Seed(category.Id, Row("a"), Row("b"), Row("c")); var checkpointStore = new InMemoryMigrationCheckpointStore(); var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 1 }; - var timeProvider = new FakeTimeProvider(); + var clock = new TimerRecordingTimeProvider(); var pause = TimeSpan.FromSeconds(1); var options = new MigrationEngineOptions(pause, 5, 100, []); - var engine = new MigrationEngine(source, target, checkpointStore, timeProvider, options, NullLogger.Instance); + var engine = new MigrationEngine(source, target, checkpointStore, clock, options, NullLogger.Instance); var runTask = engine.RunCategoryAsync(category); - await Task.Delay(50); - Assert.That(runTask.IsCompleted, Is.False, "the first inter-batch pause should still be pending"); - timeProvider.Advance(pause); - await Task.Delay(50); - Assert.That(runTask.IsCompleted, Is.False, "the second inter-batch pause should still be pending"); + // Three batches, so a pause before the second and another before the third. + for (var pauseNumber = 1; pauseNumber <= 2; pauseNumber++) + { + Assert.That(await clock.TimerCreated.WaitAsync(TimeSpan.FromSeconds(5)), Is.True, $"pause {pauseNumber} never started"); + Assert.That(runTask.IsCompleted, Is.False, $"pause {pauseNumber} should still be pending"); + clock.Advance(pause); + } - timeProvider.Advance(pause); var checkpoint = await runTask.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.That(checkpoint.CopiedCount, Is.EqualTo(3)); + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.CopiedCount, Is.EqualTo(3)); + Assert.That(clock.DueTimes, Is.EqualTo(new[] { pause, pause })); + } } [Test] @@ -59,4 +65,32 @@ public async Task Required_categories_never_pause() Assert.That(checkpoint.CopiedCount, Is.EqualTo(3)); } + + // Signals each timer the engine creates, so the test only advances the clock once a pause is waiting on it. + sealed class TimerRecordingTimeProvider : TimeProvider + { + readonly FakeTimeProvider clock = new(); + + public SemaphoreSlim TimerCreated { get; } = new(0); + + public List DueTimes { get; } = []; + + public void Advance(TimeSpan delta) => clock.Advance(delta); + + public override DateTimeOffset GetUtcNow() => clock.GetUtcNow(); + + public override long GetTimestamp() => clock.GetTimestamp(); + + public override long TimestampFrequency => clock.TimestampFrequency; + + public override TimeZoneInfo LocalTimeZone => clock.LocalTimeZone; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = clock.CreateTimer(callback, state, dueTime, period); + DueTimes.Add(dueTime); + TimerCreated.Release(); + return timer; + } + } } diff --git a/src/ServiceControl/Hosting/HostArguments.cs b/src/ServiceControl/Hosting/HostArguments.cs index 0bab996629..bd6400df32 100644 --- a/src/ServiceControl/Hosting/HostArguments.cs +++ b/src/ServiceControl/Hosting/HostArguments.cs @@ -66,7 +66,7 @@ public HostArguments(string[] args) { { "migration-source-report", - "Report what a migration would read from the source persistence, without changing it", + "Report what a migration would read from the source persistence", s => Command = typeof(MigrationSourceReportCommand) } }; From fa6c14eff0b6cf7740ec2360df8edcfc857afe6a Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 16 Sep 2026 20:06:30 +0800 Subject: [PATCH 09/15] PR review --- .../IMigrationCheckpointStore.cs | 54 +++++++++++-- .../DataMigration/IMigrationSource.cs | 1 + .../DataMigration/IMigrationTarget.cs | 11 +-- .../MigrationCheckpointConflictException.cs | 6 ++ .../DataMigration/MigrationEngine.cs | 77 ++++++------------- .../DataMigration/MigrationSkipReason.cs | 4 +- .../Fakes/InMemoryMigrationCheckpointStore.cs | 13 +++- .../Fakes/InMemoryMigrationTarget.cs | 18 +++-- .../Fakes/InMemoryMigrationTargetTests.cs | 29 +++---- .../Migration/MigrationContractShapeTests.cs | 7 +- .../MigrationEngineCategorySelectionTests.cs | 4 +- .../Migration/MigrationEngineCopyTests.cs | 6 +- .../MigrationEngineFailurePathTests.cs | 10 +-- .../Migration/MigrationEngineHaltTests.cs | 6 +- .../Migration/MigrationEngineOrderingTests.cs | 13 ++-- .../Migration/MigrationEngineResumeTests.cs | 25 +++--- .../MigrationEngineSkipReasonTests.cs | 21 ++--- 17 files changed, 164 insertions(+), 141 deletions(-) create mode 100644 src/ServiceControl.Persistence/DataMigration/MigrationCheckpointConflictException.cs diff --git a/src/ServiceControl.Persistence/DataMigration/IMigrationCheckpointStore.cs b/src/ServiceControl.Persistence/DataMigration/IMigrationCheckpointStore.cs index 5d20b9af13..bc71283d01 100644 --- a/src/ServiceControl.Persistence/DataMigration/IMigrationCheckpointStore.cs +++ b/src/ServiceControl.Persistence/DataMigration/IMigrationCheckpointStore.cs @@ -2,6 +2,7 @@ namespace ServiceControl.Persistence.DataMigration; using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -12,29 +13,68 @@ public enum MigrationCategoryState Complete, CompleteWithErrors, Halted, - Abandoned + Abandoned, + Blocked } /// A category's saved progress: where a restart carries on from, and what the status and verify commands report. public sealed record MigrationCheckpoint( string CategoryId, - bool Selected, MigrationCategoryState State, string? Cursor, long CopiedCount, long SkippedCount, long? SourceTotal, - IReadOnlyDictionary? SkipReasons, + IReadOnlyDictionary? SkipReasons, DateTime? StartedAt, DateTime? LastProgressAt, - DateTime? CompletedAt, - DateTime? AbandonedAt, + // The moment the category stopped running, whatever state it stopped in. Read it beside State: a halt settles too. + DateTime? SettledAt, string? LastError, - long AlreadyPresentCount = 0); + long AlreadyPresentCount = 0, + // The optimistic concurrency token. A store sets it on save and refuses one carrying a value the stored row no longer holds. + long Version = 0) +{ + /// Adds one batch's outcome to this checkpoint. A target calls it inside the transaction that writes the rows, so the saved counts are the real ones. + public MigrationCheckpoint Extend(int copied, int skipped, int alreadyPresent, IReadOnlyDictionary? skipReasons) + { + var explained = skipReasons?.Values.Sum() ?? 0; + if (explained != skipped) + { + throw new InvalidOperationException($"The target reported {skipped} skipped rows in category {CategoryId} but gave reasons for {explained}. Every skipped row needs a reason, or --migration-verify cannot account for it."); + } + + return this with + { + CopiedCount = CopiedCount + copied, + SkippedCount = SkippedCount + skipped, + AlreadyPresentCount = AlreadyPresentCount + alreadyPresent, + SkipReasons = AddSkipReasons(SkipReasons, skipReasons) + }; + } + + internal static IReadOnlyDictionary? AddSkipReasons(IReadOnlyDictionary? totals, IReadOnlyDictionary? additions) + { + if (additions is not { Count: > 0 }) + { + return totals; + } + + Dictionary sum = totals is null ? [] : new(totals); + foreach (var (reason, count) in additions) + { + sum[reason] = sum.GetValueOrDefault(reason) + count; + } + + return sum; + } +} public interface IMigrationCheckpointStore { Task> ReadAll(CancellationToken cancellationToken = default); Task Read(string categoryId, CancellationToken cancellationToken = default); - Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default); + + /// Saves the checkpoint and returns it as stored, carrying the version the save landed on. Throws when the stored row has moved on. + Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default); } diff --git a/src/ServiceControl.Persistence/DataMigration/IMigrationSource.cs b/src/ServiceControl.Persistence/DataMigration/IMigrationSource.cs index bc5bd1c3bf..bd19e623aa 100644 --- a/src/ServiceControl.Persistence/DataMigration/IMigrationSource.cs +++ b/src/ServiceControl.Persistence/DataMigration/IMigrationSource.cs @@ -20,6 +20,7 @@ public interface IMigrationSource : IAsyncDisposable Task Count(MigrationCategory category, CancellationToken cancellationToken = default); /// Reads a category in batches, after the checkpoint cursor if provided, or from the start when it is null. Throws on a cursor it never issued. + /// A ceiling, not a target: returning fewer costs nothing, returning more fails the target's write. IAsyncEnumerable Read( MigrationCategory category, string? resumeAfter, diff --git a/src/ServiceControl.Persistence/DataMigration/IMigrationTarget.cs b/src/ServiceControl.Persistence/DataMigration/IMigrationTarget.cs index 6ab337d815..2d6aeeb84a 100644 --- a/src/ServiceControl.Persistence/DataMigration/IMigrationTarget.cs +++ b/src/ServiceControl.Persistence/DataMigration/IMigrationTarget.cs @@ -7,19 +7,20 @@ namespace ServiceControl.Persistence.DataMigration; /// Implemented by a persister that can be the new database a migration copies into. public interface IMigrationTarget { - /// How many rows to read per batch for this category. The target picks it because its own database sets the limits. + /// The most rows a source may return in one batch for this category. The target picks it because its own database sets the limit, and a batch over it fails the write. int BatchSizeFor(MigrationCategory category); - /// Saves the batch's rows and checkpointAfterBatch in one go, so progress never gets ahead of the data. Save the checkpoint exactly as given, without adding counts to it. + /// Saves the batch's rows and the checkpoint in one transaction, so progress never gets ahead of the data. Extend checkpointToExtend with this batch's own outcome through and save the result, so what lands is the real split rather than a guess the next save has to correct. + /// Prior totals, the cursor this batch reached, and any rows the engine itself skipped. Not yet counting anything the target does. Task Write( MigrationCategory category, MigrationBatch batch, - MigrationCheckpoint checkpointAfterBatch, + MigrationCheckpoint checkpointToExtend, CancellationToken cancellationToken = default); /// How many rows the target holds for one category, for progress and verify. Counts only that category, even where two categories share a table. Task Count(MigrationCategory category, CancellationToken cancellationToken = default); } -/// What the target did with one batch. Every skipped row must have a reason in SkipReasons. -public sealed record MigrationWriteResult(int Copied, int Skipped, IReadOnlyList SkippedIds, int AlreadyPresent = 0, IReadOnlyDictionary? SkipReasons = null); +/// What the target did with one batch, and the checkpoint it committed alongside the rows. Every skipped row must have a reason in SkipReasons. +public sealed record MigrationWriteResult(MigrationCheckpoint Saved, int Copied, int Skipped, IReadOnlyList SkippedIds, int AlreadyPresent = 0, IReadOnlyDictionary? SkipReasons = null); diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationCheckpointConflictException.cs b/src/ServiceControl.Persistence/DataMigration/MigrationCheckpointConflictException.cs new file mode 100644 index 0000000000..cc2822822f --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/MigrationCheckpointConflictException.cs @@ -0,0 +1,6 @@ +namespace ServiceControl.Persistence.DataMigration; + +using System; + +/// Thrown when a checkpoint save carries a version the stored row no longer holds, because another writer moved it on. +public sealed class MigrationCheckpointConflictException(string message, Exception? innerException = null) : Exception(message, innerException); diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs b/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs index fb9c08c696..352097d470 100644 --- a/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs +++ b/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs @@ -58,27 +58,24 @@ public async Task RunCategoryAsync(MigrationCategory catego var predecessorState = predecessor?.State.ToString() ?? "not started"; var blocked = checkpoint with { - Selected = true, + State = MigrationCategoryState.Blocked, LastError = $"Blocked: {category.Id} must follow {mustFollowId}, which is {predecessorState}" }; - await checkpointStore.Upsert(blocked, cancellationToken); logger.LogWarning("Category {CategoryId} did not run: it must follow {PredecessorId}, which is {PredecessorState}", category.Id, mustFollowId, predecessorState); - return blocked; + return await checkpointStore.Upsert(blocked, cancellationToken); } } - if (checkpoint.State is MigrationCategoryState.NotStarted or MigrationCategoryState.Halted) + if (checkpoint.State is MigrationCategoryState.NotStarted or MigrationCategoryState.Halted or MigrationCategoryState.Blocked) { - checkpoint = checkpoint with + checkpoint = await checkpointStore.Upsert(checkpoint with { - Selected = true, State = MigrationCategoryState.InProgress, StartedAt = checkpoint.StartedAt ?? timeProvider.GetUtcNow().UtcDateTime, - CompletedAt = null, + SettledAt = null, LastError = null - }; - await checkpointStore.Upsert(checkpoint, cancellationToken); + }, cancellationToken); } var isFirstBatch = true; @@ -114,31 +111,17 @@ public async Task RunCategoryAsync(MigrationCategory catego } } - // Absolute totals counting every handed-over row as copied, persisted verbatim with the rows. - // The real split comes back in the result and lands on the next checkpoint. - var checkpointAfterBatch = checkpoint with + // Prior totals, the new cursor, and the rows this engine already skipped. The target adds its own + // outcome inside the transaction that writes the rows, so nothing provisional is ever stored. + var checkpointToExtend = checkpoint with { Cursor = batch.Cursor, - CopiedCount = checkpoint.CopiedCount + batchToWrite.Rows.Count, SkippedCount = checkpoint.SkippedCount + bodySkips, - SkipReasons = AddSkipReasons(checkpoint.SkipReasons, bodySkips == 0 ? null : new Dictionary { [nameof(MigrationSkipReason.BodyUnreadable)] = bodySkips }) + SkipReasons = MigrationCheckpoint.AddSkipReasons(checkpoint.SkipReasons, bodySkips == 0 ? null : new Dictionary { [MigrationSkipReason.BodyUnreadable] = bodySkips }) }; - var result = await target.Write(category, batchToWrite, checkpointAfterBatch, cancellationToken); - - checkpoint = checkpointAfterBatch with - { - CopiedCount = checkpoint.CopiedCount + result.Copied, - SkippedCount = checkpointAfterBatch.SkippedCount + result.Skipped, - AlreadyPresentCount = checkpointAfterBatch.AlreadyPresentCount + result.AlreadyPresent, - SkipReasons = AddSkipReasons(checkpointAfterBatch.SkipReasons, result.SkipReasons) - }; - - var explainedSkips = result.SkipReasons?.Values.Sum() ?? 0; - if (explainedSkips != result.Skipped) - { - throw new InvalidOperationException($"The target reported {result.Skipped} skipped rows in category {category.Id} but gave reasons for {explainedSkips}. Every skipped row needs a reason, or --migration-verify cannot account for it."); - } + var result = await target.Write(category, batchToWrite, checkpointToExtend, cancellationToken); + checkpoint = result.Saved; foreach (var id in result.SkippedIds) { @@ -156,11 +139,15 @@ public async Task RunCategoryAsync(MigrationCategory catego } } } + // A shutdown is not a halt, and there is nothing to reconcile: the last committed batch stored its + // real split with its own rows, so the row on disk is already correct and resumable. catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // The target stored the last committed batch with every row counted as copied, so save its real - // split. The category stays InProgress and the next start resumes from the cursor. - await checkpointStore.Upsert(checkpoint, CancellationToken.None); + throw; + } + // Another writer holds this row, which no amount of halting resolves. Leave the state alone so their row stands. + catch (MigrationCheckpointConflictException) + { throw; } catch (Exception ex) @@ -174,12 +161,8 @@ public async Task RunCategoryAsync(MigrationCategory catego } // Halts log before settling: the store shares the target's database, so a failed save would hide the cause. - async Task Settle(MigrationCheckpoint settled, CancellationToken cancellationToken) - { - settled = settled with { CompletedAt = timeProvider.GetUtcNow().UtcDateTime }; - await checkpointStore.Upsert(settled, cancellationToken); - return settled; - } + Task Settle(MigrationCheckpoint settled, CancellationToken cancellationToken) => + checkpointStore.Upsert(settled with { SettledAt = timeProvider.GetUtcNow().UtcDateTime }, cancellationToken); async Task<(MigrationBatch Batch, IReadOnlyList<(string SourceId, Exception LastAttemptError)> Failed)> FetchBodiesWithRetry(MigrationCategory category, MigrationBatch batch, CancellationToken cancellationToken) { @@ -239,27 +222,11 @@ async Task Settle(MigrationCheckpoint settled, Cancellation static bool IsDefect(Exception exception) => exception is NotSupportedException or NotImplementedException or InvalidOperationException or ArgumentException or NullReferenceException or InvalidCastException; - static IReadOnlyDictionary? AddSkipReasons(IReadOnlyDictionary? totals, IReadOnlyDictionary? additions) - { - if (additions is not { Count: > 0 }) - { - return totals; - } - - Dictionary sum = totals is null ? [] : new(totals); - foreach (var (reason, count) in additions) - { - sum[reason] = sum.GetValueOrDefault(reason) + count; - } - - return sum; - } - // A configured pause of zero means "do not throttle", and a timer that is never going to be // waited on is worse than no timer: against a fake clock nobody advances, it never completes. Task Pause(TimeSpan duration, CancellationToken cancellationToken) => duration <= TimeSpan.Zero ? Task.CompletedTask : Task.Delay(duration, timeProvider, cancellationToken); static MigrationCheckpoint NotStarted(MigrationCategory category) => - new(category.Id, Selected: false, MigrationCategoryState.NotStarted, null, 0, 0, null, null, null, null, null, null, null); + new(category.Id, MigrationCategoryState.NotStarted, null, 0, 0, null, null, null, null, null, null); } diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationSkipReason.cs b/src/ServiceControl.Persistence/DataMigration/MigrationSkipReason.cs index c4f442305f..b79be632a8 100644 --- a/src/ServiceControl.Persistence/DataMigration/MigrationSkipReason.cs +++ b/src/ServiceControl.Persistence/DataMigration/MigrationSkipReason.cs @@ -2,5 +2,7 @@ namespace ServiceControl.Persistence.DataMigration; public enum MigrationSkipReason { - BodyUnreadable + BodyUnreadable, + // Never written by a copier. A database a newer build wrote still reads rather than throwing where the host decides whether to start. + Unknown } diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationCheckpointStore.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationCheckpointStore.cs index ea6803524f..78edd726b6 100644 --- a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationCheckpointStore.cs +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationCheckpointStore.cs @@ -18,9 +18,16 @@ public Task> ReadAll(CancellationToken cancel public Task Read(string categoryId, CancellationToken cancellationToken = default) => Task.FromResult(checkpoints.TryGetValue(categoryId, out var checkpoint) ? checkpoint : null); - public Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default) + public Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default) { - checkpoints[checkpoint.CategoryId] = checkpoint; - return Task.CompletedTask; + var storedVersion = checkpoints.TryGetValue(checkpoint.CategoryId, out var stored) ? stored.Version : 0; + if (storedVersion != checkpoint.Version) + { + throw new MigrationCheckpointConflictException($"Checkpoint {checkpoint.CategoryId} was saved from version {checkpoint.Version}, but the stored row is at version {storedVersion}."); + } + + var saved = checkpoint with { Version = checkpoint.Version + 1 }; + checkpoints[checkpoint.CategoryId] = saved; + return Task.FromResult(saved); } } diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs index 436996e84e..38f005d1b1 100644 --- a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs @@ -11,7 +11,7 @@ public sealed class InMemoryMigrationTarget(IMigrationCheckpointStore checkpoint readonly Dictionary> writtenKeysByCategory = []; readonly Dictionary> writtenRowsByCategory = []; readonly HashSet preExistingKeys = []; - readonly Dictionary rejectedKeys = []; + readonly Dictionary rejectedKeys = []; public int DefaultBatchSize { get; set; } = 3; public string NoBatchSizeFor { get; set; } @@ -21,7 +21,7 @@ public sealed class InMemoryMigrationTarget(IMigrationCheckpointStore checkpoint public void SeedExistingKey(string sourceId) => preExistingKeys.Add(sourceId); - public void RejectKey(string sourceId, string reason) => rejectedKeys[sourceId] = reason; + public void RejectKey(string sourceId, MigrationSkipReason reason) => rejectedKeys[sourceId] = reason; public IReadOnlyList WrittenRows(string categoryId) => writtenRowsByCategory.TryGetValue(categoryId, out var rows) ? rows : []; @@ -32,7 +32,7 @@ public int BatchSizeFor(MigrationCategory category) => public async Task Write( MigrationCategory category, MigrationBatch batch, - MigrationCheckpoint checkpointAfterBatch, + MigrationCheckpoint checkpointToExtend, CancellationToken cancellationToken = default) { callCount++; @@ -54,7 +54,7 @@ public async Task Write( var copied = 0; var alreadyPresent = 0; var skippedIds = new List(); - var skipReasons = new Dictionary(); + var skipReasons = new Dictionary(); foreach (var row in batch.Rows) { @@ -75,11 +75,13 @@ public async Task Write( copied++; } - // Verbatim and in the same operation as the rows, as the real targets persist it. Adding the - // batch's own counts here would double every number the engine already included. - await checkpointStore.Upsert(checkpointAfterBatch, cancellationToken); + // Extended and saved in the same operation as the rows, as the real targets do, so what lands + // is this batch's real split rather than a provisional one the next save has to correct. + var saved = await checkpointStore.Upsert( + checkpointToExtend.Extend(copied, skippedIds.Count, alreadyPresent, skipReasons), + cancellationToken); - return new MigrationWriteResult(copied, skippedIds.Count, skippedIds, alreadyPresent, skipReasons); + return new MigrationWriteResult(saved, copied, skippedIds.Count, skippedIds, alreadyPresent, skipReasons); } public Task Count(MigrationCategory category, CancellationToken cancellationToken = default) => diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTargetTests.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTargetTests.cs index b10a340257..ae52e815dc 100644 --- a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTargetTests.cs +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTargetTests.cs @@ -12,53 +12,54 @@ class InMemoryMigrationTargetTests static MigrationRow Row(string id) => new(id, new object(), new Dictionary()); static MigrationCheckpoint EmptyCheckpoint(string categoryId) => - new(categoryId, Selected: true, MigrationCategoryState.InProgress, Cursor: null, 0, 0, null, null, null, null, null, null, null); + new(categoryId, MigrationCategoryState.InProgress, Cursor: null, 0, 0, null, null, null, null, null, null); [Test] - public async Task Writes_new_rows_and_persists_the_checkpoint_it_was_handed_exactly_as_given() + public async Task Writes_new_rows_and_commits_the_extended_checkpoint_with_them() { var checkpointStore = new InMemoryMigrationCheckpointStore(); var target = new InMemoryMigrationTarget(checkpointStore); var category = MigrationCategoryRegistry.Find("EndpointSettings")!; var batch = new MigrationBatch([Row("a"), Row("b")], Cursor: "b"); - // Absolute post-batch totals, computed by the caller. The target does no arithmetic on them. - var checkpointAfterBatch = EmptyCheckpoint(category.Id) with { Cursor = "b", CopiedCount = 2 }; + // Prior totals and the new cursor. The target adds this batch's own outcome before it saves. + var checkpointToExtend = EmptyCheckpoint(category.Id) with { Cursor = "b" }; - var result = await target.Write(category, batch, checkpointAfterBatch); + var result = await target.Write(category, batch, checkpointToExtend); using (Assert.EnterMultipleScope()) { Assert.That(result.Copied, Is.EqualTo(2)); Assert.That(result.Skipped, Is.Zero); Assert.That(target.WrittenRows(category.Id), Has.Count.EqualTo(2)); - Assert.That(await checkpointStore.Read(category.Id), Is.EqualTo(checkpointAfterBatch)); + Assert.That(await checkpointStore.Read(category.Id), Is.EqualTo(checkpointToExtend with { CopiedCount = 2, Version = 1 })); + Assert.That(result.Saved, Is.EqualTo(checkpointToExtend with { CopiedCount = 2, Version = 1 }), "the result carries the row as stored"); } } [Test] - public async Task The_persisted_checkpoint_is_not_adjusted_by_what_the_write_actually_did() + public async Task The_committed_checkpoint_carries_the_real_split_rather_than_the_rows_handed_over() { - // The engine's totals already count every row as copied, so a target that corrected them from - // its own result would double the counts. + // Three rows in, one copied: a checkpoint saying three would survive a crash as three. var checkpointStore = new InMemoryMigrationCheckpointStore(); var target = new InMemoryMigrationTarget(checkpointStore); var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; target.SeedExistingKey("already-present"); - target.RejectKey("rejected", "Rejected"); + target.RejectKey("rejected", MigrationSkipReason.BodyUnreadable); var batch = new MigrationBatch([Row("already-present"), Row("rejected"), Row("new-row")], Cursor: "new-row"); - var checkpointAfterBatch = EmptyCheckpoint(category.Id) with { Cursor = "new-row", CopiedCount = 3 }; + var checkpointToExtend = EmptyCheckpoint(category.Id) with { Cursor = "new-row" }; - var result = await target.Write(category, batch, checkpointAfterBatch); + var result = await target.Write(category, batch, checkpointToExtend); using (Assert.EnterMultipleScope()) { Assert.That(result.Copied, Is.EqualTo(1)); Assert.That(result.Skipped, Is.EqualTo(1)); Assert.That(result.SkippedIds, Is.EqualTo(new[] { "rejected" })); - Assert.That(result.SkipReasons, Is.EquivalentTo(new Dictionary { ["Rejected"] = 1 })); + Assert.That(result.SkipReasons, Is.EquivalentTo(new Dictionary { [MigrationSkipReason.BodyUnreadable] = 1 })); Assert.That(result.AlreadyPresent, Is.EqualTo(1)); Assert.That(target.WrittenRows(category.Id), Has.Count.EqualTo(1)); - Assert.That((await checkpointStore.Read(category.Id))!.CopiedCount, Is.EqualTo(3)); + var stored = (await checkpointStore.Read(category.Id))!; + Assert.That((stored.CopiedCount, stored.SkippedCount, stored.AlreadyPresentCount), Is.EqualTo((1L, 1L, 1L)), "copied, skipped, already present as committed"); } } diff --git a/src/ServiceControl.UnitTests/Migration/MigrationContractShapeTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationContractShapeTests.cs index b6a21b7f85..3df3d4e123 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationContractShapeTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationContractShapeTests.cs @@ -30,7 +30,8 @@ public void Category_states_keep_the_integers_stored_checkpoints_already_hold() ["Complete"] = 2, ["CompleteWithErrors"] = 3, ["Halted"] = 4, - ["Abandoned"] = 5 + ["Abandoned"] = 5, + ["Blocked"] = 6 }), "checkpoints store State as an integer, so a reordered or inserted member silently changes what every saved checkpoint means"); } @@ -47,7 +48,6 @@ public void MigrationCheckpoint_AlreadyPresentCount_defaults_to_zero() { var checkpoint = new MigrationCheckpoint( CategoryId: "EndpointSettings", - Selected: false, State: MigrationCategoryState.NotStarted, Cursor: null, CopiedCount: 0, @@ -56,8 +56,7 @@ public void MigrationCheckpoint_AlreadyPresentCount_defaults_to_zero() SkipReasons: null, StartedAt: null, LastProgressAt: null, - CompletedAt: null, - AbandonedAt: null, + SettledAt: null, LastError: null); Assert.That(checkpoint.AlreadyPresentCount, Is.Zero); diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs index d48c44dc35..b153258775 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs @@ -42,7 +42,7 @@ public void Only_configured_optional_categories_are_selected() public void A_category_removed_from_configuration_leaves_its_checkpoint_row_untouched() { var engine = BuildEngine([], out var checkpointStore); - var previousRun = new MigrationCheckpoint("EventLog", Selected: true, MigrationCategoryState.CompleteWithErrors, "cursor-99", 40, 2, 42, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null, null); + var previousRun = new MigrationCheckpoint("EventLog", MigrationCategoryState.CompleteWithErrors, "cursor-99", 40, 2, 42, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null); checkpointStore.Upsert(previousRun).GetAwaiter().GetResult(); var selected = engine.SelectCategories(MigrationCategoryKind.Optional); @@ -50,7 +50,7 @@ public void A_category_removed_from_configuration_leaves_its_checkpoint_row_unto using (Assert.EnterMultipleScope()) { Assert.That(selected.Select(c => c.Id), Does.Not.Contain("EventLog")); - Assert.That(checkpointStore.Read("EventLog").GetAwaiter().GetResult(), Is.EqualTo(previousRun)); + Assert.That(checkpointStore.Read("EventLog").GetAwaiter().GetResult(), Is.EqualTo(previousRun with { Version = 1 })); } } } diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineCopyTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineCopyTests.cs index c48c726e82..c1a663d1f9 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineCopyTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineCopyTests.cs @@ -35,7 +35,7 @@ public async Task Copies_every_row_and_finishes_Complete_when_nothing_was_skippe Assert.That(checkpoint.SkippedCount, Is.Zero); Assert.That(checkpoint.Cursor, Is.EqualTo("c")); Assert.That(checkpoint.StartedAt, Is.Not.Null); - Assert.That(checkpoint.CompletedAt, Is.Not.Null); + Assert.That(checkpoint.SettledAt, Is.Not.Null); Assert.That(target.WrittenRows(category.Id), Has.Count.EqualTo(3)); } } @@ -47,7 +47,7 @@ public async Task A_category_already_Complete_is_left_alone_on_a_second_run() var source = new InMemoryMigrationSource(); source.Seed(category.Id, Row("a")); var checkpointStore = new InMemoryMigrationCheckpointStore(); - var alreadyDone = new MigrationCheckpoint(category.Id, true, MigrationCategoryState.Complete, "a", 1, 0, 1, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null, null); + var alreadyDone = new MigrationCheckpoint(category.Id, MigrationCategoryState.Complete, "a", 1, 0, 1, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null); await checkpointStore.Upsert(alreadyDone); var target = new InMemoryMigrationTarget(checkpointStore); var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); @@ -57,7 +57,7 @@ public async Task A_category_already_Complete_is_left_alone_on_a_second_run() using (Assert.EnterMultipleScope()) { - Assert.That(checkpoint, Is.EqualTo(alreadyDone)); + Assert.That(checkpoint, Is.EqualTo(alreadyDone with { Version = 1 }), "the row is read back untouched, at the version the seeding save left it"); Assert.That(target.WrittenRows(category.Id), Is.Empty); } } diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs index ba354ffeca..18d109dd44 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs @@ -105,7 +105,7 @@ public async Task A_halted_category_is_re_attempted_on_the_next_run_and_resumes_ using (Assert.EnterMultipleScope()) { Assert.That(restarted!.State, Is.EqualTo(MigrationCategoryState.InProgress)); - Assert.That(restarted.CompletedAt, Is.Null, "a copy running again does not keep the finish time its halt recorded"); + Assert.That(restarted.SettledAt, Is.Null, "a copy running again does not keep the time its halt settled at"); Assert.That(finished.State, Is.EqualTo(MigrationCategoryState.Complete)); Assert.That(finished.LastError, Is.Null, "a cleared halt does not leave a stale error on the row"); Assert.That(finished.CopiedCount, Is.EqualTo(4)); @@ -154,7 +154,7 @@ public async Task An_abandoned_category_is_left_exactly_as_it_is() var source = new InMemoryMigrationSource(); source.Seed(category.Id, Row("a")); var checkpointStore = new InMemoryMigrationCheckpointStore(); - var abandoned = new MigrationCheckpoint(category.Id, true, MigrationCategoryState.Abandoned, "a", 1, 3, 4, null, DateTime.UtcNow, DateTime.UtcNow, null, DateTime.UtcNow, "Halted: the body store was unreachable"); + var abandoned = new MigrationCheckpoint(category.Id, MigrationCategoryState.Abandoned, "a", 1, 3, 4, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, "Halted: the body store was unreachable"); await checkpointStore.Upsert(abandoned); var target = new InMemoryMigrationTarget(checkpointStore); var engine = BuildEngine(source, checkpointStore, target); @@ -163,7 +163,7 @@ public async Task An_abandoned_category_is_left_exactly_as_it_is() using (Assert.EnterMultipleScope()) { - Assert.That(checkpoint, Is.EqualTo(abandoned)); + Assert.That(checkpoint, Is.EqualTo(abandoned with { Version = 1 }), "the row is read back untouched, at the version the seeding save left it"); Assert.That(target.WrittenRows(category.Id), Is.Empty); } } @@ -214,7 +214,7 @@ public void A_threshold_halt_whose_save_fails_still_logs_why_it_halted() source.Seed(category.Id, Row("a")); var checkpointStore = new HaltSaveFailsCheckpointStore(); var target = new InMemoryMigrationTarget(checkpointStore); - target.RejectKey("a", "Rejected"); + target.RejectKey("a", MigrationSkipReason.BodyUnreadable); var logger = new CapturingLogger(); // A floor of zero lets the one rejected row halt the category. var options = new MigrationEngineOptions(TimeSpan.Zero, HaltThresholdPercent: 5, HaltThresholdMinimum: 0, []); @@ -234,7 +234,7 @@ sealed class HaltSaveFailsCheckpointStore : IMigrationCheckpointStore public Task Read(string categoryId, CancellationToken cancellationToken = default) => saved.Read(categoryId, cancellationToken); - public Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default) => + public Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default) => checkpoint.State == MigrationCategoryState.Halted ? throw new TimeoutException("checkpoint store unreachable") : saved.Upsert(checkpoint, cancellationToken); } } diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs index 21e0b59be4..ba24b8dfaf 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs @@ -29,7 +29,7 @@ public async Task A_systemic_failure_halts_the_category_partway_through() var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 100 }; foreach (var i in Enumerable.Range(1, 1_000).Where(i => i % 5 == 0)) { - target.RejectKey($"row-{i}", "Rejected"); + target.RejectKey($"row-{i}", MigrationSkipReason.BodyUnreadable); } var options = new MigrationEngineOptions(TimeSpan.Zero, HaltThresholdPercent: 5, HaltThresholdMinimum: 100, []); var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); @@ -40,7 +40,7 @@ public async Task A_systemic_failure_halts_the_category_partway_through() { Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Halted)); Assert.That(checkpoint.LastError, Does.Contain("Halted")); - Assert.That(checkpoint.CompletedAt, Is.Not.Null); + Assert.That(checkpoint.SettledAt, Is.Not.Null); // Stopped partway: the 1,000th row was never reached. Assert.That(target.WrittenRows(category.Id).Count, Is.LessThan(800)); } @@ -81,7 +81,7 @@ public async Task A_restart_after_a_threshold_halt_counts_only_its_own_skips_and var failingTarget = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 100 }; foreach (var i in Enumerable.Range(1, 1_000).Where(i => i % 5 == 0)) { - failingTarget.RejectKey($"row-{i}", "Rejected"); + failingTarget.RejectKey($"row-{i}", MigrationSkipReason.BodyUnreadable); } var options = new MigrationEngineOptions(TimeSpan.Zero, HaltThresholdPercent: 5, HaltThresholdMinimum: 100, []); var halted = await new MigrationEngine(source, failingTarget, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance).RunCategoryAsync(category); diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs index 141864afc8..67c1e3b53b 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs @@ -34,13 +34,12 @@ public async Task LicensingThroughput_does_not_start_before_LicensingEndpoints_c var persisted = await checkpointStore.Read(throughputCategory.Id); using (Assert.EnterMultipleScope()) { - Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.NotStarted)); + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Blocked)); Assert.That(target.WrittenRows(throughputCategory.Id), Is.Empty); // A row exists, so status can print it. Without one, an operator cannot tell a category // waiting on another from a category nobody asked for. Assert.That(persisted, Is.Not.Null); - Assert.That(persisted!.Selected, Is.True); - Assert.That(persisted.State, Is.EqualTo(MigrationCategoryState.NotStarted)); + Assert.That(persisted!.State, Is.EqualTo(MigrationCategoryState.Blocked)); Assert.That(persisted.LastError, Does.Contain("LicensingEndpoints")); } } @@ -61,7 +60,7 @@ public async Task EndpointSettings_does_not_start_before_KnownEndpoints_complete using (Assert.EnterMultipleScope()) { - Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.NotStarted)); + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Blocked)); Assert.That(target.WrittenRows(settingsCategory.Id), Is.Empty); } } @@ -74,7 +73,7 @@ public async Task GroupComments_does_not_start_before_the_archive_completes(Migr var source = new InMemoryMigrationSource(); source.Seed(comments.Id, Row("GroupComment/g-1")); var checkpointStore = new InMemoryMigrationCheckpointStore(); - await checkpointStore.Upsert(new MigrationCheckpoint("ArchivedAndResolvedFailedMessages", true, archiveState, "m-500", 500, 0, null, null, DateTime.UtcNow, DateTime.UtcNow, null, null, null)); + await checkpointStore.Upsert(new MigrationCheckpoint("ArchivedAndResolvedFailedMessages", archiveState, "m-500", 500, 0, null, null, DateTime.UtcNow, DateTime.UtcNow, null, null)); var target = new InMemoryMigrationTarget(checkpointStore); var engine = BuildEngine(source, checkpointStore, target); @@ -82,7 +81,7 @@ public async Task GroupComments_does_not_start_before_the_archive_completes(Migr using (Assert.EnterMultipleScope()) { - Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.NotStarted)); + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Blocked)); Assert.That(checkpoint.LastError, Is.EqualTo($"Blocked: GroupComments must follow ArchivedAndResolvedFailedMessages, which is {archiveState}")); Assert.That(target.WrittenRows(comments.Id), Is.Empty); } @@ -97,7 +96,7 @@ public async Task LicensingThroughput_proceeds_once_LicensingEndpoints_is_finish var source = new InMemoryMigrationSource(); source.Seed(throughputCategory.Id, Row("t-1")); var checkpointStore = new InMemoryMigrationCheckpointStore(); - await checkpointStore.Upsert(new MigrationCheckpoint("LicensingEndpoints", true, endpointsState, "e-1", 1, 0, 1, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null, null)); + await checkpointStore.Upsert(new MigrationCheckpoint("LicensingEndpoints", endpointsState, "e-1", 1, 0, 1, null, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null)); var target = new InMemoryMigrationTarget(checkpointStore); var engine = BuildEngine(source, checkpointStore, target); diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs index fdc62cdd4e..ff7246d3bd 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs @@ -69,7 +69,7 @@ public async Task A_graceful_stop_saves_the_real_split_of_the_last_committed_bat var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2, StopOnCall = (3, stopping) }; // Both in the second batch, the last one to commit before the stop. target.SeedExistingKey("row-3"); - target.RejectKey("row-4", "Rejected"); + target.RejectKey("row-4", MigrationSkipReason.BodyUnreadable); var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); var firstEngine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); @@ -86,12 +86,12 @@ public async Task A_graceful_stop_saves_the_real_split_of_the_last_committed_bat Assert.That((afterStop.CopiedCount, afterStop.SkippedCount, afterStop.AlreadyPresentCount), Is.EqualTo((2L, 1L, 1L)), "copied, skipped, already present after the stop"); Assert.That(finalCheckpoint.State, Is.EqualTo(MigrationCategoryState.CompleteWithErrors)); Assert.That((finalCheckpoint.CopiedCount, finalCheckpoint.SkippedCount, finalCheckpoint.AlreadyPresentCount), Is.EqualTo((4L, 1L, 1L)), "copied, skipped, already present at the end"); - Assert.That(finalCheckpoint.SkipReasons, Is.EquivalentTo(new Dictionary { ["Rejected"] = 1 })); + Assert.That(finalCheckpoint.SkipReasons, Is.EquivalentTo(new Dictionary { [MigrationSkipReason.BodyUnreadable] = 1 })); } } [Test] - public async Task A_hard_crash_after_a_committed_write_is_accepted_to_leave_that_batch_counted_as_copied_and_can_end_Complete() + public async Task A_hard_crash_after_a_committed_write_loses_nothing_because_the_target_saved_the_real_split() { var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; var source = new InMemoryMigrationSource(); @@ -99,7 +99,7 @@ public async Task A_hard_crash_after_a_committed_write_is_accepted_to_leave_that var committed = new InMemoryMigrationCheckpointStore(); var target = new InMemoryMigrationTarget(committed) { DefaultBatchSize = 2 }; target.SeedExistingKey("row-3"); - target.RejectKey("row-4", "Rejected"); + target.RejectKey("row-4", MigrationSkipReason.BodyUnreadable); var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); var crashingEngine = new MigrationEngine(source, target, new CrashAfterCommitCheckpointStore(committed, crashAfterCursor: "row-4"), new FakeTimeProvider(), options, NullLogger.Instance); await crashingEngine.RunCategoryAsync(category); @@ -109,9 +109,9 @@ public async Task A_hard_crash_after_a_committed_write_is_accepted_to_leave_that using (Assert.EnterMultipleScope()) { - // Accepted: closing this needs the target to save the real split with the rows. - Assert.That(finalCheckpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); - Assert.That((finalCheckpoint.CopiedCount, finalCheckpoint.SkippedCount, finalCheckpoint.AlreadyPresentCount), Is.EqualTo((4L, 0L, 0L)), "copied, skipped, already present at the end"); + // The engine's own settle was lost, but every count came from the target's own transaction. + Assert.That(finalCheckpoint.State, Is.EqualTo(MigrationCategoryState.CompleteWithErrors)); + Assert.That((finalCheckpoint.CopiedCount, finalCheckpoint.SkippedCount, finalCheckpoint.AlreadyPresentCount), Is.EqualTo((2L, 1L, 1L)), "copied, skipped, already present at the end"); Assert.That(target.WrittenRows(category.Id).Select(r => r.SourceId), Is.EqualTo(new[] { "row-1", "row-2" })); } } @@ -123,12 +123,9 @@ sealed class CrashAfterCommitCheckpointStore(IMigrationCheckpointStore committed public Task Read(string categoryId, CancellationToken cancellationToken = default) => committed.Read(categoryId, cancellationToken); - public async Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default) - { - if ((await committed.Read(checkpoint.CategoryId, cancellationToken))?.Cursor != crashAfterCursor) - { - await committed.Upsert(checkpoint, cancellationToken); - } - } + public async Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default) => + (await committed.Read(checkpoint.CategoryId, cancellationToken))?.Cursor == crashAfterCursor + ? checkpoint + : await committed.Upsert(checkpoint, cancellationToken); } } diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs index 2d2d0dda9f..618edd403a 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs @@ -24,9 +24,10 @@ public async Task Reasons_the_target_reports_add_up_across_batches_on_the_checkp source.Seed(category.Id, Row("a"), Row("b"), Row("c"), Row("d")); var checkpointStore = new InMemoryMigrationCheckpointStore(); var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 3 }; - target.RejectKey("a", "KeyTooLong"); - target.RejectKey("b", "Unparseable"); - target.RejectKey("d", "KeyTooLong"); + // One reason exists, so this pins the total rather than the split between reasons. + target.RejectKey("a", MigrationSkipReason.BodyUnreadable); + target.RejectKey("b", MigrationSkipReason.BodyUnreadable); + target.RejectKey("d", MigrationSkipReason.BodyUnreadable); var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); @@ -35,8 +36,8 @@ public async Task Reasons_the_target_reports_add_up_across_batches_on_the_checkp using (Assert.EnterMultipleScope()) { Assert.That(checkpoint.SkippedCount, Is.EqualTo(3)); - Assert.That(checkpoint.SkipReasons, Is.EquivalentTo(new Dictionary { ["KeyTooLong"] = 2, ["Unparseable"] = 1 })); - Assert.That((await checkpointStore.Read(category.Id))!.SkipReasons, Is.EquivalentTo(new Dictionary { ["KeyTooLong"] = 2, ["Unparseable"] = 1 })); + Assert.That(checkpoint.SkipReasons, Is.EquivalentTo(new Dictionary { [MigrationSkipReason.BodyUnreadable] = 3 })); + Assert.That((await checkpointStore.Read(category.Id))!.SkipReasons, Is.EquivalentTo(new Dictionary { [MigrationSkipReason.BodyUnreadable] = 3 })); } } @@ -54,7 +55,7 @@ public async Task A_message_whose_body_is_never_read_is_counted_under_BodyUnread var checkpoint = await engine.RunCategoryAsync(category); - Assert.That(checkpoint.SkipReasons, Is.EquivalentTo(new Dictionary { [nameof(MigrationSkipReason.BodyUnreadable)] = 1 })); + Assert.That(checkpoint.SkipReasons, Is.EquivalentTo(new Dictionary { [MigrationSkipReason.BodyUnreadable] = 1 })); } [Test] @@ -74,7 +75,7 @@ public async Task A_target_whose_skip_reasons_do_not_add_up_to_its_skips_halts_t { Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Halted)); Assert.That(checkpoint.LastError, Does.Contain("reported 1 skipped").And.Contain("reasons for 0")); - Assert.That(checkpoint.Cursor, Is.EqualTo("a"), "the target committed the batch, so a restart must carry on after it"); + Assert.That(checkpoint.Cursor, Is.Null, "the target refused ahead of its commit, so nothing was written and a restart reads the batch again"); } } @@ -82,10 +83,10 @@ sealed class UnexplainedSkipTarget(IMigrationCheckpointStore checkpointStore) : { public int BatchSizeFor(MigrationCategory category) => 10; - public async Task Write(MigrationCategory category, MigrationBatch batch, MigrationCheckpoint checkpointAfterBatch, CancellationToken cancellationToken = default) + public async Task Write(MigrationCategory category, MigrationBatch batch, MigrationCheckpoint checkpointToExtend, CancellationToken cancellationToken = default) { - await checkpointStore.Upsert(checkpointAfterBatch, cancellationToken); - return new MigrationWriteResult(0, batch.Rows.Count, []); + var saved = await checkpointStore.Upsert(checkpointToExtend.Extend(0, batch.Rows.Count, 0, null), cancellationToken); + return new MigrationWriteResult(saved, 0, batch.Rows.Count, []); } public Task Count(MigrationCategory category, CancellationToken cancellationToken = default) => Task.FromResult(0L); From 4bc951ec7fed547e8a905320f80d8c88048b6c1a Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 17 Sep 2026 10:16:01 +0800 Subject: [PATCH 10/15] Enhance migration engine to handle benign skips and update related tests - Introduced `BenignSkipped` property in `MigrationWriteResult` to track rows that the target would have deleted anyway. - Updated `MigrationEngine` logic to ensure benign skips do not count towards the halt threshold. - Added tests to verify that benign skips are handled correctly and do not cause category halts. - Updated skip reasons to include `PastRetention` for better clarity on skipped rows. - Created approval files for category selection tests to ensure correct order and configuration. --- .../ravendb-to-sql-migration-overview.md | 103 ++++++++++++++++-- .../DataMigration/IMigrationTarget.cs | 3 +- .../DataMigration/MigrationEngine.cs | 9 +- .../DataMigration/MigrationSkipReason.cs | 1 + ...ategory_runs_in_a_fixed_order.approved.txt | 18 +++ ...ional_categories_are_selected.approved.txt | 6 + .../Fakes/InMemoryMigrationTarget.cs | 15 ++- .../MigrationEngineCategorySelectionTests.cs | 34 +++++- .../Migration/MigrationEngineHaltTests.cs | 27 +++++ .../MigrationEngineSkipReasonTests.cs | 34 ++++++ 10 files changed, 226 insertions(+), 24 deletions(-) create mode 100644 src/ServiceControl.UnitTests/ApprovalFiles/MigrationEngineCategorySelectionTests.Every_category_runs_in_a_fixed_order.approved.txt create mode 100644 src/ServiceControl.UnitTests/ApprovalFiles/MigrationEngineCategorySelectionTests.Only_configured_optional_categories_are_selected.approved.txt diff --git a/docs/migration/ravendb-to-sql-migration-overview.md b/docs/migration/ravendb-to-sql-migration-overview.md index 3f04217729..de2c322b14 100644 --- a/docs/migration/ravendb-to-sql-migration-overview.md +++ b/docs/migration/ravendb-to-sql-migration-overview.md @@ -77,14 +77,14 @@ The copier runs inside the ServiceControl host, so every row and every message b 6. Every check runs before a single row moves. If one fails the host does not start and names which, having copied nothing, so a wrong database name or unconfigured body storage costs a restart rather than a half-finished migration. 7. The copying of [required data](#required) starts, with ServiceControl still closed. This is assumed to be a small amount of data. 8. ServiceControl opens, and whatever [optional data](#optional) they asked for is copied in the background while the instance runs normally. They can watch it from ServicePulse custom checks and events, but not steer it. -9. They run the verification pass once the background job has completed, which reports row counts on both sides category by category, accounting for deliberate skips so a difference is explained rather than reported as a fault, then set `MigrationMode=false` and restart. +9. They run the verification pass once the background job has completed, which reports row counts on both sides category by category, accounting for deliberate skips so a difference is explained rather than reported as a fault, then set `MigrationMode=false` and restart. It tolerates more rows in SQL than in RavenDB, because RavenDB keeps expiring rows the copier already took. 10. RavenDB data can be removed. - If `MigrationMode=false` is set while a selected category is still incomplete, the host refuses to start and names exactly what is outstanding. - A category that ended *complete with errors* counts as complete and does not block, though its skipped count is printed so the loss is stated rather than silent. - An explicit override exists for a customer who has changed their mind and accepts leaving data behind. It marks the outstanding categories as abandoned, which is a deliberate end state rather than a failure, so the progress check settles and the guard stays armed for any later migration. - **Steps 5 to 7 are the abort window**, which is not the override above: see [the one point you can go back](#the-one-point-you-can-go-back). -- A category that stops because too many rows failed is *halted*, and restarting will halt it again. Fix the cause and restart to resume it, or abandon it deliberately if you accept the loss. +- A category that stops because too many rows failed is *halted*, and it stays that way until someone acts: fix the cause and restart to carry on from where it stopped, or abandon it deliberately if you accept the loss. See [a halt stops one category, and clearing it is a restart](#a-halt-stops-one-category-and-clearing-it-is-a-restart). ## Architecture @@ -172,7 +172,7 @@ flowchart TB ### Not migrated -- The fifteen RavenDB index definitions, which map to a much smaller set of ordinary SQL indexes, and two of which are dead already +- The RavenDB index definitions - The transient in-flight collections, which are empty when nothing is running: `RetryBatches`, `RetryBatchNowForwardings`, `FailedMessageRetries`, `ArchiveOperations` and `UnarchiveOperations` - `ArchiveBatches` and `UnarchiveBatches`, which exist only because of how RavenDB works - `ConnectedApplications`, which only versions 6.0 and 6.1 wrote and nothing has read since @@ -201,11 +201,13 @@ flowchart TB - **Endpoint settings for two endpoint names that differ only in case merge onto one row on SQL Server**, because SQL Server's default collation compares names without case, so one of the two settings is kept. PostgreSQL keeps both, and so does a SQL Server database created with a case-sensitive collation. The dry run counts this one too, by asking SQL Server how the name column compares, though for unusual characters its count can differ from what the copy does. - **Event log items and historic retry operations are renumbered.** Their keys are database identities and nothing references them, so this is safe, but the old numbers do not survive. +**Rows RavenDB deletes while the copy is running are an absence, not a skip.** Expiration only deletes a document carrying `@expires`, and only two kinds ever get one: a resolved or archived failed message, and an event log item (`ExpirationManager.cs:34,41`). Everything in the [required](#required) set is therefore safe, since unresolved and retry-issued messages have their expiry removed when the retry is issued, so only the archived and resolved messages category and the event log category can shrink underneath the copier, and both copy in the background where the window is longest. A document the sweep removes before the stream reaches it is never read, so it is counted nowhere: the counts are of rows the source actually handed over, and there is no expected total to fall short of. It is the same population as the retention skip above, and which of the two it becomes is a race with the sweep. The consequence to know is that the dry run's count is a snapshot rather than a promise, and for those two categories the difference between it and the final copied count is not attributed to anything. + **A category can finish with a small amount of loss and still count as complete.** A few skipped rows in a large table leave the category in a *complete with errors* state, which blocks nothing. Its skipped count is printed and the ids of the skipped rows are written to the log, so while the RavenDB database still exists you can go and look at exactly what did not make it. ## The one point you can go back -While ServiceControl is closed and the required copy is running, nothing except the copier has written to SQL, and the migration has written nothing to RavenDB, which is still authoritative. RavenDB's own expiration still runs, though: unless you disabled it, it keeps deleting expired failed messages and event log items, as [Goals](#goals) describes. Back up both RavenDB databases, or disable expiration on them, before you start. If you need your instance back, set `MigrationMode=false`, point `PersistenceType` back at RavenDB, and start. You lose the copy, not your data, and you can start again later. +While ServiceControl is closed and the required copy is running, nothing except the copier has written to SQL, and the migration has written nothing to RavenDB, which is still authoritative. RavenDB's own expiration still runs, though: unless you disabled it, it keeps deleting expired failed messages and event log items, as [Goals](#goals) describes. If you need your instance back, set `MigrationMode=false`, point `PersistenceType` back at RavenDB, and start. You lose the copy, not your data, and you can start again later. That window closes the moment ServiceControl opens. From then on new failed messages are ingesting into SQL, RavenDB is no longer current, and there is no rollback: nothing copies SQL rows back. The choice at that point is to finish the migration or to accept losing whatever has not been copied. @@ -222,7 +224,7 @@ That window closes the moment ServiceControl opens. From then on new failed mess - `UniqueMessageId` keeps its value, but converts type: the source holds a string and the target column is a `uniqueidentifier`. It is the primary key, the ServicePulse URL, the retry correlation key and the body lookup key at once. - `StatusChangedAt` is reconstructed from `@expires` for resolved and archived messages, which is the only place RavenDB sets it. Unresolved and retry-issued messages have no `@expires`, so the copier uses the newest processing attempt's timestamp. The column is `NOT NULL`, so it cannot be left empty, but the value is harmless for those two: the retention sweep only considers resolved and archived rows, so an unresolved message never ages out whatever is written here. - Message bodies go through `IBodyStoragePersistence`, which owns the compression threshold and the choice of filesystem, Azure Blob or S3. The separate 102,400-byte inline threshold is not there: it lives on the ingestion path, so the copier has to apply it rather than inherit it. -- Throughput rows are written directly rather than through the collector, and the write sets each day's count rather than adding to it. +- Throughput rows are written directly rather than through the collector, and the write sets each day's count rather than adding to it. Throughput is a required category, so it copies while ServiceControl is closed, before any collector has written to SQL. Setting is what makes the category safe to resume after a crash, where adding would double-count. Copying the rows is also what stops the audit and broker collectors re-gathering the same days when the host opens, because `LastCollectedDate` is derived from the newest throughput row rather than stored (`LicensingDataStore.cs:45`). The checkpoint is what stops a second pass overwriting days the collectors have written since. - Identifiers narrow on the way across, and the dry run counts every kind. What narrows, merges or cannot be stored at all is in [what does not come across](#what-does-not-come-across). ## Batching and throttling @@ -232,12 +234,50 @@ That window closes the moment ServiceControl opens. From then on new failed mess ## Checkpointing and resume -- The checkpoint table is a new table on the target, created by `--setup` along with the rest of the schema. -- It holds one row per category: the selection that row ran with, the state, the resume cursor, copied and skipped counts, timings and the last error. -- Only the copier writes to it. The status command reads it. -- Rows and the resume cursor commit in one transaction. -- If a message is in both databases the SQL row wins and the copier skips it, so every category is safe to run twice. -- Each category has its own cursor, so a half-copied category picks up where it stopped. +A copy that runs for hours will be interrupted at some point: a restart, a dropped connection, a machine reboot. The checkpoint is what makes an interruption cost only the batch that was in flight. It is one row per category, kept on the target and created by `--setup` along with the rest of the schema, and it is written in the same database transaction as the rows it describes. Only the copier writes to it; the status and verify commands read it. + +**What one row holds:** the category it tracks, its state, the resume cursor, how many rows were copied, skipped and already present, a count per skip reason, how many rows the source held when the category started, when it started, when it last made progress, when it settled, the last error, and a version number used to spot a second writer. + +**The states, and which ones a restart re-enters.** `Complete`, `CompleteWithErrors` and `Abandoned` are terminal, so a restart passes straight over the category. `Halted` and `Blocked` are not: a halt is resumed from its cursor once the cause is fixed, and a block clears itself once the category it waits on settles, which is how group comments end up behind archived messages. `NotStarted` and `InProgress` both mean there is work to do. + +### One batch, and why nothing provisional is ever saved + +```mermaid +sequenceDiagram + participant E as Migration engine + participant S as RavenDB source + participant T as SQL target + participant C as Checkpoint row + + E->>C: Read this category's row + C-->>E: State, cursor, totals so far + loop One batch at a time + E->>S: Read the next batch after the cursor + S-->>E: Rows, and the cursor they end at + opt The category carries message bodies + E->>S: Read each body, up to three attempts + S-->>E: The bodies, and which ones could not be read + end + E->>T: Write the rows, with the totals so far,
the unreadable bodies and the new cursor + Note over T,C: One transaction. The rows, the target's own skips,
the new totals and the cursor all commit, or none of them do + T-->>E: The checkpoint exactly as it committed + E->>E: Halt if too much of this run was skipped + end + E->>C: Settle as complete, complete with errors, or halted +``` + +The thing to read twice is that the counts never travel back through the engine to be saved on some later write. The engine hands the target the totals so far, the target adds its own outcome to them and saves the result beside the rows, and the engine then keeps whatever committed. So there is no window in which the stored row claims rows that are not there, and a crash at any instant leaves counts and cursor that both describe exactly the rows in SQL. + +**What that buys, and why each part is needed:** + +- **Progress never gets ahead of the data.** The rows and the cursor commit together, so a restart cannot skip past rows that were never written. +- **A crash costs the batch in flight and nothing else.** The next run reads from the committed cursor. +- **Re-reading a batch cannot double-count it.** Unreadable bodies stay off the checkpoint until the write commits, so a batch that is read twice is counted once, and rows the earlier attempt did write come back as *already present* rather than as fresh copies. +- **Every skipped row has a reason, or the save is refused.** The checkpoint rejects a batch reporting more skips than it explains, because verification has to account for each one rather than report a healthy migration as broken. +- **Each category resumes independently**, so a half-copied category picks up where it stopped while its neighbours are untouched. +- **The halt counters are per run and deliberately not stored.** If the skips that tripped a halt stayed on the row, a restart with the cause fixed would re-trip it on its first batch. +- **A second writer is caught rather than merged.** Each save carries the version it read, and a save against a row that has moved on is refused, so two hosts pointed at one target cannot quietly interleave their progress. +- **If a message is already in SQL the SQL row wins and the copier skips it**, which is what makes every category safe to run twice. ## Error handling @@ -246,9 +286,48 @@ That window closes the moment ServiceControl opens. From then on new failed mess - Deciding whether a row is past the target's retention cutoff needs two retention periods: the source's reverses `@expires` back into the status-change instant, and the target's current one decides whether that instant is past the cutoff. - A bad row does not stop the copy. Its category finishes in a separate complete-with-errors state. - The halt threshold is proportional with an absolute floor, and a category halts only when both are exceeded. Proportional alone halts a three-row category on one bad row; absolute alone halts a five-million-row table on its 101st failure at the default floor of 100. Together, a large category keeps going through losses under the percentage and finishes complete with errors, so ten thousand skipped rows out of five million do not halt it. -- Rows left behind because SQL would remove them anyway (past retention, orphaned group comments, settings for unknown endpoints) are counted and reported, but never halt a category. +- Rows left behind because SQL would remove them anyway (past retention, orphaned group comments, settings for unknown endpoints) are counted and reported, but never halt a category. The target reports them apart from its real failures, so they land in the skipped count and the log without moving the category toward a halt. +- The percentage is measured against what the run has processed so far rather than against the category's total, so a run that starts badly looks worse than it is. The floor is what keeps that harmless, since fewer than 101 skipped rows never consults the percentage at all. More than that, bunched at the start, does halt a category whose overall rate would have been fine, and the cost is one restart: the skipped rows commit with the cursor, so the next run resumes past them with its counters back at zero. +- A source therefore must not read a category in an order that puts the rows most likely to be skipped at the front of it. - Verification therefore cannot treat any count difference as a fault. It accounts for every skip rule, or it reports every successful migration as broken. +### A halt stops one category, and clearing it is a restart + +A halt is the copy refusing to keep going on one category because something is wrong beyond the odd bad row. It is not a crash and not data loss: everything already copied is committed, the cursor points at the row after the last one that committed, and the reason is written on the category. Nothing is retried in the background and nothing waits for a timer. The category sits halted until a person does something about it. + +```mermaid +stateDiagram-v2 + [*] --> NotStarted: nothing has run yet + NotStarted --> InProgress: the host starts with MigrationMode = true + NotStarted --> Blocked: the category it must follow has not settled + Blocked --> InProgress: that category settles, then the next restart + InProgress --> InProgress: the host was stopped mid-copy,
so the next start resumes from the cursor + InProgress --> Complete: every row reached, none skipped + InProgress --> CompleteWithErrors: every row reached, some skipped + InProgress --> Halted: too many rows skipped in this run,
or the copy hit an error it did not expect + Halted --> InProgress: fix the cause, restart,
carry on from the cursor + Halted --> Abandoned: accept the loss, deliberately + InProgress --> Abandoned: accept the loss, deliberately + Complete --> [*] + CompleteWithErrors --> [*] + Abandoned --> [*] +``` + +**Two things halt a category.** Either the skipped rows in this run pass both the percentage and the floor, which says the failures are systematic rather than incidental, or the copy hits an error it did not expect, in which case the error type and the cursor it stopped at are recorded. A host being shut down is neither: it leaves the category in progress, to be picked up from the cursor next time. Nor is a second host writing to the same checkpoint, which is refused so that the other host's progress stands. + +**A halt stops that category and nothing else.** The remaining categories still run, with one exception: a category that must follow the halted one goes to blocked rather than running early, which is how group comments stay behind the archived messages they belong to. A blocked category is not a failure and needs no separate action, since clearing the halt clears the block on the next restart. + +**What it costs depends on which category halted.** A halted optional category means the instance keeps serving traffic and that one slice of history is missing until it is resumed. A halted required category means the host stays closed, so the outage carries on until the halt is cleared or the category is abandoned. That is deliberate: opening the host is the point of no return, and it should not happen with required data left behind by accident. + +**Clearing it:** + +1. Read the reason on the category, in the custom check or the status command. It names the count that tripped the threshold, or the error, and the cursor either way. +2. Fix the cause. It is usually outside the migration: the body store unreachable, a certificate expired, the source or the target down, or the disk full. +3. Restart the host with `MigrationMode=true`. The category picks up at its cursor, its run counters start again at zero, and the skips already recorded stay on the row so the totals still add up at the end. +4. Repeat only if it halts again. A restart that halts at the same point is telling you the cause is still there, and a restart that gets further has made real progress, because the rows it skipped are committed and will not be read again. + +**Or abandon it, on purpose.** Abandoning marks the category as deliberately given up rather than failed, which lets the host open and lets the migration end. It is the right answer when the data is not worth the outage, and the wrong one if it was picked by accident, because nothing goes back for an abandoned category afterwards. What it leaves behind is stated in the counts rather than guessed at. + ## Dry run Runnable before anything starts, and again later against whatever is still outstanding. It never writes to RavenDB. diff --git a/src/ServiceControl.Persistence/DataMigration/IMigrationTarget.cs b/src/ServiceControl.Persistence/DataMigration/IMigrationTarget.cs index 2d6aeeb84a..78026fc571 100644 --- a/src/ServiceControl.Persistence/DataMigration/IMigrationTarget.cs +++ b/src/ServiceControl.Persistence/DataMigration/IMigrationTarget.cs @@ -23,4 +23,5 @@ Task Write( } /// What the target did with one batch, and the checkpoint it committed alongside the rows. Every skipped row must have a reason in SkipReasons. -public sealed record MigrationWriteResult(MigrationCheckpoint Saved, int Copied, int Skipped, IReadOnlyList SkippedIds, int AlreadyPresent = 0, IReadOnlyDictionary? SkipReasons = null); +/// How many of Skipped the target would have deleted anyway, such as a row already past retention. Counted and reported like any skip, but never counted toward the halt threshold. +public sealed record MigrationWriteResult(MigrationCheckpoint Saved, int Copied, int Skipped, IReadOnlyList SkippedIds, int AlreadyPresent = 0, IReadOnlyDictionary? SkipReasons = null, int BenignSkipped = 0); diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs b/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs index 352097d470..c64ac05f2d 100644 --- a/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs +++ b/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs @@ -128,8 +128,15 @@ public async Task RunCategoryAsync(MigrationCategory catego logger.LogWarning("Skipped {SourceId} in category {CategoryId}", id, category.Id); } + // A negative fault count would silently disarm the halt threshold for the rest of the run. + if (result.BenignSkipped > result.Skipped) + { + throw new InvalidOperationException($"The target reported {result.BenignSkipped} benign skips in category {category.Id} out of {result.Skipped} skipped rows. Benign skips are a subset of the skipped rows."); + } + processedThisRun += bodySkips + result.Copied + result.Skipped + result.AlreadyPresent; - skippedThisRun += bodySkips + result.Skipped; + // Rows the target would have deleted anyway are not faults, so they never halt a category. + skippedThisRun += bodySkips + result.Skipped - result.BenignSkipped; if (HaltThreshold.Exceeded(skippedThisRun, processedThisRun, options.HaltThresholdPercent, options.HaltThresholdMinimum)) { diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationSkipReason.cs b/src/ServiceControl.Persistence/DataMigration/MigrationSkipReason.cs index b79be632a8..4129eb939d 100644 --- a/src/ServiceControl.Persistence/DataMigration/MigrationSkipReason.cs +++ b/src/ServiceControl.Persistence/DataMigration/MigrationSkipReason.cs @@ -3,6 +3,7 @@ namespace ServiceControl.Persistence.DataMigration; public enum MigrationSkipReason { BodyUnreadable, + PastRetention, // Never written by a copier. A database a newer build wrote still reads rather than throwing where the host decides whether to start. Unknown } diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/MigrationEngineCategorySelectionTests.Every_category_runs_in_a_fixed_order.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/MigrationEngineCategorySelectionTests.Every_category_runs_in_a_fixed_order.approved.txt new file mode 100644 index 0000000000..b77ea84b2b --- /dev/null +++ b/src/ServiceControl.UnitTests/ApprovalFiles/MigrationEngineCategorySelectionTests.Every_category_runs_in_a_fixed_order.approved.txt @@ -0,0 +1,18 @@ +Required 1: KnownEndpoints +Required 2: EndpointSettings, after KnownEndpoints +Required 3: MessageRedirects +Required 4: Subscriptions +Required 5: NotificationSettings +Required 6: TrialEndDate +Required 7: RetryOperations +Required 8: LicensingEndpoints +Required 9: LicensingThroughput, after LicensingEndpoints +Required 10: LicensingReportMasks +Required 11: LicensedEndpointDetails +Required 12: UnresolvedAndRetryIssuedFailedMessages, with bodies +Optional 1: EventLog +Optional 2: CustomChecks +Optional 3: FailedErrorImports, with bodies +Optional 4: FailedMessageEdits +Optional 5: ArchivedAndResolvedFailedMessages, with bodies +Optional 6: GroupComments, after ArchivedAndResolvedFailedMessages \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/MigrationEngineCategorySelectionTests.Only_configured_optional_categories_are_selected.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/MigrationEngineCategorySelectionTests.Only_configured_optional_categories_are_selected.approved.txt new file mode 100644 index 0000000000..6c96bee11f --- /dev/null +++ b/src/ServiceControl.UnitTests/ApprovalFiles/MigrationEngineCategorySelectionTests.Only_configured_optional_categories_are_selected.approved.txt @@ -0,0 +1,6 @@ +Configured: GroupComments, EventLog, ArchivedAndResolvedFailedMessages +Runs as: +Optional 1: EventLog +Optional 5: ArchivedAndResolvedFailedMessages, with bodies +Optional 6: GroupComments, after ArchivedAndResolvedFailedMessages +Never copied: CustomChecks, FailedErrorImports, FailedMessageEdits \ No newline at end of file diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs index 38f005d1b1..4e50306469 100644 --- a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs @@ -11,7 +11,7 @@ public sealed class InMemoryMigrationTarget(IMigrationCheckpointStore checkpoint readonly Dictionary> writtenKeysByCategory = []; readonly Dictionary> writtenRowsByCategory = []; readonly HashSet preExistingKeys = []; - readonly Dictionary rejectedKeys = []; + readonly Dictionary rejectedKeys = []; public int DefaultBatchSize { get; set; } = 3; public string NoBatchSizeFor { get; set; } @@ -21,7 +21,7 @@ public sealed class InMemoryMigrationTarget(IMigrationCheckpointStore checkpoint public void SeedExistingKey(string sourceId) => preExistingKeys.Add(sourceId); - public void RejectKey(string sourceId, MigrationSkipReason reason) => rejectedKeys[sourceId] = reason; + public void RejectKey(string sourceId, MigrationSkipReason reason, bool benign = false) => rejectedKeys[sourceId] = (reason, benign); public IReadOnlyList WrittenRows(string categoryId) => writtenRowsByCategory.TryGetValue(categoryId, out var rows) ? rows : []; @@ -53,15 +53,20 @@ public async Task Write( var copied = 0; var alreadyPresent = 0; + var benignSkipped = 0; var skippedIds = new List(); var skipReasons = new Dictionary(); foreach (var row in batch.Rows) { - if (rejectedKeys.TryGetValue(row.SourceId, out var reason)) + if (rejectedKeys.TryGetValue(row.SourceId, out var rejection)) { skippedIds.Add(row.SourceId); - skipReasons[reason] = skipReasons.GetValueOrDefault(reason) + 1; + skipReasons[rejection.Reason] = skipReasons.GetValueOrDefault(rejection.Reason) + 1; + if (rejection.Benign) + { + benignSkipped++; + } continue; } @@ -81,7 +86,7 @@ public async Task Write( checkpointToExtend.Extend(copied, skippedIds.Count, alreadyPresent, skipReasons), cancellationToken); - return new MigrationWriteResult(saved, copied, skippedIds.Count, skippedIds, alreadyPresent, skipReasons); + return new MigrationWriteResult(saved, copied, skippedIds.Count, skippedIds, alreadyPresent, skipReasons, benignSkipped); } public Task Count(MigrationCategory category, CancellationToken cancellationToken = default) => diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs index b153258775..9c74a7b605 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineCategorySelectionTests.cs @@ -6,6 +6,7 @@ namespace ServiceControl.UnitTests.Migration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using NUnit.Framework; +using Particular.Approvals; using ServiceControl.Persistence.DataMigration; using ServiceControl.UnitTests.Migration.Fakes; @@ -20,22 +21,45 @@ static MigrationEngine BuildEngine(IReadOnlyCollection selectedOptionalI return new MigrationEngine(new InMemoryMigrationSource(), target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); } + static string Describe(MigrationCategory category) => + $"{category.Kind} {category.Order}: {category.Id}" + + (category.CarriesBodies ? ", with bodies" : string.Empty) + + (category.MustFollow is null ? string.Empty : $", after {category.MustFollow}"); + [Test] - public void All_twelve_required_categories_are_always_selected() + public void Every_category_runs_in_a_fixed_order() { - var engine = BuildEngine([], out _); + var everyOptionalId = MigrationCategoryRegistry.All + .Where(category => category.Kind == MigrationCategoryKind.Optional) + .Select(category => category.Id) + .ToArray(); + var engine = BuildEngine(everyOptionalId, out _); + + var runOrder = engine.SelectCategories(MigrationCategoryKind.Required) + .Concat(engine.SelectCategories(MigrationCategoryKind.Optional)) + .Select(Describe); - Assert.That(engine.SelectCategories(MigrationCategoryKind.Required), Has.Count.EqualTo(12)); + Approver.Verify(string.Join(Environment.NewLine, runOrder)); } [Test] public void Only_configured_optional_categories_are_selected() { - var engine = BuildEngine(["EventLog"], out _); + string[] configured = [MigrationCategoryIds.GroupComments, MigrationCategoryIds.EventLog, MigrationCategoryIds.ArchivedAndResolvedFailedMessages]; + var engine = BuildEngine(configured, out _); var selected = engine.SelectCategories(MigrationCategoryKind.Optional); + var left = MigrationCategoryRegistry.All + .Where(category => category.Kind == MigrationCategoryKind.Optional && !selected.Contains(category)) + .Select(category => category.Id); - Assert.That(selected.Select(c => c.Id), Is.EqualTo(new[] { "EventLog" })); + Approver.Verify(string.Join(Environment.NewLine, + [ + $"Configured: {string.Join(", ", configured)}", + "Runs as:", + .. selected.Select(Describe), + $"Never copied: {string.Join(", ", left)}" + ])); } [Test] diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs index ba24b8dfaf..52edc7954f 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs @@ -46,6 +46,33 @@ public async Task A_systemic_failure_halts_the_category_partway_through() } } + [Test] + public async Task Rows_the_target_would_have_deleted_anyway_never_count_toward_the_halt_threshold() + { + var category = MigrationCategoryRegistry.Find("ArchivedAndResolvedFailedMessages")!; + var source = new InMemoryMigrationSource(); + // The same 20% that halts the category above, except these rows are past the target's retention + // cutoff, so leaving them behind is the copy working rather than failing. + source.Seed(category.Id, [.. Enumerable.Range(1, 1_000).Select(i => Row($"row-{i}"))]); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 100 }; + foreach (var i in Enumerable.Range(1, 1_000).Where(i => i % 5 == 0)) + { + target.RejectKey($"row-{i}", MigrationSkipReason.PastRetention, benign: true); + } + var options = new MigrationEngineOptions(TimeSpan.Zero, HaltThresholdPercent: 5, HaltThresholdMinimum: 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.CompleteWithErrors)); + Assert.That((checkpoint.CopiedCount, checkpoint.SkippedCount), Is.EqualTo((800L, 200L))); + Assert.That(target.WrittenRows(category.Id), Has.Count.EqualTo(800), "the last row was reached, so nothing halted partway"); + } + } + [Test] public async Task Rows_already_present_in_the_target_never_count_toward_the_halt_threshold() { diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs index 618edd403a..d8c5202d27 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs @@ -79,6 +79,40 @@ public async Task A_target_whose_skip_reasons_do_not_add_up_to_its_skips_halts_t } } + [Test] + public async Task A_target_counting_more_benign_skips_than_skipped_rows_halts_the_category() + { + var category = MigrationCategoryRegistry.Find(MigrationCategoryIds.KnownEndpoints)!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new OverCountedBenignTarget(checkpointStore); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Halted)); + Assert.That(checkpoint.LastError, Does.Contain("2 benign skips").And.Contain("out of 1 skipped")); + } + } + + sealed class OverCountedBenignTarget(IMigrationCheckpointStore checkpointStore) : IMigrationTarget + { + public int BatchSizeFor(MigrationCategory category) => 10; + + public async Task Write(MigrationCategory category, MigrationBatch batch, MigrationCheckpoint checkpointToExtend, CancellationToken cancellationToken = default) + { + var reasons = new Dictionary { [MigrationSkipReason.PastRetention] = batch.Rows.Count }; + var saved = await checkpointStore.Upsert(checkpointToExtend.Extend(0, batch.Rows.Count, 0, reasons), cancellationToken); + return new MigrationWriteResult(saved, 0, batch.Rows.Count, [], 0, reasons, BenignSkipped: batch.Rows.Count + 1); + } + + public Task Count(MigrationCategory category, CancellationToken cancellationToken = default) => Task.FromResult(0L); + } + sealed class UnexplainedSkipTarget(IMigrationCheckpointStore checkpointStore) : IMigrationTarget { public int BatchSizeFor(MigrationCategory category) => 10; From 0dcf9c82c40cd1a3faa3c330c969795d22fbc67d Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Thu, 17 Sep 2026 12:14:22 +0800 Subject: [PATCH 11/15] Enhance Migration Engine and Unit Tests - Improved error logging in MigrationEngine to provide clearer context on exceptions. - Added functionality to InMemoryMigrationSource to simulate shutdown behavior during body reads. - Enhanced InMemoryMigrationTarget to allow custom exceptions for failure scenarios. - Introduced TimerRecordingTimeProvider to facilitate testing of timer-related functionality. - Expanded HaltThresholdTests to cover edge cases involving mixed benign and fault skips. - Updated MigrationEngineBodyRetryTests to ensure proper handling of shutdowns during body reads. - Added tests to verify behavior when categories are empty or already completed. - Implemented checks for checkpoint conflicts and cancellation scenarios in MigrationEngineFailurePathTests. - Enhanced MigrationEngineHaltTests to evaluate mixed skip scenarios and their impact on halting. - Improved MigrationEngineOrderingTests to ensure blocked categories behave correctly. - Added tests to MigrationEngineResumeTests to verify that restart behavior maintains original start time. - Updated MigrationEngineRunCategoriesTests to handle cases where no categories are selected. - Enhanced MigrationEngineSkipReasonTests to ensure accurate recording of multiple skip reasons. - Added tests to MigrationEngineThrottleTests to verify behavior during pauses and shutdowns. --- .../DataMigration/MigrationEngine.cs | 3 +- .../Fakes/InMemoryMigrationSource.cs | 10 ++ .../Fakes/InMemoryMigrationTarget.cs | 13 +- .../Fakes/TimerRecordingTimeProvider.cs | 35 +++++ .../Migration/HaltThresholdTests.cs | 30 +++- .../MigrationEngineBodyRetryTests.cs | 86 ++++++++++- .../Migration/MigrationEngineCopyTests.cs | 66 +++++++++ .../MigrationEngineFailurePathTests.cs | 136 ++++++++++++++++++ .../Migration/MigrationEngineHaltTests.cs | 61 ++++++++ .../Migration/MigrationEngineOrderingTests.cs | 66 +++++++++ .../Migration/MigrationEngineResumeTests.cs | 30 ++++ .../MigrationEngineRunCategoriesTests.cs | 18 +++ .../MigrationEngineSkipReasonTests.cs | 32 +++++ .../Migration/MigrationEngineThrottleTests.cs | 57 ++++---- 14 files changed, 608 insertions(+), 35 deletions(-) create mode 100644 src/ServiceControl.UnitTests/Migration/Fakes/TimerRecordingTimeProvider.cs diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs b/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs index c64ac05f2d..3503321796 100644 --- a/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs +++ b/src/ServiceControl.Persistence/DataMigration/MigrationEngine.cs @@ -159,7 +159,8 @@ public async Task RunCategoryAsync(MigrationCategory catego } catch (Exception ex) { - var reason = $"{ex.GetType().Name} at cursor {checkpoint.Cursor ?? "the start"}: {ex.Message}"; + var position = checkpoint.Cursor is null ? "at the start" : $"at cursor {checkpoint.Cursor}"; + var reason = $"{ex.GetType().Name} {position}: {ex.Message}"; logger.LogError(ex, "Category {CategoryId} halted at cursor {Cursor}", category.Id, checkpoint.Cursor); return await Settle(checkpoint with { State = MigrationCategoryState.Halted, LastError = reason }, cancellationToken); } diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSource.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSource.cs index bf5d522856..66341cbf20 100644 --- a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSource.cs +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationSource.cs @@ -24,6 +24,9 @@ public sealed class InMemoryMigrationSource : IMigrationSource public void FailBodyReads(string sourceId, int times, Exception failure) => bodyFailures[sourceId] = (times, failure); + /// Makes the body read for this row behave like a host shutting down: the token is cancelled and the read throws. + public (string SourceId, CancellationTokenSource Source)? StopOnBodyRead { get; set; } + public int BodyReadAttempts(string sourceId) => bodyReadAttempts.GetValueOrDefault(sourceId); public Task Open(CancellationToken cancellationToken = default) => Task.CompletedTask; @@ -70,6 +73,13 @@ public async IAsyncEnumerable Read( await Task.Yield(); var attempt = bodyReadAttempts[sourceId] = BodyReadAttempts(sourceId) + 1; + + if (StopOnBodyRead is { } stop && stop.SourceId == sourceId) + { + await stop.Source.CancelAsync(); + throw new OperationCanceledException(stop.Source.Token); + } + if (bodyFailures.TryGetValue(sourceId, out var failures) && attempt <= failures.Times) { throw failures.Failure; diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs index 4e50306469..cdfaff9777 100644 --- a/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs +++ b/src/ServiceControl.UnitTests/Migration/Fakes/InMemoryMigrationTarget.cs @@ -16,6 +16,12 @@ public sealed class InMemoryMigrationTarget(IMigrationCheckpointStore checkpoint public int DefaultBatchSize { get; set; } = 3; public string NoBatchSizeFor { get; set; } public int? FailOnCallNumber { get; set; } + + /// What FailOnCallNumber throws, when the default simulated failure is the wrong shape for the test. + public Exception FailWith { get; set; } + + /// Cancels the token on this call and then writes normally, so the stop surfaces from the source's next batch. + public (int CallNumber, CancellationTokenSource Source)? CancelOnCall { get; set; } public (int CallNumber, CancellationTokenSource Source)? StopOnCall { get; set; } int callCount; @@ -45,7 +51,12 @@ public async Task Write( if (FailOnCallNumber == callCount) { - throw new InvalidOperationException($"Simulated failure on write {callCount}"); + throw FailWith ?? new InvalidOperationException($"Simulated failure on write {callCount}"); + } + + if (CancelOnCall is { } cancel && cancel.CallNumber == callCount) + { + await cancel.Source.CancelAsync(); } var keys = writtenKeysByCategory.TryGetValue(category.Id, out var existingKeys) ? existingKeys : writtenKeysByCategory[category.Id] = []; diff --git a/src/ServiceControl.UnitTests/Migration/Fakes/TimerRecordingTimeProvider.cs b/src/ServiceControl.UnitTests/Migration/Fakes/TimerRecordingTimeProvider.cs new file mode 100644 index 0000000000..6178cbde97 --- /dev/null +++ b/src/ServiceControl.UnitTests/Migration/Fakes/TimerRecordingTimeProvider.cs @@ -0,0 +1,35 @@ +#nullable enable +namespace ServiceControl.UnitTests.Migration.Fakes; + +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.Extensions.Time.Testing; + +// Signals each timer the engine creates, so a test only advances the clock once a pause is waiting on it. +public sealed class TimerRecordingTimeProvider : TimeProvider +{ + readonly FakeTimeProvider clock = new(); + + public SemaphoreSlim TimerCreated { get; } = new(0); + + public List DueTimes { get; } = []; + + public void Advance(TimeSpan delta) => clock.Advance(delta); + + public override DateTimeOffset GetUtcNow() => clock.GetUtcNow(); + + public override long GetTimestamp() => clock.GetTimestamp(); + + public override long TimestampFrequency => clock.TimestampFrequency; + + public override TimeZoneInfo LocalTimeZone => clock.LocalTimeZone; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = clock.CreateTimer(callback, state, dueTime, period); + DueTimes.Add(dueTime); + TimerCreated.Release(); + return timer; + } +} diff --git a/src/ServiceControl.UnitTests/Migration/HaltThresholdTests.cs b/src/ServiceControl.UnitTests/Migration/HaltThresholdTests.cs index f0b02dbdfb..fbe2bebe97 100644 --- a/src/ServiceControl.UnitTests/Migration/HaltThresholdTests.cs +++ b/src/ServiceControl.UnitTests/Migration/HaltThresholdTests.cs @@ -36,12 +36,40 @@ public void Does_not_halt_a_large_category_with_only_a_small_proportion_skipped( [Test] public void Exactly_at_both_boundaries_does_not_halt_because_both_must_be_exceeded() { - // 100 of 2,000 is exactly 5% and exactly the floor. "Exceed" means strictly past, not "at". + // Exactly the floor, which settles it before the proportion is ever worked out. "Exceed" means strictly past, not "at". var exceeded = HaltThreshold.Exceeded(skippedCount: 100, totalCount: 2_000, percentThreshold: 5, minimumFloor: 100); Assert.That(exceeded, Is.False); } + [Test] + public void Exactly_on_the_proportion_does_not_halt_once_the_floor_is_behind_it() + { + // 101 of 2,020 is exactly 5% with the floor already passed, so this is the only shape that + // reaches the proportion comparison and depends on it being strictly greater. + var exceeded = HaltThreshold.Exceeded(skippedCount: 101, totalCount: 2_020, percentThreshold: 5, minimumFloor: 100); + + Assert.That(exceeded, Is.False); + } + + [Test] + public void A_hair_past_the_proportion_halts_once_the_floor_is_behind_it() + { + // 101 of 2,000 is 5.05%: the same skip count as above, one row's worth over the line. + var exceeded = HaltThreshold.Exceeded(skippedCount: 101, totalCount: 2_000, percentThreshold: 5, minimumFloor: 100); + + Assert.That(exceeded, Is.True); + } + + [Test] + public void A_skip_count_with_nothing_processed_never_halts_and_never_divides_by_zero() + { + // Past the floor with a zero total, which is the only input that reaches the division guard. + var exceeded = HaltThreshold.Exceeded(skippedCount: 101, totalCount: 0, percentThreshold: 5, minimumFloor: 100); + + Assert.That(exceeded, Is.False); + } + [Test] public void No_rows_processed_yet_never_halts() { diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineBodyRetryTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineBodyRetryTests.cs index cfac8aa11a..dc938d6543 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineBodyRetryTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineBodyRetryTests.cs @@ -4,6 +4,7 @@ namespace ServiceControl.UnitTests.Migration; using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; @@ -43,13 +44,25 @@ public async Task A_body_that_fails_until_the_last_attempt_then_succeeds_is_writ } } - [Test] - public async Task A_body_read_that_fails_as_a_defect_halts_the_category_on_the_first_attempt_without_skipping_the_message() + // Every type the engine treats as a defect. Each fails the same way on every attempt, so retrying one + // would only turn a code fault into skipped messages. + static readonly Exception[] Defects = + [ + new NotSupportedException("this source cannot read bodies"), + new NotImplementedException("not written yet"), + new InvalidOperationException("the session is closed"), + new ArgumentException("the id is not a document id"), + new NullReferenceException("no attachment"), + new InvalidCastException("not an attachment") + ]; + + [TestCaseSource(nameof(Defects))] + public async Task A_body_read_that_fails_as_a_defect_halts_the_category_on_the_first_attempt_without_skipping_the_message(Exception defect) { var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; var source = new InMemoryMigrationSource(); source.Seed(category.Id, Row("msg-1")); - source.FailBodyReads("msg-1", MigrationEngine.MaxBodyReadAttempts, new NotSupportedException("this source cannot read bodies")); + source.FailBodyReads("msg-1", MigrationEngine.MaxBodyReadAttempts, defect); var checkpointStore = new InMemoryMigrationCheckpointStore(); var target = new InMemoryMigrationTarget(checkpointStore); var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = TimeSpan.Zero }; @@ -60,13 +73,78 @@ public async Task A_body_read_that_fails_as_a_defect_halts_the_category_on_the_f using (Assert.EnterMultipleScope()) { Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Halted)); - Assert.That(checkpoint.LastError, Does.Contain(nameof(NotSupportedException))); + Assert.That(checkpoint.LastError, Does.Contain(defect.GetType().Name)); Assert.That(source.BodyReadAttempts("msg-1"), Is.EqualTo(1)); Assert.That(checkpoint.SkippedCount, Is.Zero); Assert.That(checkpoint.SkipReasons, Is.Null); } } + [Test] + public async Task A_shutdown_during_a_body_read_stops_the_run_instead_of_skipping_the_message() + { + // Retrying a shutdown to the attempt limit and then recording the message as permanently + // unreadable is the one path here that silently loses a customer's failed message. + var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("msg-1")); + using var stopping = new CancellationTokenSource(); + source.StopOnBodyRead = ("msg-1", stopping); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = TimeSpan.Zero }; + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + Assert.ThrowsAsync(() => engine.RunCategoryAsync(category, stopping.Token)); + + var persisted = await checkpointStore.Read(category.Id); + using (Assert.EnterMultipleScope()) + { + Assert.That(source.BodyReadAttempts("msg-1"), Is.EqualTo(1), "a shutdown is not a transient body failure, so it is not retried"); + Assert.That(persisted!.State, Is.EqualTo(MigrationCategoryState.InProgress)); + Assert.That(persisted.SkippedCount, Is.Zero, "the message is still there to copy on the next run"); + Assert.That(target.WrittenRows(category.Id), Is.Empty); + } + } + + [Test] + public async Task The_configured_backoff_is_waited_between_body_read_attempts() + { + // Without the wait, three attempts against a body store that is briefly down all fail inside a + // millisecond and the message is skipped for an outage it would have survived. + var category = MigrationCategoryRegistry.Find("UnresolvedAndRetryIssuedFailedMessages")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("msg-1")); + var body = new MigrationBody(new byte[] { 1 }, "text/plain"); + source.SetBody("msg-1", body); + source.FailBodyReads("msg-1", MigrationEngine.MaxBodyReadAttempts - 1, new TimeoutException("body store unreachable")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var clock = new TimerRecordingTimeProvider(); + var backoff = TimeSpan.FromMilliseconds(200); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = backoff }; + var engine = new MigrationEngine(source, target, checkpointStore, clock, options, NullLogger.Instance); + + var runTask = engine.RunCategoryAsync(category); + + // Two failures, so a wait after each before the attempt that succeeds. + for (var waitNumber = 1; waitNumber <= MigrationEngine.MaxBodyReadAttempts - 1; waitNumber++) + { + Assert.That(await clock.TimerCreated.WaitAsync(TimeSpan.FromSeconds(5)), Is.True, $"backoff {waitNumber} never started"); + Assert.That(runTask.IsCompleted, Is.False, $"backoff {waitNumber} should still be pending"); + clock.Advance(backoff); + } + + var checkpoint = await runTask.WaitAsync(TimeSpan.FromSeconds(5)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); + Assert.That(clock.DueTimes, Is.EqualTo(new[] { backoff, backoff }), "no wait after the final attempt, which has nothing left to retry"); + Assert.That(target.WrittenRows(category.Id).Single().Body, Is.EqualTo(body)); + } + } + [Test] public async Task The_skip_warning_for_an_unreadable_body_carries_the_last_attempts_exception() { diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineCopyTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineCopyTests.cs index c1a663d1f9..aafb9d4935 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineCopyTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineCopyTests.cs @@ -40,6 +40,72 @@ public async Task Copies_every_row_and_finishes_Complete_when_nothing_was_skippe } } + [Test] + public async Task A_category_with_no_rows_finishes_Complete_without_a_cursor() + { + // The ordinary state of several required categories on a small instance: nothing to copy is a + // finished category, not a category that never ran. + var category = MigrationCategoryRegistry.Find("MessageRedirects")!; + var source = new InMemoryMigrationSource(); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Complete)); + Assert.That((checkpoint.CopiedCount, checkpoint.SkippedCount), Is.EqualTo((0L, 0L))); + Assert.That(checkpoint.Cursor, Is.Null); + Assert.That(checkpoint.SettledAt, Is.Not.Null); + } + } + + [Test] + public async Task The_moment_a_category_settles_comes_from_the_injected_clock() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var settledAt = new DateTimeOffset(2026, 3, 4, 5, 6, 7, TimeSpan.Zero); + var clock = new FakeTimeProvider(settledAt); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, clock, options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + Assert.That(checkpoint.SettledAt, Is.EqualTo(settledAt.UtcDateTime), "a wall-clock read here would drift from every other time the migration reports"); + } + + [Test] + public async Task A_category_already_CompleteWithErrors_is_left_alone_on_a_second_run() + { + // Finished with a few skips is finished. Re-reading it would copy the whole category again and + // count its skips a second time. + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var finishedWithSkips = new MigrationCheckpoint(category.Id, MigrationCategoryState.CompleteWithErrors, "b", 1, 1, 2, + new Dictionary { [MigrationSkipReason.BodyUnreadable] = 1 }, DateTime.UtcNow, DateTime.UtcNow, DateTime.UtcNow, null); + await checkpointStore.Upsert(finishedWithSkips); + var target = new InMemoryMigrationTarget(checkpointStore); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint, Is.EqualTo(finishedWithSkips with { Version = 1 }), "the row is read back untouched, at the version the seeding save left it"); + Assert.That(target.WrittenRows(category.Id), Is.Empty); + } + } + [Test] public async Task A_category_already_Complete_is_left_alone_on_a_second_run() { diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs index 18d109dd44..fb26628421 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineFailurePathTests.cs @@ -225,6 +225,142 @@ public void A_threshold_halt_whose_save_fails_still_logs_why_it_halted() Assert.That(logger.Entries.Where(e => e.Level == LogLevel.Error).Select(e => e.Message), Has.Some.Contains("Halted: 1 of 1 rows skipped")); } + [Test] + public async Task A_checkpoint_conflict_leaves_the_other_writer_alone_instead_of_halting_over_it() + { + // Two hosts pointed at one target is what the version token exists for. Settling this as halted + // would write over the progress of whichever host is still copying. + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b")); + // Save 1 moves the row to in progress; save 2 is the first batch, by which point the other host has moved it on. + var checkpointStore = new ConflictOnNthSaveCheckpointStore { ConflictOnSave = 2 }; + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2 }; + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []), NullLogger.Instance); + + Assert.ThrowsAsync(() => engine.RunCategoryAsync(category)); + + var persisted = await checkpointStore.Read(category.Id); + Assert.That(persisted!.State, Is.EqualTo(MigrationCategoryState.InProgress), "no halted row was written over the conflict"); + } + + [Test] + public async Task A_cancellation_that_is_not_a_shutdown_halts_the_category_like_any_other_failure() + { + // An inner timeout surfaces as the same exception type as a host stopping, and only the token + // says which. Treating a timeout as a shutdown would end the run with no reason on the row. + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) + { + FailOnCallNumber = 1, + FailWith = new OperationCanceledException("the query timed out") + }; + var engine = BuildEngine(source, checkpointStore, target); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Halted)); + Assert.That(checkpoint.LastError, Does.Contain("OperationCanceledException").And.Contain("the query timed out")); + } + } + + [Test] + public async Task The_halt_reason_names_the_cursor_the_copy_had_reached() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b"), Row("c"), Row("d")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2, FailOnCallNumber = 2 }; + var engine = BuildEngine(source, checkpointStore, target); + + var checkpoint = await engine.RunCategoryAsync(category); + + Assert.That(checkpoint.LastError, Does.Contain("at cursor b"), "the cursor is the only pointer an operator has to where it stopped"); + } + + [Test] + public async Task The_halt_reason_says_at_the_start_when_the_first_batch_never_committed() + { + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { FailOnCallNumber = 1 }; + var engine = BuildEngine(source, checkpointStore, target); + + var checkpoint = await engine.RunCategoryAsync(category); + + Assert.That(checkpoint.LastError, Does.Contain("at the start")); + } + + [Test] + public async Task Every_row_the_target_skips_is_named_in_the_log() + { + // The counts say how much was left behind. Only the log says which rows, and it is the way back + // to them while the RavenDB database still exists. + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + target.RejectKey("b", MigrationSkipReason.PastRetention); + var logger = new CapturingLogger(); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []), logger); + + await engine.RunCategoryAsync(category); + + Assert.That(logger.Entries.Select(entry => entry.Message), Has.Some.EqualTo("Skipped b in category KnownEndpoints")); + } + + [Test] + public async Task A_stop_between_batches_leaves_the_row_in_progress_at_the_batch_that_committed() + { + // The stop lands in the source rather than in a write, so nothing is mid-transaction: the row + // still has to describe the batches that did commit, and stay resumable. + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b"), Row("c"), Row("d")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + using var stopping = new CancellationTokenSource(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2, CancelOnCall = (1, stopping) }; + var engine = BuildEngine(source, checkpointStore, target); + + Assert.ThrowsAsync(() => engine.RunCategoryAsync(category, stopping.Token)); + + var persisted = await checkpointStore.Read(category.Id); + using (Assert.EnterMultipleScope()) + { + Assert.That(persisted!.State, Is.EqualTo(MigrationCategoryState.InProgress), "a shutdown is not a halt"); + Assert.That(persisted.Cursor, Is.EqualTo("b")); + Assert.That(persisted.CopiedCount, Is.EqualTo(2)); + Assert.That(persisted.SettledAt, Is.Null); + } + } + + // Another host moved the row on between this host reading it and saving it. + sealed class ConflictOnNthSaveCheckpointStore : IMigrationCheckpointStore + { + readonly InMemoryMigrationCheckpointStore saved = new(); + int saves; + + public int ConflictOnSave { get; init; } + + public Task> ReadAll(CancellationToken cancellationToken = default) => saved.ReadAll(cancellationToken); + + public Task Read(string categoryId, CancellationToken cancellationToken = default) => saved.Read(categoryId, cancellationToken); + + public Task Upsert(MigrationCheckpoint checkpoint, CancellationToken cancellationToken = default) => + ++saves == ConflictOnSave + ? throw new MigrationCheckpointConflictException($"Checkpoint {checkpoint.CategoryId} was saved from version {checkpoint.Version}, but the stored row has moved on.") + : saved.Upsert(checkpoint, cancellationToken); + } + // The store shares the target's database, which has become unreachable by the time the halt is saved. sealed class HaltSaveFailsCheckpointStore : IMigrationCheckpointStore { diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs index 52edc7954f..47a77f85d7 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineHaltTests.cs @@ -73,6 +73,67 @@ public async Task Rows_the_target_would_have_deleted_anyway_never_count_toward_t } } + [TestCase(25, MigrationCategoryState.CompleteWithErrors, TestName = "A_mix_of_benign_and_fault_skips_runs_on_while_the_faults_stay_under_the_threshold")] + [TestCase(10, MigrationCategoryState.Halted, TestName = "A_mix_of_benign_and_fault_skips_halts_once_the_faults_alone_pass_the_threshold")] + public async Task A_batch_mixing_benign_and_fault_skips_is_judged_on_the_faults_alone(int everyNthIsAFault, MigrationCategoryState expected) + { + // A real archive copy loses rows both ways at once: retention takes some, unreadable bodies take + // others. This is the only shape where the subtraction has to do arithmetic rather than pick a side. + var category = MigrationCategoryRegistry.Find("ArchivedAndResolvedFailedMessages")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, [.. Enumerable.Range(1, 5_000).Select(i => Row($"row-{i}"))]); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 100 }; + // A fifth of the category is past retention either way, which on its own is four times the threshold. + foreach (var i in Enumerable.Range(1, 5_000).Where(i => i % 5 == 0)) + { + target.RejectKey($"row-{i}", MigrationSkipReason.PastRetention, benign: true); + } + // The offset keeps the faults clear of the benign rows: 4% of the category in one case, 10% in the other. + foreach (var i in Enumerable.Range(1, 5_000).Where(i => i % everyNthIsAFault == 3)) + { + target.RejectKey($"row-{i}", MigrationSkipReason.BodyUnreadable); + } + var options = new MigrationEngineOptions(TimeSpan.Zero, HaltThresholdPercent: 5, HaltThresholdMinimum: 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + Assert.That(checkpoint.State, Is.EqualTo(expected)); + } + + [Test] + public async Task Rows_already_present_keep_a_category_under_the_halt_threshold() + { + // Already-present rows are in the denominator because the run did handle them. Drop them from it + // and this category's 4% fault rate reads as 12%, halting a copy that is merely being re-run. + var category = MigrationCategoryRegistry.Find("ArchivedAndResolvedFailedMessages")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, [.. Enumerable.Range(1, 3_000).Select(i => Row($"row-{i}"))]); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 100 }; + foreach (var i in Enumerable.Range(1, 2_000)) + { + target.SeedExistingKey($"row-{i}"); + } + // 120 faults spread through the whole category: past the 100-row floor, and 4% of 3,000. + foreach (var i in Enumerable.Range(1, 3_000).Where(i => i % 25 == 0)) + { + target.RejectKey($"row-{i}", MigrationSkipReason.BodyUnreadable); + } + var options = new MigrationEngineOptions(TimeSpan.Zero, HaltThresholdPercent: 5, HaltThresholdMinimum: 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.CompleteWithErrors)); + Assert.That(checkpoint.SkippedCount, Is.EqualTo(120)); + Assert.That(checkpoint.AlreadyPresentCount, Is.EqualTo(1_920), "the 80 already-present rows that are also faults are refused before the collision check"); + } + } + [Test] public async Task Rows_already_present_in_the_target_never_count_toward_the_halt_threshold() { diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs index 67c1e3b53b..a7f2c99e25 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineOrderingTests.cs @@ -87,6 +87,72 @@ public async Task GroupComments_does_not_start_before_the_archive_completes(Migr } } + [Test] + public async Task A_blocked_category_runs_once_the_category_it_follows_settles() + { + // Every real migration starts group comments blocked, so a block nothing can clear would strand + // the last category and leave the migration unable to end. + var comments = MigrationCategoryRegistry.Find("GroupComments")!; + var archive = MigrationCategoryRegistry.Find("ArchivedAndResolvedFailedMessages")!; + var source = new InMemoryMigrationSource(); + source.Seed(comments.Id, Row("GroupComment/g-1")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var archiveRunning = new MigrationCheckpoint(archive.Id, MigrationCategoryState.InProgress, "m-500", 500, 0, null, null, DateTime.UtcNow, DateTime.UtcNow, null, null); + var saved = await checkpointStore.Upsert(archiveRunning); + var target = new InMemoryMigrationTarget(checkpointStore); + + var blocked = await BuildEngine(source, checkpointStore, target).RunCategoryAsync(comments); + + await checkpointStore.Upsert(saved with { State = MigrationCategoryState.Complete, SettledAt = DateTime.UtcNow }); + var unblocked = await BuildEngine(source, checkpointStore, target).RunCategoryAsync(comments); + + using (Assert.EnterMultipleScope()) + { + Assert.That(blocked.State, Is.EqualTo(MigrationCategoryState.Blocked)); + Assert.That(unblocked.State, Is.EqualTo(MigrationCategoryState.Complete)); + Assert.That(unblocked.LastError, Is.Null, "the block reason does not outlive the block"); + Assert.That(target.WrittenRows(comments.Id), Has.Count.EqualTo(1)); + } + } + + [Test] + public async Task A_blocked_category_has_not_settled_because_it_has_not_finished() + { + // Anything reading SettledAt beside State would otherwise take a blocked category for a finished one. + var comments = MigrationCategoryRegistry.Find("GroupComments")!; + var source = new InMemoryMigrationSource(); + source.Seed(comments.Id, Row("GroupComment/g-1")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + + await BuildEngine(source, checkpointStore, target).RunCategoryAsync(comments); + + var persisted = await checkpointStore.Read(comments.Id); + Assert.That(persisted!.SettledAt, Is.Null); + } + + [TestCase(MigrationCategoryState.NotStarted)] + [TestCase(MigrationCategoryState.Blocked)] + public async Task A_predecessor_that_has_a_row_but_has_not_run_is_named_by_the_state_on_that_row(MigrationCategoryState archiveState) + { + // A missing row reads as "not started"; a row that exists says what it actually holds, which is + // how an operator tells a category waiting its turn from one waiting on a chain. + var comments = MigrationCategoryRegistry.Find("GroupComments")!; + var source = new InMemoryMigrationSource(); + source.Seed(comments.Id, Row("GroupComment/g-1")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + await checkpointStore.Upsert(new MigrationCheckpoint("ArchivedAndResolvedFailedMessages", archiveState, null, 0, 0, null, null, null, null, null, null)); + var target = new InMemoryMigrationTarget(checkpointStore); + + var checkpoint = await BuildEngine(source, checkpointStore, target).RunCategoryAsync(comments); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.Blocked)); + Assert.That(checkpoint.LastError, Is.EqualTo($"Blocked: GroupComments must follow ArchivedAndResolvedFailedMessages, which is {archiveState}")); + } + } + [TestCase(MigrationCategoryState.Complete)] [TestCase(MigrationCategoryState.CompleteWithErrors)] [TestCase(MigrationCategoryState.Abandoned)] diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs index ff7246d3bd..d21760171f 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineResumeTests.cs @@ -17,6 +17,36 @@ class MigrationEngineResumeTests { static MigrationRow Row(string id) => new(id, new object(), new Dictionary()); + [Test] + public async Task A_restart_keeps_the_moment_the_category_first_started() + { + // StartedAt is what an operator reads to see how long a background copy has been going. Stamping + // it again on every restart would report minutes for a copy that has been running for days. + var category = MigrationCategoryRegistry.Find("KnownEndpoints")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b"), Row("c"), Row("d")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 2, FailOnCallNumber = 2 }; + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []); + var firstStart = new DateTimeOffset(2026, 3, 1, 9, 0, 0, TimeSpan.Zero); + var halted = await new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(firstStart), options, NullLogger.Instance) + .RunCategoryAsync(category); + + // A day later, the cause is fixed and the host is started again. + target.FailOnCallNumber = null; + var restart = firstStart.AddDays(1); + var finished = await new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(restart), options, NullLogger.Instance) + .RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(halted.StartedAt, Is.EqualTo(firstStart.UtcDateTime)); + Assert.That(finished.State, Is.EqualTo(MigrationCategoryState.Complete)); + Assert.That(finished.StartedAt, Is.EqualTo(firstStart.UtcDateTime), "the restart carries on a copy that started a day ago"); + Assert.That(finished.SettledAt, Is.EqualTo(restart.UtcDateTime)); + } + } + [Test] public async Task Restarting_after_a_mid_category_stop_produces_no_duplicates_and_no_gaps() { diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineRunCategoriesTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineRunCategoriesTests.cs index 691f0a6f0b..e4e8393de0 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineRunCategoriesTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineRunCategoriesTests.cs @@ -40,6 +40,24 @@ public async Task Runs_every_category_it_is_given_in_the_order_it_is_given_them( } } + [Test] + public async Task Running_no_categories_copies_nothing_and_reports_nothing() + { + // What an instance that selected no optional categories asks for on every background pass. + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore); + var engine = new MigrationEngine(new InMemoryMigrationSource(), target, checkpointStore, new FakeTimeProvider(), + new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []), NullLogger.Instance); + + var results = await engine.RunCategories([]); + + using (Assert.EnterMultipleScope()) + { + Assert.That(results, Is.Empty); + Assert.That(await checkpointStore.ReadAll(), Is.Empty, "a category nobody ran gets no row"); + } + } + [Test] public async Task One_category_halting_does_not_stop_the_ones_after_it() { diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs index d8c5202d27..2e07501603 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineSkipReasonTests.cs @@ -3,6 +3,7 @@ namespace ServiceControl.UnitTests.Migration; using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; @@ -58,6 +59,37 @@ public async Task A_message_whose_body_is_never_read_is_counted_under_BodyUnread Assert.That(checkpoint.SkipReasons, Is.EquivalentTo(new Dictionary { [MigrationSkipReason.BodyUnreadable] = 1 })); } + [Test] + public async Task A_batch_that_loses_rows_two_different_ways_records_both_reasons() + { + // The breakdown is what tells a customer what they lost and why. A merge that overwrote instead of + // summing would leave the total right and the reasons wrong, and verification would still balance. + var category = MigrationCategoryRegistry.Find(MigrationCategoryIds.UnresolvedAndRetryIssuedFailedMessages)!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("msg-1"), Row("msg-2"), Row("msg-3")); + // One the engine skips itself because the body will not read, one the target refuses. + source.FailBodyReads("msg-1", MigrationEngine.MaxBodyReadAttempts, new TimeoutException("body store unreachable")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 3 }; + target.RejectKey("msg-2", MigrationSkipReason.PastRetention, benign: true); + var options = new MigrationEngineOptions(TimeSpan.Zero, 5, 100, []) { BodyRetryBackoff = TimeSpan.Zero }; + var engine = new MigrationEngine(source, target, checkpointStore, new FakeTimeProvider(), options, NullLogger.Instance); + + var checkpoint = await engine.RunCategoryAsync(category); + + using (Assert.EnterMultipleScope()) + { + Assert.That(checkpoint.State, Is.EqualTo(MigrationCategoryState.CompleteWithErrors)); + Assert.That(checkpoint.SkippedCount, Is.EqualTo(2)); + Assert.That(checkpoint.SkipReasons, Is.EquivalentTo(new Dictionary + { + [MigrationSkipReason.BodyUnreadable] = 1, + [MigrationSkipReason.PastRetention] = 1 + })); + Assert.That(target.WrittenRows(category.Id).Select(row => row.SourceId), Is.EqualTo(new[] { "msg-3" })); + } + } + [Test] public async Task A_target_whose_skip_reasons_do_not_add_up_to_its_skips_halts_the_category() { diff --git a/src/ServiceControl.UnitTests/Migration/MigrationEngineThrottleTests.cs b/src/ServiceControl.UnitTests/Migration/MigrationEngineThrottleTests.cs index c4665da55f..e568edb6a1 100644 --- a/src/ServiceControl.UnitTests/Migration/MigrationEngineThrottleTests.cs +++ b/src/ServiceControl.UnitTests/Migration/MigrationEngineThrottleTests.cs @@ -48,6 +48,35 @@ public async Task Optional_categories_pause_between_batches_for_the_configured_d } } + [Test] + public async Task A_stop_during_a_pause_ends_the_run_as_a_shutdown() + { + // Most of a throttled background copy's life is spent in this pause, so it is where a host being + // stopped most often lands. + var category = MigrationCategoryRegistry.Find("EventLog")!; + var source = new InMemoryMigrationSource(); + source.Seed(category.Id, Row("a"), Row("b"), Row("c")); + var checkpointStore = new InMemoryMigrationCheckpointStore(); + var target = new InMemoryMigrationTarget(checkpointStore) { DefaultBatchSize = 1 }; + var clock = new TimerRecordingTimeProvider(); + using var stopping = new CancellationTokenSource(); + var options = new MigrationEngineOptions(TimeSpan.FromSeconds(1), 5, 100, []); + var engine = new MigrationEngine(source, target, checkpointStore, clock, options, NullLogger.Instance); + + var runTask = engine.RunCategoryAsync(category, stopping.Token); + Assert.That(await clock.TimerCreated.WaitAsync(TimeSpan.FromSeconds(5)), Is.True, "the first pause never started"); + await stopping.CancelAsync(); + + Assert.ThrowsAsync(() => runTask); + + var persisted = await checkpointStore.Read(category.Id); + using (Assert.EnterMultipleScope()) + { + Assert.That(persisted!.State, Is.EqualTo(MigrationCategoryState.InProgress), "a shutdown mid-pause is not a halt"); + Assert.That(persisted.Cursor, Is.EqualTo("a"), "the batch before the pause committed"); + } + } + [Test] public async Task Required_categories_never_pause() { @@ -65,32 +94,4 @@ public async Task Required_categories_never_pause() Assert.That(checkpoint.CopiedCount, Is.EqualTo(3)); } - - // Signals each timer the engine creates, so the test only advances the clock once a pause is waiting on it. - sealed class TimerRecordingTimeProvider : TimeProvider - { - readonly FakeTimeProvider clock = new(); - - public SemaphoreSlim TimerCreated { get; } = new(0); - - public List DueTimes { get; } = []; - - public void Advance(TimeSpan delta) => clock.Advance(delta); - - public override DateTimeOffset GetUtcNow() => clock.GetUtcNow(); - - public override long GetTimestamp() => clock.GetTimestamp(); - - public override long TimestampFrequency => clock.TimestampFrequency; - - public override TimeZoneInfo LocalTimeZone => clock.LocalTimeZone; - - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) - { - var timer = clock.CreateTimer(callback, state, dueTime, period); - DueTimes.Add(dueTime); - TimerCreated.Release(); - return timer; - } - } } From 94c8cab597940d1677fb479edc378027fee2d747 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 30 Sep 2026 11:20:28 +0800 Subject: [PATCH 12/15] Refactor migration settings and update documentation to remove RavenDB references --- .../ravendb-to-sql-migration-instructions.md | 2 -- .../TwoPersistersInOneProcessTests.cs | 17 ----------------- .../DataMigration/MigrationSettings.cs | 4 ---- ...pprovals.PlatformSampleSettings.approved.txt | 1 - .../Commands/MigrationSourceReportCommand.cs | 2 +- src/ServiceControl/Hosting/Help.txt | 5 ++--- .../Infrastructure/Settings/Settings.cs | 2 -- .../Persistence/PersistenceFactory.cs | 13 ++++++------- 8 files changed, 9 insertions(+), 37 deletions(-) diff --git a/docs/migration/ravendb-to-sql-migration-instructions.md b/docs/migration/ravendb-to-sql-migration-instructions.md index 7b3fc8c7ae..91b421c143 100644 --- a/docs/migration/ravendb-to-sql-migration-instructions.md +++ b/docs/migration/ravendb-to-sql-migration-instructions.md @@ -18,8 +18,6 @@ The source is a ServiceControl error instance on RavenDB. Keep its RavenDB setti | `ServiceControl/RavenDB/ClientCertificatePath` or `ServiceControl/RavenDB/ClientCertificateBase64`, with `ServiceControl/RavenDB/ClientCertificatePassword` | `SERVICECONTROL_RAVENDB_CLIENTCERTIFICATEPATH` and so on | A secured external server's client certificate | | `ServiceControl/ErrorRetentionPeriod` | `SERVICECONTROL_ERRORRETENTIONPERIOD` | Required. Don't change it during the move | -`ServiceControl/Migration/SourcePersistenceType` defaults to `RavenDB` and needs no setting. - ## Report on the source Run the instance's executable with `--migration-source-report`: diff --git a/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs b/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs index ba677f8b42..b246735641 100644 --- a/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs +++ b/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs @@ -84,23 +84,6 @@ public async Task A_source_and_a_target_load_side_by_side_and_share_one_type_ide "The source read its own RavenDB settings rather than the target's."); } - [Test] - public void Asking_a_SQL_persister_for_a_source_names_the_setting_to_change() - { - Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_SOURCEPERSISTENCETYPE", "SQLServer"); - - try - { - var refusal = Assert.ThrowsAsync(async () => await PersistenceFactory.OpenMigrationSource(new Settings(persisterType: "SQLServer", forwardErrorMessages: false, errorRetentionPeriod: TimeSpan.FromDays(10)))); - - Assert.That(refusal.Message, Does.Contain("cannot be read as a migration source").And.Contain("ServiceControl/Migration/SourcePersistenceType")); - } - finally - { - Environment.SetEnvironmentVariable("SERVICECONTROL_MIGRATION_SOURCEPERSISTENCETYPE", null); - } - } - [Test] public void Asking_a_SQL_persister_for_maintenance_mode_is_still_refused() { diff --git a/src/ServiceControl.Persistence/DataMigration/MigrationSettings.cs b/src/ServiceControl.Persistence/DataMigration/MigrationSettings.cs index 2b4faf5c91..0de8ddb892 100644 --- a/src/ServiceControl.Persistence/DataMigration/MigrationSettings.cs +++ b/src/ServiceControl.Persistence/DataMigration/MigrationSettings.cs @@ -8,12 +8,8 @@ public static class MigrationSettings public const string HaltThresholdMinimumKey = "Migration/HaltThresholdMinimum"; /// A comma-separated list of the optional categories to copy, such as "EventLog, CustomChecks". public const string OptionalCategoriesKey = "Migration/OptionalCategories"; - /// Which persister holds the old data being copied from. - public const string SourcePersistenceTypeKey = "Migration/SourcePersistenceType"; public const int DefaultThrottlePauseMilliseconds = 100; public const int DefaultHaltThresholdPercent = 5; public const int DefaultHaltThresholdMinimum = 100; - /// RavenDB is the only source supported today. - public const string DefaultSourcePersistenceType = "RavenDB"; } diff --git a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt index f73200052b..abd4c98313 100644 --- a/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt +++ b/src/ServiceControl.UnitTests/ApprovalFiles/APIApprovals.PlatformSampleSettings.approved.txt @@ -63,7 +63,6 @@ "VirtualDirectory": "", "HeartbeatGracePeriod": "00:00:40", "TransportType": "ServiceControl.Transports.Learning.LearningTransportCustomization, ServiceControl.Transports.Learning", - "MigrationSourcePersistenceType": "RavenDB", "ErrorLogQueue": "error.log", "ErrorQueue": "error", "ForwardErrorMessages": false, diff --git a/src/ServiceControl/Hosting/Commands/MigrationSourceReportCommand.cs b/src/ServiceControl/Hosting/Commands/MigrationSourceReportCommand.cs index ce0ac0501b..21446e6c5e 100644 --- a/src/ServiceControl/Hosting/Commands/MigrationSourceReportCommand.cs +++ b/src/ServiceControl/Hosting/Commands/MigrationSourceReportCommand.cs @@ -18,7 +18,7 @@ public override async Task Execute(HostArguments args, Settings settings, Cancel Console.Out.WriteLine("ServiceControl migration source report"); Console.Out.WriteLine(); - Console.Out.WriteLine($"{"Source persistence",-20}: {settings.MigrationSourcePersistenceType}"); + Console.Out.WriteLine($"{"Source persistence",-20}: {PersistenceFactory.MigrationSourcePersistenceType}"); Console.Out.WriteLine($"{"Version",-20}: {description.Version}"); foreach (var fact in description.Facts) diff --git a/src/ServiceControl/Hosting/Help.txt b/src/ServiceControl/Hosting/Help.txt index 3c3d0d5774..6fba5341f9 100644 --- a/src/ServiceControl/Hosting/Help.txt +++ b/src/ServiceControl/Hosting/Help.txt @@ -27,9 +27,8 @@ MIGRATION SOURCE REPORT ServiceControl.exe --migration-source-report -Reports what a migration would read from the source persistence named by -ServiceControl/Migration/SourcePersistenceType: the facts the source reports about itself, with the -setting each came from, and a row count for everything it holds. The source reads the instance's own +Reports what a migration would read from the RavenDB source: the facts the source reports about +itself, with the setting each came from, and a row count for everything it holds. The source reads the instance's own RavenDB settings, so keep them in place when switching PersistenceType. For a RavenDB source: an EXTERNAL server can be reported on while ServiceControl is running. An EMBEDDED source diff --git a/src/ServiceControl/Infrastructure/Settings/Settings.cs b/src/ServiceControl/Infrastructure/Settings/Settings.cs index 9b39e03426..af52f4bf88 100644 --- a/src/ServiceControl/Infrastructure/Settings/Settings.cs +++ b/src/ServiceControl/Infrastructure/Settings/Settings.cs @@ -16,7 +16,6 @@ using ServiceControl.Infrastructure.Settings; using ServiceControl.Infrastructure.WebApi; using ServiceControl.Persistence; - using ServiceControl.Persistence.DataMigration; using ServiceControl.Transports; using ServicePulse; using JsonSerializer = System.Text.Json.JsonSerializer; @@ -186,7 +185,6 @@ public string InstanceId public string TransportType { get; set; } public string PersistenceType { get; private set; } - public string MigrationSourcePersistenceType => SettingsReader.Read(SettingsRootNamespace, MigrationSettings.SourcePersistenceTypeKey, MigrationSettings.DefaultSourcePersistenceType); public string ErrorLogQueue { get; set; } public string ErrorQueue { get; set; } diff --git a/src/ServiceControl/Persistence/PersistenceFactory.cs b/src/ServiceControl/Persistence/PersistenceFactory.cs index 5aac4cdda8..c431680696 100644 --- a/src/ServiceControl/Persistence/PersistenceFactory.cs +++ b/src/ServiceControl/Persistence/PersistenceFactory.cs @@ -28,15 +28,14 @@ public static IPersistence Create(Settings settings, bool maintenanceMode = fals return persistence; } + /// + /// The persistence a migration copies from. RavenDB is the only source supported today. + /// + public const string MigrationSourcePersistenceType = "RavenDB"; + public static IMigrationSource CreateMigrationSource(Settings settings) { - var persistenceType = settings.MigrationSourcePersistenceType; - var persistenceConfiguration = CreatePersistenceConfiguration(persistenceType, settings); - - if (persistenceConfiguration is not IMigrationSourceFactory sourceFactory) - { - throw new Exception($"The '{persistenceType}' persistence cannot be read as a migration source. Set {Settings.SettingsRootNamespace}/{MigrationSettings.SourcePersistenceTypeKey} to the persistence that holds the data being migrated away from."); - } + var sourceFactory = (IMigrationSourceFactory)CreatePersistenceConfiguration(MigrationSourcePersistenceType, settings); return sourceFactory.CreateSource(Settings.SettingsRootNamespace); } From 84822527a2ec1f868cc491231d01832e3d47a76b Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 30 Sep 2026 11:48:35 +0800 Subject: [PATCH 13/15] Clarify event log handling and data retention during migration from RavenDB to SQL --- docs/migration/ravendb-to-sql-migration-overview.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/migration/ravendb-to-sql-migration-overview.md b/docs/migration/ravendb-to-sql-migration-overview.md index de2c322b14..663f79ae0f 100644 --- a/docs/migration/ravendb-to-sql-migration-overview.md +++ b/docs/migration/ravendb-to-sql-migration-overview.md @@ -160,11 +160,11 @@ flowchart TB - Licensing report masks - The uploaded licensed endpoint details file, which nothing recomputes: skipping it means the customer re-downloads it from the licence portal and uploads it again - Subscriptions +- The last 7 days of the event log, so the ServicePulse activity feed shows what led up to the failures you are about to act on the moment ServiceControl opens. The 7 days count back from the newest event in RavenDB rather than from when the copy runs, so a restart copies the same window. Older events are not copied and age out of RavenDB on their own: at the default 14-day event retention that is at most another 7 days of history ### Optional - Archived and resolved failed messages: the biggest category by far, and most of the copying time -- The event log, without which the ServicePulse activity feed starts empty - Custom checks, which cost almost nothing to skip because every check re-reports on its next interval - Failed error imports, the record of errors that could not be ingested - Group comments, **copied last of everything**, after archived and resolved messages. A comment survives only once the failed messages its group is built from have arrived, so on a large archive the comments are the last thing to appear. An empty comment field partway through a migration is the copy still running, not data loss @@ -181,7 +181,7 @@ flowchart TB ## What does not come across -**Whole categories are never copied.** Which ones, and why nothing needs them, is the [not migrated](#not-migrated) list above. Anything in an optional category you did not select is also never copied, and nothing later goes back for it. +**Whole categories are never copied.** Which ones, and why nothing needs them, is the [not migrated](#not-migrated) list above. Anything in an optional category you did not select is also never copied, and nothing later goes back for it. Neither is an event log item raised more than 7 days before the newest one, which falls outside the [required](#required) event log window rather than being skipped. **Rows skipped one at a time, and counted.** Each of these shows up in the skipped count for its category, broken out by reason, so you can see how much went and why: @@ -201,7 +201,7 @@ flowchart TB - **Endpoint settings for two endpoint names that differ only in case merge onto one row on SQL Server**, because SQL Server's default collation compares names without case, so one of the two settings is kept. PostgreSQL keeps both, and so does a SQL Server database created with a case-sensitive collation. The dry run counts this one too, by asking SQL Server how the name column compares, though for unusual characters its count can differ from what the copy does. - **Event log items and historic retry operations are renumbered.** Their keys are database identities and nothing references them, so this is safe, but the old numbers do not survive. -**Rows RavenDB deletes while the copy is running are an absence, not a skip.** Expiration only deletes a document carrying `@expires`, and only two kinds ever get one: a resolved or archived failed message, and an event log item (`ExpirationManager.cs:34,41`). Everything in the [required](#required) set is therefore safe, since unresolved and retry-issued messages have their expiry removed when the retry is issued, so only the archived and resolved messages category and the event log category can shrink underneath the copier, and both copy in the background where the window is longest. A document the sweep removes before the stream reaches it is never read, so it is counted nowhere: the counts are of rows the source actually handed over, and there is no expected total to fall short of. It is the same population as the retention skip above, and which of the two it becomes is a race with the sweep. The consequence to know is that the dry run's count is a snapshot rather than a promise, and for those two categories the difference between it and the final copied count is not attributed to anything. +**Rows RavenDB deletes while the copy is running are an absence, not a skip.** Expiration only deletes a document carrying `@expires`, and only two kinds ever get one: a resolved or archived failed message, and an event log item (`ExpirationManager.cs:34,41`). Unresolved and retry-issued messages have their expiry removed when the retry is issued, so only the archived and resolved messages category and the event log can shrink underneath the copier. Archived and resolved messages copy in the background, where the window is longest. The event log's 7 days copy while ServiceControl is closed, and at the default 14-day event retention even the oldest of them is a week from expiring on a source that stopped recently, so the sweep reaches the window only on a source left stopped for days before the move. A document the sweep removes before the stream reaches it is never read, so it is counted nowhere: the counts are of rows the source actually handed over, and there is no expected total to fall short of. It is the same population as the retention skip above, and which of the two it becomes is a race with the sweep. The consequence to know is that the dry run's count is a snapshot rather than a promise, and for those two categories the difference between it and the final copied count is not attributed to anything. **A category can finish with a small amount of loss and still count as complete.** A few skipped rows in a large table leave the category in a *complete with errors* state, which blocks nothing. Its skipped count is printed and the ids of the skipped rows are written to the log, so while the RavenDB database still exists you can go and look at exactly what did not make it. From bbdbcb7840925c6a47c4a17eae38ca2dbc678080 Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 30 Sep 2026 14:18:59 +0800 Subject: [PATCH 14/15] Update migration documentation to clarify integration events and error imports handling --- .../ravendb-to-sql-migration-overview.md | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/migration/ravendb-to-sql-migration-overview.md b/docs/migration/ravendb-to-sql-migration-overview.md index 663f79ae0f..6af075e895 100644 --- a/docs/migration/ravendb-to-sql-migration-overview.md +++ b/docs/migration/ravendb-to-sql-migration-overview.md @@ -19,7 +19,7 @@ This covers the error instance only. The audit instance has no SQL persister, so - **No writes through the client**. The copier never changes the source, but RavenDB's own expiration does: the primary database already has it configured, and the sweep keeps deleting failed messages and event log items throughout the migration and for as long afterwards as the instance is left running. The old database is a fallback that degrades from the moment you start. - **Abandonable up to a known point, and only up to that point**. While ServiceControl is closed the copy can be thrown away at no cost, because nothing but the copier has written to SQL and the migration has written nothing to RavenDB: see [the one point you can go back](#the-one-point-you-can-go-back). Once the host opens there is no way back at all. - **No duplicates and no gaps**. Rows and the resume cursor commit in one transaction, so a crash needs no reconciliation. -- **Every identifier anything depends on is carried across**. The event log and historic retry operations are renumbered, because nothing references their keys. +- **Every identifier anything depends on is carried across**. The event log, historic retry operations and pending integration events are renumbered, because nothing references their keys. - **Refuse rather than half-migrate**. Every check runs before the first row moves, and a failure is a host that will not start. - **No silent loss**. A migration cannot end with a selected category still in progress or halted, only with each one finished or explicitly abandoned. Abandoning is a deliberate choice, and an abandoned category lets the host open. Skipped rows are counted and reported. - **Bounded impact on a live instance**. Throttled behind normal ingestion and streamed, so memory does not track the size of the database. @@ -160,24 +160,25 @@ flowchart TB - Licensing report masks - The uploaded licensed endpoint details file, which nothing recomputes: skipping it means the customer re-downloads it from the licence portal and uploads it again - Subscriptions -- The last 7 days of the event log, so the ServicePulse activity feed shows what led up to the failures you are about to act on the moment ServiceControl opens. The 7 days count back from the newest event in RavenDB rather than from when the copy runs, so a restart copies the same window. Older events are not copied and age out of RavenDB on their own: at the default 14-day event retention that is at most another 7 days of history +- The last 7 days of the event log, so the ServicePulse activity feed shows what led up to the failures you are about to act on the moment ServiceControl opens. The 7 days count back from the newest event in RavenDB rather than from when the copy runs, so a restart copies the same window. That newest time is capped at when RavenDB last stored an event, because event times come from the endpoints and an endpoint whose clock runs ahead would otherwise push the window forward. Older events are not copied and age out of RavenDB on their own: at the default 14-day event retention that is at most another 7 days of history +- Integration events still waiting to be sent when you switch over. There are only any if the old instance was falling behind or could not reach the broker, and each one is an event a subscriber has not yet received. They are sent once ServiceControl opens, later than they would have been. RavenDB already sends them in no particular order, so no ordering is lost +- Custom checks, with the status each last reported. They are required because not every check reports again: an endpoint that is down never does, and a check with no repeat interval reports only when its endpoint starts. Leaving one behind could hide a known failure until that endpoint restarts +- Failed error imports, with their bodies. Each is a failed message ServiceControl took off the error queue but could not ingest, so it exists nowhere else, and it never expires. After the move, the "Error Message Ingestion" custom check keeps flagging them and `--import-failed-errors` imports them into SQL. Importing them on RavenDB before you start is better still, and the [dry run](#dry-run) tells you how many there are +- Group comments, copied straight after the unresolved failed messages, so a note such as "do not retry this group" is there the moment ServiceControl opens. Every comment except a blank one is copied. A comment on a group whose messages are all archived or resolved waits while those messages copy, and once the migration settles ServiceControl's own clean-up removes any comment whose group has no failed messages left, exactly as it always does on SQL ### Optional - Archived and resolved failed messages: the biggest category by far, and most of the copying time -- Custom checks, which cost almost nothing to skip because every check re-reports on its next interval -- Failed error imports, the record of errors that could not be ingested -- Group comments, **copied last of everything**, after archived and resolved messages. A comment survives only once the failed messages its group is built from have arrived, so on a large archive the comments are the last thing to appear. An empty comment field partway through a migration is the copy still running, not data loss -- Failed message edits ### Not migrated - The RavenDB index definitions - The transient in-flight collections, which are empty when nothing is running: `RetryBatches`, `RetryBatchNowForwardings`, `FailedMessageRetries`, `ArchiveOperations` and `UnarchiveOperations` - `ArchiveBatches` and `UnarchiveBatches`, which exist only because of how RavenDB works -- `ConnectedApplications`, which only versions 6.0 and 6.1 wrote and nothing has read since -- Integration events still waiting to be sent when you switch over are never sent +- The `ConnectedApplications` document, which only versions 6.0 and 6.1 wrote. Since 6.2 the MassTransit connector status that ServicePulse uses to turn features on and off comes from the connector's own heartbeat. ServiceControl holds that in memory and refills it when the connector next reports, so nothing reads the document - Broker and audit service version details, which refill on the throughput collector's next run +- Failed message edit locks, which stop one failed message being edited twice. An edited message is resolved, so the lock only matters if the message fails again afterwards: on RavenDB it can then never be edited again, and after the move it can be edited once more +- Heartbeat state, which neither persister stores: ServiceControl rebuilds it in memory from live heartbeats after every restart. The list of known endpoints and which ones are monitored is copied, so after the move heartbeat monitoring behaves exactly as it does after any restart. An endpoint instance that is down sends no heartbeat, so it is counted as failing on the dashboard with no last heartbeat time, and no heartbeat alert is raised for it ## What does not come across @@ -190,7 +191,6 @@ flowchart TB - A failed message whose body cannot be read after three attempts. **The whole message is skipped, not just its body**, because a message with no body is worse than no message. - A subscription whose message type or transport address exceeds 200 characters. The target key columns are capped at 200 characters, so it cannot be stored at all. - An archived or resolved failed message, or an event log item, already past its retention period. SQL's retention clean-up would delete it on its first pass, so it is counted rather than copied only to be deleted. -- A group comment whose failure group has no failed messages in SQL once the messages are copied. SQL's clean-up removes such a comment, where RavenDB never expired one. - Endpoint settings for an endpoint ServiceControl does not know. ServiceControl removes those settings shortly after it starts. - A row missing a value SQL requires, such as a known endpoint with no name or host, or a failed message with no failing endpoint address. An empty group comment is left behind the same way, because ServiceControl never stores one. @@ -199,9 +199,9 @@ flowchart TB - **Processing attempt history collapses to the newest attempt.** The SQL model has no attempts table. This affects every failed message that failed more than once, in the one category every customer copies. A message that failed five times arrives showing one attempt, and the other four are gone. - **Subscriptions that differ only in message-type version merge onto one row**, because the target key carries the type name without the version. - **Endpoint settings for two endpoint names that differ only in case merge onto one row on SQL Server**, because SQL Server's default collation compares names without case, so one of the two settings is kept. PostgreSQL keeps both, and so does a SQL Server database created with a case-sensitive collation. The dry run counts this one too, by asking SQL Server how the name column compares, though for unusual characters its count can differ from what the copy does. -- **Event log items and historic retry operations are renumbered.** Their keys are database identities and nothing references them, so this is safe, but the old numbers do not survive. +- **Event log items, historic retry operations and pending integration events are renumbered.** Their keys are database identities and nothing references them, so this is safe, but the old numbers do not survive. -**Rows RavenDB deletes while the copy is running are an absence, not a skip.** Expiration only deletes a document carrying `@expires`, and only two kinds ever get one: a resolved or archived failed message, and an event log item (`ExpirationManager.cs:34,41`). Unresolved and retry-issued messages have their expiry removed when the retry is issued, so only the archived and resolved messages category and the event log can shrink underneath the copier. Archived and resolved messages copy in the background, where the window is longest. The event log's 7 days copy while ServiceControl is closed, and at the default 14-day event retention even the oldest of them is a week from expiring on a source that stopped recently, so the sweep reaches the window only on a source left stopped for days before the move. A document the sweep removes before the stream reaches it is never read, so it is counted nowhere: the counts are of rows the source actually handed over, and there is no expected total to fall short of. It is the same population as the retention skip above, and which of the two it becomes is a race with the sweep. The consequence to know is that the dry run's count is a snapshot rather than a promise, and for those two categories the difference between it and the final copied count is not attributed to anything. +**Rows RavenDB deletes while the copy is running are an absence, not a skip.** Expiration only deletes a document carrying `@expires`, and only two kinds ever get one: a resolved or archived failed message, and an event log item (`ExpirationManager.cs:34,41`). A failed message loses its expiry whenever it becomes unresolved or retry-issued again: when it fails again, when it is unarchived, or when a retry is issued. The exception is a database last written by version 6.18 or earlier, where a message that failed again after being archived or resolved kept its old expiry, so a few unresolved messages there can still expire during the copy. Apart from those, only the archived and resolved messages category and the event log can shrink underneath the copier. Archived and resolved messages copy in the background, where the window is longest. The event log's 7 days copy while ServiceControl is closed, and at the default 14-day event retention even the oldest of them is a week from expiring on a source that stopped recently, so the sweep reaches the window only on a source left stopped for days before the move. A document the sweep removes before the stream reaches it is never read, so it is counted nowhere. The copier counts each category before it starts, and if the copy comes up short of that count it counts the source again: rows that no longer exist were removed by RavenDB and are an absence, while rows that still exist but were never read halt the category. It is the same population as the retention skip above, and which of the two it becomes is a race with the sweep. The consequence to know is that the dry run's count is a snapshot rather than a promise, and for those two categories the difference between it and the final copied count is not attributed to anything. **A category can finish with a small amount of loss and still count as complete.** A few skipped rows in a large table leave the category in a *complete with errors* state, which blocks nothing. Its skipped count is printed and the ids of the skipped rows are written to the log, so while the RavenDB database still exists you can go and look at exactly what did not make it. @@ -286,7 +286,7 @@ The thing to read twice is that the counts never travel back through the engine - Deciding whether a row is past the target's retention cutoff needs two retention periods: the source's reverses `@expires` back into the status-change instant, and the target's current one decides whether that instant is past the cutoff. - A bad row does not stop the copy. Its category finishes in a separate complete-with-errors state. - The halt threshold is proportional with an absolute floor, and a category halts only when both are exceeded. Proportional alone halts a three-row category on one bad row; absolute alone halts a five-million-row table on its 101st failure at the default floor of 100. Together, a large category keeps going through losses under the percentage and finishes complete with errors, so ten thousand skipped rows out of five million do not halt it. -- Rows left behind because SQL would remove them anyway (past retention, orphaned group comments, settings for unknown endpoints) are counted and reported, but never halt a category. The target reports them apart from its real failures, so they land in the skipped count and the log without moving the category toward a halt. +- Rows left behind because SQL would remove them anyway (past retention, settings for unknown endpoints) are counted and reported, but never halt a category. The target reports them apart from its real failures, so they land in the skipped count and the log without moving the category toward a halt. - The percentage is measured against what the run has processed so far rather than against the category's total, so a run that starts badly looks worse than it is. The floor is what keeps that harmless, since fewer than 101 skipped rows never consults the percentage at all. More than that, bunched at the start, does halt a category whose overall rate would have been fine, and the cost is one restart: the skipped rows commit with the cursor, so the next run resumes past them with its counters back at zero. - A source therefore must not read a category in an order that puts the rows most likely to be skipped at the front of it. - Verification therefore cannot treat any count difference as a fault. It accounts for every skip rule, or it reports every successful migration as broken. @@ -315,7 +315,7 @@ stateDiagram-v2 **Two things halt a category.** Either the skipped rows in this run pass both the percentage and the floor, which says the failures are systematic rather than incidental, or the copy hits an error it did not expect, in which case the error type and the cursor it stopped at are recorded. A host being shut down is neither: it leaves the category in progress, to be picked up from the cursor next time. Nor is a second host writing to the same checkpoint, which is refused so that the other host's progress stands. -**A halt stops that category and nothing else.** The remaining categories still run, with one exception: a category that must follow the halted one goes to blocked rather than running early, which is how group comments stay behind the archived messages they belong to. A blocked category is not a failure and needs no separate action, since clearing the halt clears the block on the next restart. +**A halt stops that category and nothing else.** The remaining categories still run, with one exception: a category that must follow the halted one goes to blocked rather than running early, which is how group comments stay behind the unresolved failed messages their groups are built from. A blocked category is not a failure and needs no separate action, since clearing the halt clears the block on the next restart. **What it costs depends on which category halted.** A halted optional category means the instance keeps serving traffic and that one slice of history is missing until it is resumed. A halted required category means the host stays closed, so the outage carries on until the halt is cleared or the category is abandoned. That is deliberate: opening the host is the point of no return, and it should not happen with required data left behind by accident. @@ -342,12 +342,14 @@ What it resolves and reports: It runs the same startup checks that gate startup, so a missing setting surfaces before a customer books an outage. -It counts, before anything moves, the rows that cannot cross as they stand: +If the source holds failed error imports, it says how many and advises running `--import-failed-errors` against RavenDB before the move. They are copied either way, but ones imported first arrive as ordinary failed messages rather than as imports still waiting. + +It counts, before anything moves, the rows the target says it would skip or merge, by reason. These include: - Documents whose `UniqueMessageId` will not parse as a GUID - Subscriptions that differ only in message-type version, and so merge onto one row - Subscriptions whose message type or transport address exceeds the 200-character key limit -- Integration event dispatches still queued, which are not copied and will never be sent +- Rows already past their retention period, and rows missing a value SQL requires It reports no duration for the optional categories, and nothing about load on the source. From e9f6da879eca2902720dc9011d6a65fc11e4130a Mon Sep 17 00:00:00 2001 From: Warwick Schroeder Date: Wed, 30 Sep 2026 14:24:12 +0800 Subject: [PATCH 15/15] Refine migration documentation for clarity on data retention and copying strategy --- .../ravendb-to-sql-migration-overview.md | 65 +++++++++++-------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/docs/migration/ravendb-to-sql-migration-overview.md b/docs/migration/ravendb-to-sql-migration-overview.md index 6af075e895..9c167c3c0b 100644 --- a/docs/migration/ravendb-to-sql-migration-overview.md +++ b/docs/migration/ravendb-to-sql-migration-overview.md @@ -8,8 +8,8 @@ This covers the error instance only. The audit instance has no SQL persister, so ## Strategy -- Switch over first, and copy only what has to be copied. Retention does most of the work: error retention is between 5 and 45 days and event retention is shorter, so most of the source ages out on its own within weeks. That is why archived and resolved messages are optional rather than required. Retention would have deleted them anyway. -- The required set is small because unresolved failures are the only category a customer can act on, and the strategy assumes customers keep that number low by resolving and archiving. **A neglected instance breaks that assumption**: unresolved failures can legitimately be months old, and a large backlog of them makes the closed window long rather than short. The dry run is what tells a customer which case they are in. +- Switch over first, and copy only what has to be copied. Retention does most of the work: error retention is between 5 and 45 days and event retention defaults to 14 days, so most of the source ages out on its own within weeks. That is why archived and resolved messages are optional rather than required. Retention would have deleted them anyway. +- The required set is what the instance needs the moment it opens. Most of it is small, but two parts grow: unresolved failures, and the last 7 days of event log, which on a busy instance holds a row for every failure, retry and submission. The strategy assumes you keep unresolved failures low by resolving and archiving. **A neglected instance breaks that assumption**: unresolved failures can legitimately be months old, and a large backlog of them makes the closed window long rather than short. The dry run is what tells you which case you are in. - Anything not selected simply ages out of RavenDB, and the customer deletes the old database when they are ready. ## Goals @@ -18,11 +18,11 @@ This covers the error instance only. The audit instance has no SQL persister, so - **All three RavenDB sources are supported**. Embedded, a container, or RavenDB Cloud, on one code path rather than three. - **No writes through the client**. The copier never changes the source, but RavenDB's own expiration does: the primary database already has it configured, and the sweep keeps deleting failed messages and event log items throughout the migration and for as long afterwards as the instance is left running. The old database is a fallback that degrades from the moment you start. - **Abandonable up to a known point, and only up to that point**. While ServiceControl is closed the copy can be thrown away at no cost, because nothing but the copier has written to SQL and the migration has written nothing to RavenDB: see [the one point you can go back](#the-one-point-you-can-go-back). Once the host opens there is no way back at all. -- **No duplicates and no gaps**. Rows and the resume cursor commit in one transaction, so a crash needs no reconciliation. +- **No duplicates and no gaps**. Rows and the resume cursor, a marker of the last row copied, commit in one transaction, so a crash needs no reconciliation. - **Every identifier anything depends on is carried across**. The event log, historic retry operations and pending integration events are renumbered, because nothing references their keys. - **Refuse rather than half-migrate**. Every check runs before the first row moves, and a failure is a host that will not start. -- **No silent loss**. A migration cannot end with a selected category still in progress or halted, only with each one finished or explicitly abandoned. Abandoning is a deliberate choice, and an abandoned category lets the host open. Skipped rows are counted and reported. -- **Bounded impact on a live instance**. Throttled behind normal ingestion and streamed, so memory does not track the size of the database. +- **No silent loss**. A migration cannot end with a selected category still in progress or halted, only with each one finished or explicitly abandoned. Abandoning is a deliberate choice, and an abandoned category lets the host open. Skipped rows are counted and reported. The one exception is rows RavenDB's own expiration deletes while the copy runs: they are never read, so they are not skips, and the recount is what tells them apart from a real loss. See [what does not come across](#what-does-not-come-across). +- **Bounded impact on a live instance**. The background copy waits a fixed pause between batches, and reads are streamed, so memory does not track the size of the database. - **Known before it starts, visible while it runs**. A dry run reports what will move and how long ServiceControl is closed, and every category transition is reported as it happens. - **Use existing functionality where possible**. Progress goes through custom checks and the activity feed, so no new client or screen is needed. @@ -30,7 +30,7 @@ This covers the error instance only. The audit instance has no SQL persister, so - **Zero downtime.** The required data is copied with ServiceControl closed, so there is a real, if short, outage. - **Reversible once ServiceControl opens.** Nothing copies SQL rows back to RavenDB, so once the host has served traffic there is no rollback of any kind. -- **Steerable while running.** No pause, resume, or abort. Changing anything means editing configuration and restarting. +- **Steerable while running.** No pause or resume, and no abort command. Going back during the closed window means stopping and reconfiguring, and changing anything else means editing configuration and restarting. - **A general-purpose migration tool.** The source is always RavenDB and the target is always a ServiceControl EF Core persister, both at versions this build can read. - **Custom migration UI via ServicePulse.** Custom checks and the event log will be used for progress reporting, but migration configuration and migration engine control will not be available via the UI. @@ -70,19 +70,20 @@ The copier runs inside the ServiceControl host, so every row and every message b ## Migration workflow 1. Upgrade ServiceControl as normal, still on RavenDB. -2. Set four things in configuration: the new `PersistenceType`, its connection string, `MigrationMode=true`, and which [optional data](#data-to-be-migrated-categories) they want copied. +2. Set four things in configuration: the new `PersistenceType`, its connection string, `MigrationMode=true`, and whether you want the one [optional](#optional) category, archived and resolved messages, copied. 3. Run `--setup` to create the SQL schema. It fails against a SQL Server instance without Full-Text Search installed. 4. Run the [dry run](#dry-run). It reports what it resolved as a source, what each category holds, and an estimate of how long ServiceControl will be closed. Read [what the dry run reports](#dry-run) before booking an outage around its estimate. 5. Start ServiceControl (`MigrationMode=true`). 6. Every check runs before a single row moves. If one fails the host does not start and names which, having copied nothing, so a wrong database name or unconfigured body storage costs a restart rather than a half-finished migration. -7. The copying of [required data](#required) starts, with ServiceControl still closed. This is assumed to be a small amount of data. +7. The copying of [required data](#required) starts, with ServiceControl still closed. How long it takes depends on how many unresolved failures you have and how busy the last 7 days were, and the [dry run](#dry-run) gives you an estimate. If a required category halts, the host stays closed until you fix the cause and restart, or abandon that category. 8. ServiceControl opens, and whatever [optional data](#optional) they asked for is copied in the background while the instance runs normally. They can watch it from ServicePulse custom checks and events, but not steer it. -9. They run the verification pass once the background job has completed, which reports row counts on both sides category by category, accounting for deliberate skips so a difference is explained rather than reported as a fault, then set `MigrationMode=false` and restart. It tolerates more rows in SQL than in RavenDB, because RavenDB keeps expiring rows the copier already took. +9. You run the verification pass once the background job has completed, which reports row counts on both sides category by category, accounting for deliberate skips so a difference is explained rather than reported as a fault, then set `MigrationMode=false` and restart. Counts can differ in both directions without anything being wrong. SQL can hold more rows, because RavenDB keeps expiring rows the copier already took. SQL can also hold fewer, because once ServiceControl opens it sends pending integration events, removes group comments whose group has no failed messages left, and removes failed error imports once they are imported again. 10. RavenDB data can be removed. - If `MigrationMode=false` is set while a selected category is still incomplete, the host refuses to start and names exactly what is outstanding. - A category that ended *complete with errors* counts as complete and does not block, though its skipped count is printed so the loss is stated rather than silent. - An explicit override exists for a customer who has changed their mind and accepts leaving data behind. It marks the outstanding categories as abandoned, which is a deliberate end state rather than a failure, so the progress check settles and the guard stays armed for any later migration. +- While a selected category is still unfinished, ServiceControl pauses its own clean-up: the retention sweep, the purge API, the heartbeat settings sync and throughput collection. Your SQL database grows until the copy finishes, and these restart on their own once it does. - **Steps 5 to 7 are the abort window**, which is not the override above: see [the one point you can go back](#the-one-point-you-can-go-back). - A category that stops because too many rows failed is *halted*, and it stays that way until someone acts: fix the cause and restart to carry on from where it stopped, or abandon it deliberately if you accept the loss. See [a halt stops one category, and clearing it is a restart](#a-halt-stops-one-category-and-clearing-it-is-a-restart). @@ -126,9 +127,10 @@ flowchart TB B --> C["Open the old RavenDB, read only"] C --> D{"All checks pass?"} D -->|"No"| E["Host does not start.
Says which check failed.
Nothing has been copied."] - D -->|"Yes"| F["Copy what cannot be recreated.
Minutes. ServiceControl still closed."] - F --> G["ServiceControl opens.
New failed messages go straight to SQL."] - G --> H["Copy the selected history in the background,
throttled behind normal ingestion"] + D -->|"Yes"| F["Copy what cannot be recreated.
How long depends on unresolved failures
and the last 7 days of events.
ServiceControl still closed."] + F -->|"A required category halts"| M["Host stays closed.
Fix the cause and restart,
or abandon the category."] + F -->|"Required data copied"| G["ServiceControl opens.
New failed messages go straight to SQL."] + G --> H["Copy the selected history in the background,
with a fixed pause between batches"] H --> I["Verify row counts on both sides,
category by category"] I --> J{"MigrationMode = false,
everything complete?"} J -->|"No"| K["Host does not start.
Names what is outstanding.
An override exists."] @@ -160,11 +162,11 @@ flowchart TB - Licensing report masks - The uploaded licensed endpoint details file, which nothing recomputes: skipping it means the customer re-downloads it from the licence portal and uploads it again - Subscriptions -- The last 7 days of the event log, so the ServicePulse activity feed shows what led up to the failures you are about to act on the moment ServiceControl opens. The 7 days count back from the newest event in RavenDB rather than from when the copy runs, so a restart copies the same window. That newest time is capped at when RavenDB last stored an event, because event times come from the endpoints and an endpoint whose clock runs ahead would otherwise push the window forward. Older events are not copied and age out of RavenDB on their own: at the default 14-day event retention that is at most another 7 days of history +- The last 7 days of the event log, so the ServicePulse activity feed shows what led up to the failures you are about to act on the moment ServiceControl opens. The 7 days count back from whichever is earlier: the newest event in RavenDB, or the last time RavenDB stored one. Counting from the data rather than from when the copy runs means a restart copies the same window. The second limit is there because event times come from the endpoints, and an endpoint whose clock runs ahead would otherwise push the window forward. Older events are not copied and age out of RavenDB on their own: at the default 14-day event retention that is at most another 7 days of history - Integration events still waiting to be sent when you switch over. There are only any if the old instance was falling behind or could not reach the broker, and each one is an event a subscriber has not yet received. They are sent once ServiceControl opens, later than they would have been. RavenDB already sends them in no particular order, so no ordering is lost - Custom checks, with the status each last reported. They are required because not every check reports again: an endpoint that is down never does, and a check with no repeat interval reports only when its endpoint starts. Leaving one behind could hide a known failure until that endpoint restarts - Failed error imports, with their bodies. Each is a failed message ServiceControl took off the error queue but could not ingest, so it exists nowhere else, and it never expires. After the move, the "Error Message Ingestion" custom check keeps flagging them and `--import-failed-errors` imports them into SQL. Importing them on RavenDB before you start is better still, and the [dry run](#dry-run) tells you how many there are -- Group comments, copied straight after the unresolved failed messages, so a note such as "do not retry this group" is there the moment ServiceControl opens. Every comment except a blank one is copied. A comment on a group whose messages are all archived or resolved waits while those messages copy, and once the migration settles ServiceControl's own clean-up removes any comment whose group has no failed messages left, exactly as it always does on SQL +- Group comments, copied after the unresolved failed messages, so a note such as "do not retry this group" is there the moment ServiceControl opens. They follow those messages because a comment belongs to a failure group, and the groups ServicePulse shows first are built from unresolved failed messages. Every comment except a blank one is copied. A comment on a group whose messages are all archived or resolved waits while those messages copy, and once the migration settles ServiceControl's own clean-up removes any comment whose group has no failed messages left, exactly as it always does on SQL ### Optional @@ -182,7 +184,7 @@ flowchart TB ## What does not come across -**Whole categories are never copied.** Which ones, and why nothing needs them, is the [not migrated](#not-migrated) list above. Anything in an optional category you did not select is also never copied, and nothing later goes back for it. Neither is an event log item raised more than 7 days before the newest one, which falls outside the [required](#required) event log window rather than being skipped. +**Whole categories are never copied.** Which ones, and why nothing needs them, is the [not migrated](#not-migrated) list above. Anything in an optional category you did not select is also never copied, and nothing later goes back for it. Neither is an event log item raised before the start of the 7-day window, which falls outside the [required](#required) event log window rather than being skipped. **Rows skipped one at a time, and counted.** Each of these shows up in the skipped count for its category, broken out by reason, so you can see how much went and why: @@ -192,22 +194,22 @@ flowchart TB - A subscription whose message type or transport address exceeds 200 characters. The target key columns are capped at 200 characters, so it cannot be stored at all. - An archived or resolved failed message, or an event log item, already past its retention period. SQL's retention clean-up would delete it on its first pass, so it is counted rather than copied only to be deleted. - Endpoint settings for an endpoint ServiceControl does not know. ServiceControl removes those settings shortly after it starts. -- A row missing a value SQL requires, such as a known endpoint with no name or host, or a failed message with no failing endpoint address. An empty group comment is left behind the same way, because ServiceControl never stores one. +- A row missing a value SQL requires, such as a known endpoint with no name or host, or a failed message with no failing endpoint address. An empty group comment is left behind the same way: RavenDB can store one, but SQL never does. -**Things that change shape, and are not counted as skips at all.** The dry run counts these before anything moves, so they are a number you see in advance rather than a discovery afterwards. They are also the ones to read twice: +**Things that change shape, and are not counted as skips at all.** The dry run counts the two merges before anything moves. Attempt history and renumbering apply to every row of their kind, so there is nothing to count. They are also the ones to read twice: -- **Processing attempt history collapses to the newest attempt.** The SQL model has no attempts table. This affects every failed message that failed more than once, in the one category every customer copies. A message that failed five times arrives showing one attempt, and the other four are gone. +- **Processing attempt history collapses to the newest attempt.** The SQL model has no attempts table. This affects every failed message that failed more than once, whether it is unresolved, archived or resolved. A message that failed five times arrives showing one attempt, and the other four are gone. - **Subscriptions that differ only in message-type version merge onto one row**, because the target key carries the type name without the version. - **Endpoint settings for two endpoint names that differ only in case merge onto one row on SQL Server**, because SQL Server's default collation compares names without case, so one of the two settings is kept. PostgreSQL keeps both, and so does a SQL Server database created with a case-sensitive collation. The dry run counts this one too, by asking SQL Server how the name column compares, though for unusual characters its count can differ from what the copy does. - **Event log items, historic retry operations and pending integration events are renumbered.** Their keys are database identities and nothing references them, so this is safe, but the old numbers do not survive. -**Rows RavenDB deletes while the copy is running are an absence, not a skip.** Expiration only deletes a document carrying `@expires`, and only two kinds ever get one: a resolved or archived failed message, and an event log item (`ExpirationManager.cs:34,41`). A failed message loses its expiry whenever it becomes unresolved or retry-issued again: when it fails again, when it is unarchived, or when a retry is issued. The exception is a database last written by version 6.18 or earlier, where a message that failed again after being archived or resolved kept its old expiry, so a few unresolved messages there can still expire during the copy. Apart from those, only the archived and resolved messages category and the event log can shrink underneath the copier. Archived and resolved messages copy in the background, where the window is longest. The event log's 7 days copy while ServiceControl is closed, and at the default 14-day event retention even the oldest of them is a week from expiring on a source that stopped recently, so the sweep reaches the window only on a source left stopped for days before the move. A document the sweep removes before the stream reaches it is never read, so it is counted nowhere. The copier counts each category before it starts, and if the copy comes up short of that count it counts the source again: rows that no longer exist were removed by RavenDB and are an absence, while rows that still exist but were never read halt the category. It is the same population as the retention skip above, and which of the two it becomes is a race with the sweep. The consequence to know is that the dry run's count is a snapshot rather than a promise, and for those two categories the difference between it and the final copied count is not attributed to anything. +**Rows RavenDB deletes while the copy is running are an absence, not a skip.** Expiration only deletes a document carrying `@expires`, and only two kinds ever get one: a resolved or archived failed message, and an event log item (`ExpirationManager.cs:34,41`). A failed message loses its expiry whenever it becomes unresolved or retry-issued again: when it fails again, when it is unarchived, or when a retry is issued. The exception is a message that failed again after being archived or resolved while the instance ran version 6.18 or earlier: it kept its old expiry, and upgrading does not remove it, so a few unresolved messages can still expire during the copy. Apart from those, only the archived and resolved messages category and the event log can shrink underneath the copier. Archived and resolved messages copy in the background, where the window is longest. The event log's 7 days copy while ServiceControl is closed, and at the default 14-day event retention even the oldest of them is a week from expiring on a source that stopped recently, so the sweep reaches the window only on a source left stopped for days before the move. A document the sweep removes before the stream reaches it is never read, so it is counted nowhere. The copier counts each category before it starts, and if the copy comes up short of that count it counts the source again: rows that no longer exist were removed by RavenDB and are an absence, while rows that still exist but were never read halt the category. The dry run's count is a snapshot rather than a promise, and the recount is what shows that RavenDB removed rows while the copy ran. **A category can finish with a small amount of loss and still count as complete.** A few skipped rows in a large table leave the category in a *complete with errors* state, which blocks nothing. Its skipped count is printed and the ids of the skipped rows are written to the log, so while the RavenDB database still exists you can go and look at exactly what did not make it. ## The one point you can go back -While ServiceControl is closed and the required copy is running, nothing except the copier has written to SQL, and the migration has written nothing to RavenDB, which is still authoritative. RavenDB's own expiration still runs, though: unless you disabled it, it keeps deleting expired failed messages and event log items, as [Goals](#goals) describes. If you need your instance back, set `MigrationMode=false`, point `PersistenceType` back at RavenDB, and start. You lose the copy, not your data, and you can start again later. +While ServiceControl is closed and the required copy is running, nothing except the copier has written to SQL, and the migration has written nothing to RavenDB, which is still authoritative. RavenDB's own expiration still runs, though: unless you disabled it, it keeps deleting expired failed messages and event log items, as [Goals](#goals) describes. If you need your instance back, set `MigrationMode=false`, point `PersistenceType` back at RavenDB, and start. You lose the copy, not your data. To start again later, start from an empty SQL database: drop it, create it, and run `--setup` again. Do not reuse the old copy, because a second attempt skips every category the first one finished, so anything that reached RavenDB since is left behind, and integration events RavenDB has since sent would be sent again. That window closes the moment ServiceControl opens. From then on new failed messages are ingesting into SQL, RavenDB is no longer current, and there is no rollback: nothing copies SQL rows back. The choice at that point is to finish the migration or to accept losing whatever has not been copied. @@ -222,7 +224,7 @@ That window closes the moment ServiceControl opens. From then on new failed mess - A whole `FailedMessage` is written with its stored status intact. No existing caller does that, though the dialect upsert already accepts a status, so the gap is smaller than it looks. - `UniqueMessageId` keeps its value, but converts type: the source holds a string and the target column is a `uniqueidentifier`. It is the primary key, the ServicePulse URL, the retry correlation key and the body lookup key at once. -- `StatusChangedAt` is reconstructed from `@expires` for resolved and archived messages, which is the only place RavenDB sets it. Unresolved and retry-issued messages have no `@expires`, so the copier uses the newest processing attempt's timestamp. The column is `NOT NULL`, so it cannot be left empty, but the value is harmless for those two: the retention sweep only considers resolved and archived rows, so an unresolved message never ages out whatever is written here. +- `StatusChangedAt` is reconstructed from `@expires` for resolved and archived messages, which is the only place RavenDB sets it. Unresolved and retry-issued messages normally have no `@expires` (see [the exception](#what-does-not-come-across) for messages from version 6.18 or earlier), so the copier uses the newest processing attempt's timestamp. The column is `NOT NULL`, so it cannot be left empty, but the value is harmless for those two: the retention sweep only considers resolved and archived rows, so an unresolved message never ages out whatever is written here. - Message bodies go through `IBodyStoragePersistence`, which owns the compression threshold and the choice of filesystem, Azure Blob or S3. The separate 102,400-byte inline threshold is not there: it lives on the ingestion path, so the copier has to apply it rather than inherit it. - Throughput rows are written directly rather than through the collector, and the write sets each day's count rather than adding to it. Throughput is a required category, so it copies while ServiceControl is closed, before any collector has written to SQL. Setting is what makes the category safe to resume after a crash, where adding would double-count. Copying the rows is also what stops the audit and broker collectors re-gathering the same days when the host opens, because `LastCollectedDate` is derived from the newest throughput row rather than stored (`LicensingDataStore.cs:45`). The checkpoint is what stops a second pass overwriting days the collectors have written since. - Identifiers narrow on the way across, and the dry run counts every kind. What narrows, merges or cannot be stored at all is in [what does not come across](#what-does-not-come-across). @@ -230,7 +232,7 @@ That window closes the moment ServiceControl opens. From then on new failed mess ## Batching and throttling - Batch size comes from the provider: SQL Server divides its own parameter budget by the column count, PostgreSQL uses a flat 50 rows. -- The throttle is a configurable pause between batches, defaulting to 100 ms. Lowering it, or turning `MigrationMode` off, is the only remedy for a copy competing with production. +- The throttle is a configurable pause between batches, defaulting to 100 ms. Raising it slows the background copy and eases the load on production. Turning `MigrationMode` off is not a remedy: while a selected category is unfinished the host refuses to start, unless you abandon that category. ## Checkpointing and resume @@ -238,7 +240,7 @@ A copy that runs for hours will be interrupted at some point: a restart, a dropp **What one row holds:** the category it tracks, its state, the resume cursor, how many rows were copied, skipped and already present, a count per skip reason, how many rows the source held when the category started, when it started, when it last made progress, when it settled, the last error, and a version number used to spot a second writer. -**The states, and which ones a restart re-enters.** `Complete`, `CompleteWithErrors` and `Abandoned` are terminal, so a restart passes straight over the category. `Halted` and `Blocked` are not: a halt is resumed from its cursor once the cause is fixed, and a block clears itself once the category it waits on settles, which is how group comments end up behind archived messages. `NotStarted` and `InProgress` both mean there is work to do. +**The states, and which ones a restart re-enters.** `Complete`, `CompleteWithErrors` and `Abandoned` are terminal, so a restart passes straight over the category. `Halted` and `Blocked` are not: a halt is resumed from its cursor once the cause is fixed, and a block clears itself once the category it waits on settles, which is how group comments end up behind unresolved failed messages. `NotStarted` and `InProgress` both mean there is work to do. ### One batch, and why nothing provisional is ever saved @@ -285,7 +287,7 @@ The thing to read twice is that the counts never travel back through the engine - A body is read up to three times before the message is skipped whole, and the exhausted attempts count toward the halt threshold. - Deciding whether a row is past the target's retention cutoff needs two retention periods: the source's reverses `@expires` back into the status-change instant, and the target's current one decides whether that instant is past the cutoff. - A bad row does not stop the copy. Its category finishes in a separate complete-with-errors state. -- The halt threshold is proportional with an absolute floor, and a category halts only when both are exceeded. Proportional alone halts a three-row category on one bad row; absolute alone halts a five-million-row table on its 101st failure at the default floor of 100. Together, a large category keeps going through losses under the percentage and finishes complete with errors, so ten thousand skipped rows out of five million do not halt it. +- The halt threshold is proportional, 5 percent by default, with an absolute floor, and skips halt a category only when both are exceeded. The one exception is a category smaller than the floor, which halts if it loses more than half its rows. Proportional alone halts a three-row category on one bad row; absolute alone halts a five-million-row table on its 101st failure at the default floor of 100. Together, a large category keeps going through losses under the percentage and finishes complete with errors, so ten thousand skipped rows out of five million do not halt it. - Rows left behind because SQL would remove them anyway (past retention, settings for unknown endpoints) are counted and reported, but never halt a category. The target reports them apart from its real failures, so they land in the skipped count and the log without moving the category toward a halt. - The percentage is measured against what the run has processed so far rather than against the category's total, so a run that starts badly looks worse than it is. The floor is what keeps that harmless, since fewer than 101 skipped rows never consults the percentage at all. More than that, bunched at the start, does halt a category whose overall rate would have been fine, and the cost is one restart: the skipped rows commit with the cursor, so the next run resumes past them with its counters back at zero. - A source therefore must not read a category in an order that puts the rows most likely to be skipped at the front of it. @@ -304,7 +306,7 @@ stateDiagram-v2 InProgress --> InProgress: the host was stopped mid-copy,
so the next start resumes from the cursor InProgress --> Complete: every row reached, none skipped InProgress --> CompleteWithErrors: every row reached, some skipped - InProgress --> Halted: too many rows skipped in this run,
or the copy hit an error it did not expect + InProgress --> Halted: too many rows skipped, most of a small category lost,
an error it did not expect, or a shortfall the recount confirms Halted --> InProgress: fix the cause, restart,
carry on from the cursor Halted --> Abandoned: accept the loss, deliberately InProgress --> Abandoned: accept the loss, deliberately @@ -313,7 +315,14 @@ stateDiagram-v2 Abandoned --> [*] ``` -**Two things halt a category.** Either the skipped rows in this run pass both the percentage and the floor, which says the failures are systematic rather than incidental, or the copy hits an error it did not expect, in which case the error type and the cursor it stopped at are recorded. A host being shut down is neither: it leaves the category in progress, to be picked up from the cursor next time. Nor is a second host writing to the same checkpoint, which is refused so that the other host's progress stands. +**Four things halt a category.** + +- **Too many skips.** The skipped rows in this run pass both the percentage and the floor, which says the failures are systematic rather than incidental. +- **Most of a small category lost.** A category with fewer rows than the floor loses more than half of them. +- **An error the copy did not expect.** The error type and the cursor it stopped at are recorded. +- **A shortfall that survives the recount.** The copy came up short of the starting count, and the missing rows still exist in RavenDB but were never read. + +A host being shut down is none of these: it leaves the category in progress, to be picked up from the cursor next time. Nor is a second host writing to the same checkpoint, which is refused so that the other host's progress stands. **A halt stops that category and nothing else.** The remaining categories still run, with one exception: a category that must follow the halted one goes to blocked rather than running early, which is how group comments stay behind the unresolved failed messages their groups are built from. A blocked category is not a failure and needs no separate action, since clearing the halt clears the block on the next restart. @@ -363,9 +372,9 @@ A containerised instance runs all three as a one-off `docker run` of the same im ## Configuration and control -- A customer sets `MigrationMode` and the list of categories next to it. A status command and a custom check report back. +- You set `MigrationMode`, and next to it whether to copy the one optional category, archived and resolved messages. A status command and a custom check report back. - Categories are read fresh at every startup. Adding one copies it on the next restart, removing one deletes nothing. -- There is no HTTP API, no pause, no resume, no abort, and no way to add a category to a running instance. All of those mean editing configuration and restarting. +- There is no HTTP API, no pause, no resume, no abort command, and no way to add a category to a running instance. All of those mean editing configuration and restarting. Going back during the closed window means stopping and reconfiguring, as [the one point you can go back](#the-one-point-you-can-go-back) describes. - The checkpoint table is a record of what happened, not a control channel. - Stopping a copy takes a restart, so it cannot be stopped in ten seconds.

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 z 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 literal 0 HcmV?d00001 diff --git a/docs/migration/ravendb-to-sql-migration-instructions.md b/docs/migration/ravendb-to-sql-migration-instructions.md new file mode 100644 index 0000000000..af849a9e43 --- /dev/null +++ b/docs/migration/ravendb-to-sql-migration-instructions.md @@ -0,0 +1,54 @@ +# Migrating from RavenDB to SQL Server or PostgreSQL + +This page covers what you can run today. How the migration works, and what is planned, is in the [migration overview](ravendb-to-sql-migration-overview.md) and the [system design diagram](migration-system-design-diagram.png). + +> [!NOTE] +> Copying data is not built yet. The one migration command available is the source report, which reads the RavenDB database and changes nothing. + +## Before you start + +The source is a ServiceControl error instance on RavenDB. Keep its RavenDB settings in its configuration: the migration reads RavenDB through them, including after `PersistenceType` is switched to SQL Server or PostgreSQL. + +| Setting | Environment variable | What it is | +| --- | --- | --- | +| `ServiceControl/RavenDB/ConnectionString` | `SERVICECONTROL_RAVENDB_CONNECTIONSTRING` | An external RavenDB server. Leave unset for an embedded database | +| `ServiceControl/DbPath` | `SERVICECONTROL_DBPATH` | The embedded database's data directory | +| `ServiceControl/RavenDB/DatabaseName` | `SERVICECONTROL_RAVENDB_DATABASENAME` | The primary database, `primary` by default | +| `LicensingComponent/RavenDB/ThroughputDatabaseName` | `LICENSINGCOMPONENT_RAVENDB_THROUGHPUTDATABASENAME` | The throughput database, `throughput` by default | +| `ServiceControl/RavenDB/ClientCertificatePath` or `ServiceControl/RavenDB/ClientCertificateBase64`, with `ServiceControl/RavenDB/ClientCertificatePassword` | `SERVICECONTROL_RAVENDB_CLIENTCERTIFICATEPATH` and so on | A secured external server's client certificate | +| `ServiceControl/ErrorRetentionPeriod` | `SERVICECONTROL_ERRORRETENTIONPERIOD` | Required. Don't change it during the move | + +`ServiceControl/Migration/SourcePersistenceType` defaults to `RavenDB` and needs no setting. + +## Report on the source + +Run the instance's executable with `--migration-source-report`: + +```powershell +# Installed on Windows, from the instance's installation folder +.\ServiceControl.exe --migration-source-report +``` + +```shell +# Container, against an external RavenDB server +docker run --rm --env-file servicecontrol.env ghcr.io/particular/servicecontrol: --migration-source-report +``` + +From source, build `src/ServiceControl` and run the same command from its output folder, as in [How to run/debug locally](../../README.md#how-to-rundebug-locally). + +The report prints the RavenDB server version, whether the source is embedded or external and where it is, both database names with the setting each came from, and a row count for every collection in both databases. + +- **External server:** run it while ServiceControl is running. It only reads. +- **Embedded database:** stop the ServiceControl service, run the report, then start the service again. The report starts its own RavenDB process against the data directory, which cannot happen while the instance holds it. +- **Container with an embedded database:** not supported, because the container image does not ship the RavenDB server. Point the instance at an external RavenDB server instead. + +## If the report fails + +The error names the setting to fix: + +- **"has no database named ..."**: the database name setting it quotes is wrong. +- **"refused its client certificate access ..."**: grant that certificate Read access to the database, or supply a certificate that has it. + +## Not available yet + +Copying the data (`MigrationMode`), the dry run, and the status and verify commands are planned but not built. The planned steps are in [Migration workflow](ravendb-to-sql-migration-overview.md#migration-workflow). diff --git a/docs/ravendb-to-sql-migration-overview.md b/docs/migration/ravendb-to-sql-migration-overview.md similarity index 89% rename from docs/ravendb-to-sql-migration-overview.md rename to docs/migration/ravendb-to-sql-migration-overview.md index 93306b9566..277a53c783 100644 --- a/docs/ravendb-to-sql-migration-overview.md +++ b/docs/migration/ravendb-to-sql-migration-overview.md @@ -15,19 +15,17 @@ A customer can already point ServiceControl at SQL Server or PostgreSQL. They ca - **Minimal downtime**. Only the required data copies with ServiceControl closed. Optional data copies in the background while it serves traffic. - **All three RavenDB sources are supported**. Embedded, a container, or RavenDB Cloud, on one code path rather than three. - **No writes through the client**. The copier never changes the source, but RavenDB's own expiration does: the primary database already has it configured, and the sweep keeps deleting failed messages and event log items throughout the migration and for as long afterwards as the instance is left running. The old database is a fallback that degrades from the moment you start. -- **Abandonable up to a known point, and only up to that point**. While ServiceControl is closed the copy can be thrown away at no cost, because nothing but the copier has written to SQL and RavenDB is untouched: see [the one point you can go back](#the-one-point-you-can-go-back). Once the host opens there is no way back at all. +- **Abandonable up to a known point, and only up to that point**. While ServiceControl is closed the copy can be thrown away at no cost, because nothing but the copier has written to SQL and the migration has written nothing to RavenDB: see [the one point you can go back](#the-one-point-you-can-go-back). Once the host opens there is no way back at all. - **No duplicates and no gaps**. Rows and the resume cursor commit in one transaction, so a crash needs no reconciliation. - **Every identifier anything depends on is carried across**. The event log and historic retry operations are renumbered, because nothing references their keys. - **Refuse rather than half-migrate**. Every check runs before the first row moves, and a failure is a host that will not start. -- **No silent loss**. A migration cannot end with a selected category incomplete, and skipped rows are counted and reported. +- **No silent loss**. A migration cannot end with a selected category still in progress or halted, only with each one finished or explicitly abandoned. Abandoning is a deliberate choice, and an abandoned category lets the host open. Skipped rows are counted and reported. - **Bounded impact on a live instance**. Throttled behind normal ingestion and streamed, so memory does not track the size of the database. - **Known before it starts, visible while it runs**. A dry run reports what will move and how long ServiceControl is closed, and every category transition is reported as it happens. - **Use existing functionality where possible**. Progress goes through custom checks and the activity feed, so no new client or screen is needed. ## Deliberately not built, and not currently planned -*Each of these was considered and left out. None of them is scheduled: if one becomes a requirement it is new design work, not a later increment of this one.* - - **Zero downtime.** The required data is copied with ServiceControl closed, so there is a real, if short, outage. - **Reversible once ServiceControl opens.** Nothing copies SQL rows back to RavenDB, so once the host has served traffic there is no rollback of any kind. - **Steerable while running.** No pause, resume, or abort. Changing anything means editing configuration and restarting. @@ -70,9 +68,9 @@ The copier runs inside the ServiceControl host, so every row and every message b ## Migration workflow 1. Upgrade ServiceControl as normal, still on RavenDB. -2. Set four things in configuration: the new `PersistenceType`, its connection string, `MigrationMode=true`, and which [optional data](#data-to-be-migrated) they want copied. +2. Set four things in configuration: the new `PersistenceType`, its connection string, `MigrationMode=true`, and which [optional data](#data-to-be-migrated-categories) they want copied. 3. Run `--setup` to create the SQL schema. It fails against a SQL Server instance without Full-Text Search installed. -4. Run the [dry run](#dry-run). It reports what it resolved as a source, what each category holds, and an estimate of how long ServiceControl will be closed. Read [what the estimate is worth](#what-the-estimate-is-worth) before booking an outage around it. +4. Run the [dry run](#dry-run). It reports what it resolved as a source, what each category holds, and an estimate of how long ServiceControl will be closed. Read [what the dry run reports](#dry-run) before booking an outage around its estimate. 5. Start ServiceControl (`MigrationMode=true`). 6. Every check runs before a single row moves. If one fails the host does not start and names which, having copied nothing, so a wrong database name or unconfigured body storage costs a restart rather than a half-finished migration. 7. The copying of [required data](#required) starts, with ServiceControl still closed. This is assumed to be a small amount of data. @@ -113,7 +111,8 @@ flowchart TB target --> bodies ``` -- **No provider-specific code in the engine or the source.** The source is always RavenDB and the target is always an `IPersistence`. +- **The engine and the host know no store.** They deal in categories, cursors and counts. The source maps a category to what it reads and describes itself as labelled facts, the target maps a category to where it writes and how to count it, and each contributes its own startup checks. RavenDB to SQL is the only pair built, and another pair, such as SQL to RavenDB, would add a source or a target without changing the engine. +- **The source reads the instance's own RavenDB settings**, so an existing customer sets nothing new. Leave them in place when switching `PersistenceType`. - **Both persisters load into the same process**, each into its own `AssemblyLoadContext`. - **The engine references neither assembly.** It knows only `IMigrationSource` and `IMigrationTarget`, and treats the resume cursor as an opaque value it passes from one to the other, so it can be tested against fakes on either side. @@ -172,11 +171,11 @@ flowchart TB ### Not migrated - The fifteen RavenDB index definitions, which map to a much smaller set of ordinary SQL indexes, and two of which are dead already -- The transient in-flight collections, which are empty when nothing is running -- `ArchiveBatches`, which exists only because of how RavenDB works +- The transient in-flight collections, which are empty when nothing is running: `RetryBatches`, `RetryBatchNowForwardings`, `FailedMessageRetries`, `ArchiveOperations` and `UnarchiveOperations` +- `ArchiveBatches` and `UnarchiveBatches`, which exist only because of how RavenDB works +- `ConnectedApplications`, which only versions 6.0 and 6.1 wrote and nothing has read since - Integration events still waiting to be sent when you switch over are never sent - Broker and audit service version details, which refill on the throughput collector's next run -- The last computed licensed-endpoint count, which is recomputed from the throughput data that is being copied ## What does not come across @@ -187,22 +186,20 @@ flowchart TB - A failed message whose `UniqueMessageId` is not a GUID. The target column is a `uniqueidentifier` and the value is never regenerated, because it is simultaneously the primary key, the ServicePulse URL, the retry correlation key and the body lookup key. - A failed message with no processing attempts recorded against it. The SQL model keeps the newest attempt and derives the failure time, the failing endpoint and the exception from it, all of which are required columns, so a message with nothing to derive them from cannot be written at all rather than being written blank. - A failed message whose body cannot be read after three attempts. **The whole message is skipped, not just its body**, because a message with no body is worse than no message. -- A row already past the target's retention cutoff. The sweeper would delete it within the hour, so copying it would write a body to blob storage for nothing. -- A group comment whose failure group has no messages left in the target. RavenDB never expires comments, but the SQL sweeper reclaims orphans. +- A subscription whose message type or transport address exceeds 200 characters. The target key columns are capped at 200 characters, so it cannot be stored at all. **Things that change shape, and are not counted as skips at all.** The dry run counts these before anything moves, so they are a number you see in advance rather than a discovery afterwards. They are also the ones to read twice: - **Processing attempt history collapses to the newest attempt.** The SQL model has no attempts table. This affects every failed message that failed more than once, in the one category every customer copies. A message that failed five times arrives showing one attempt, and the other four are gone. - **Subscriptions that differ only in message-type version merge onto one row**, because the target key carries the type name without the version. -- **A subscription whose message type or transport address exceeds 200 characters cannot be stored at all.** -- **Throughput endpoint names that differ only in case merge onto one row**, because the target key is lower-cased. +- **Endpoint settings for two endpoint names that differ only in case merge onto one row on SQL Server**, because SQL Server's default collation compares names without case, so one of the two settings is kept. PostgreSQL keeps both, and so does a SQL Server database created with a case-sensitive collation. The dry run counts this one too, by asking SQL Server how the name column compares, though for unusual characters its count can differ from what the copy does. - **Event log items and historic retry operations are renumbered.** Their keys are database identities and nothing references them, so this is safe, but the old numbers do not survive. **A category can finish with a small amount of loss and still count as complete.** A few skipped rows in a large table leave the category in a *complete with errors* state, which blocks nothing. Its skipped count is printed and the ids of the skipped rows are written to the log, so while the RavenDB database still exists you can go and look at exactly what did not make it. ## The one point you can go back -While ServiceControl is closed and the required copy is running, nothing except the copier has written to SQL and RavenDB is untouched and still authoritative. If you need your instance back, set `MigrationMode=false`, point `PersistenceType` back at RavenDB, and start. You lose the copy, not your data, and you can start again later. +While ServiceControl is closed and the required copy is running, nothing except the copier has written to SQL, and the migration has written nothing to RavenDB, which is still authoritative. RavenDB's own expiration still runs, though: unless you disabled it, it keeps deleting expired failed messages and event log items, as [Goals](#goals) describes. Back up both RavenDB databases, or disable expiration on them, before you start. If you need your instance back, set `MigrationMode=false`, point `PersistenceType` back at RavenDB, and start. You lose the copy, not your data, and you can start again later. That window closes the moment ServiceControl opens. From then on new failed messages are ingesting into SQL, RavenDB is no longer current, and there is no rollback: nothing copies SQL rows back. The choice at that point is to finish the migration or to accept losing whatever has not been copied. @@ -211,7 +208,7 @@ That window closes the moment ServiceControl opens. From then on new failed mess - A third RavenDB lifecycle opens the source: connect, check the version, stop. It never calls `DatabaseSetup.Execute`. - Both source databases must be on the same server or cluster (`LicensingDataStore.cs:35`). - The source has to be at a ServiceControl version this build can read, and nothing in RavenDB records one today. The only version check that exists compares the RavenDB server version to the RavenDB client version, and runs only for an external source. So a marker is stamped into the database on upgrade, and a source without one, or one from a newer major version, is refused by name rather than misread. -- Duration scales with distance to the source. The copier already holds the document from the stream, so each body costs **one** round trip rather than two, but it is one per message and they are not batched. Egress out of RavenDB Cloud is billed to the customer. See [how long the background copy actually takes](#how-long-the-background-copy-actually-takes). +- Duration scales with distance to the source. The copier already holds the document from the stream, so each body costs **one** round trip rather than two, but it is one per message and they are not batched. Egress out of RavenDB Cloud is billed to the customer. See [batching and throttling](#batching-and-throttling). ## Writing to SQL @@ -243,7 +240,7 @@ That window closes the moment ServiceControl opens. From then on new failed mess - Deciding whether a row is past the target's retention cutoff needs two retention periods: the source's reverses `@expires` back into the status-change instant, and the target's current one decides whether that instant is past the cutoff. - A bad row does not stop the copy. Its category finishes in a separate complete-with-errors state. - The halt threshold is proportional with an absolute floor, and a category halts only when both are exceeded. Proportional alone halts a three-row category on one bad row; absolute alone lets ten thousand failures pass on a five-million-row table as "only 0.2%". -- Verification therefore cannot treat any count difference as a fault. It accounts for all five skip rules, or it reports every successful migration as broken. +- Verification therefore cannot treat any count difference as a fault. It accounts for every skip rule, or it reports every successful migration as broken. ## Dry run @@ -257,12 +254,11 @@ What it resolves and reports: - Rows per category, and message-body volume per category - A duration for the window while ServiceControl is closed, as a range -It runs the same seven checks that gate startup, so a missing setting surfaces before a customer books an outage. +It runs the same startup checks that gate startup, so a missing setting surfaces before a customer books an outage. It counts, before anything moves, the rows that cannot cross as they stand: - Documents whose `UniqueMessageId` will not parse as a GUID -- Throughput endpoint names that differ only in case, and so merge onto one row - Subscriptions that differ only in message-type version, and so merge onto one row - Subscriptions whose message type or transport address exceeds the 200-character key limit - Integration event dispatches still queued, which are not copied and will never be sent diff --git a/src/ServiceControl.Migration.Tests/MigrationSourceReportCommandTests.cs b/src/ServiceControl.Migration.Tests/MigrationSourceReportCommandTests.cs index 8184bc21de..737a26bd4f 100644 --- a/src/ServiceControl.Migration.Tests/MigrationSourceReportCommandTests.cs +++ b/src/ServiceControl.Migration.Tests/MigrationSourceReportCommandTests.cs @@ -32,6 +32,20 @@ public void TearDown() [Test] public async Task The_report_names_the_databases_the_settings_and_the_collections() + { + var report = await RunReport(); + + Assert.Multiple(() => + { + Assert.That(report, Does.Match(@"Mode\s+: external")); + Assert.That(report, Does.Contain(MigrationSourceServer.ServerUrl)); + Assert.That(report, Does.Contain("from ServiceControl/RavenDB/DatabaseName")); + Assert.That(report, Does.Contain("from LicensingComponent/RavenDB/ThroughputDatabaseName")); + Assert.That(report, Does.Match(@"EndpointSettings\s+1"), "The report has to render a count, not just name the collection."); + }); + } + + static async Task RunReport() { var settings = new Settings(persisterType: "RavenDB", forwardErrorMessages: false, errorRetentionPeriod: TimeSpan.FromDays(10)); @@ -48,15 +62,6 @@ public async Task The_report_names_the_databases_the_settings_and_the_collection Console.SetOut(original); } - var report = writer.ToString(); - - Assert.Multiple(() => - { - Assert.That(report, Does.Contain("(external)")); - Assert.That(report, Does.Contain(MigrationSourceServer.ServerUrl)); - Assert.That(report, Does.Contain("from ServiceControl/RavenDB/DatabaseName")); - Assert.That(report, Does.Contain("from LicensingComponent/RavenDB/ThroughputDatabaseName")); - Assert.That(report, Does.Match(@"EndpointSettings\s+1"), "The report has to render a count, not just name the collection."); - }); + return writer.ToString(); } } diff --git a/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs b/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs index af0c18df37..61958afec2 100644 --- a/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs +++ b/src/ServiceControl.Migration.Tests/TwoPersistersInOneProcessTests.cs @@ -3,6 +3,7 @@ namespace ServiceControl.Migration.Tests; using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Runtime.Loader; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -87,7 +88,7 @@ public async Task A_source_and_a_target_load_side_by_side_and_share_one_type_ide { Assert.That(AssemblyLoadContext.GetLoadContext(description.GetType().Assembly), Is.SameAs(AssemblyLoadContext.Default), "A shared type produced inside the plugin context must arrive as the host's own type."); - Assert.That(description.PrimaryDatabase, Is.EqualTo(MigrationSourceServer.PrimaryDatabase), + Assert.That(description.Facts.Single(fact => fact.Label == "Primary database").Value, Is.EqualTo(MigrationSourceServer.PrimaryDatabase), "The source read its own RavenDB settings rather than the target's."); }); } diff --git a/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenMigrationSource.cs b/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenMigrationSource.cs index edf971e7c2..cc3c59a998 100644 --- a/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenMigrationSource.cs +++ b/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenMigrationSource.cs @@ -2,9 +2,12 @@ namespace ServiceControl.Persistence.RavenDB.DataMigration; +using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Particular.LicensingComponent.Contracts; using Raven.Client.Documents.Operations; using Raven.Client.ServerWide.Operations; using ServiceControl.Persistence.DataMigration; @@ -18,24 +21,44 @@ public async Task Describe(CancellationToken cancell var settings = lifecycle.Settings; var build = await lifecycle.DocumentStore.Maintenance.Server.SendAsync(new GetBuildNumberOperation(), cancellationToken); - return new MigrationSourceDescription( - settings.UseEmbeddedServer, - settings.UseEmbeddedServer ? settings.ServerUrl : settings.ConnectionString, - settings.DatabaseName, - settings.ThroughputDatabaseName, - build.ProductVersion); + var server = settings.UseEmbeddedServer + ? new MigrationSourceFact("Server", settings.ServerUrl, $"{lifecycle.SettingsRoot}/{RavenBootstrapper.DatabaseMaintenancePortKey}") + : new MigrationSourceFact("Server", settings.ConnectionString, $"{lifecycle.SettingsRoot}/{RavenBootstrapper.ConnectionStringKey}"); + + return new MigrationSourceDescription(build.ProductVersion, + [ + new MigrationSourceFact("Mode", settings.UseEmbeddedServer ? "embedded" : "external"), + server, + new MigrationSourceFact("Primary database", settings.DatabaseName, $"{lifecycle.SettingsRoot}/{RavenBootstrapper.DatabaseNameKey}"), + new MigrationSourceFact("Throughput database", settings.ThroughputDatabaseName, $"{ThroughputSettings.SettingsNamespace}/{ThroughputSettings.DatabaseNameKey}") + ]); } - public async Task> CountCollections(MigrationSourceDatabase database, CancellationToken cancellationToken = default) + public async Task> Inventory(CancellationToken cancellationToken = default) { - var databaseName = database == MigrationSourceDatabase.Primary - ? lifecycle.Settings.DatabaseName - : lifecycle.Settings.ThroughputDatabaseName; + var entries = new List(); - var statistics = await lifecycle.DocumentStore.Maintenance.ForDatabase(databaseName).SendAsync(new GetCollectionStatisticsOperation(), cancellationToken); + foreach (var databaseName in new[] { lifecycle.Settings.DatabaseName, lifecycle.Settings.ThroughputDatabaseName }) + { + var statistics = await lifecycle.DocumentStore.Maintenance.ForDatabase(databaseName).SendAsync(new GetCollectionStatisticsOperation(), cancellationToken); + entries.AddRange(statistics.Collections.Select(collection => new MigrationSourceInventoryEntry(databaseName, collection.Key, collection.Value))); + } - return statistics.Collections; + return entries; } + public Task Count(MigrationCategory category, CancellationToken cancellationToken = default) => + throw new NotSupportedException($"The RavenDB migration source cannot count category {category.Id} yet"); + + public IAsyncEnumerable Read( + MigrationCategory category, + string? resumeAfter, + int batchSize, + CancellationToken cancellationToken = default) => + throw new NotSupportedException($"The RavenDB migration source cannot read category {category.Id} yet"); + + public Task ReadBody(MigrationCategory category, string sourceId, CancellationToken cancellationToken = default) => + throw new NotSupportedException($"The RavenDB migration source cannot read bodies for category {category.Id} yet"); + public ValueTask DisposeAsync() => lifecycle.DisposeAsync(); } diff --git a/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenReadOnlySourceLifecycle.cs b/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenReadOnlySourceLifecycle.cs index d5ecc8694f..354587cb5e 100644 --- a/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenReadOnlySourceLifecycle.cs +++ b/src/ServiceControl.Persistence.RavenDB/DataMigration/RavenReadOnlySourceLifecycle.cs @@ -13,13 +13,16 @@ namespace ServiceControl.Persistence.RavenDB.DataMigration; using Raven.Client.Documents.Operations; using Raven.Client.Documents.Session; using Raven.Client.Exceptions.Database; -using Raven.Client.ServerWide.Operations; +using Raven.Client.Exceptions.Security; +using ServiceControl.Configuration; using ServiceControl.RavenDB; -sealed class RavenReadOnlySourceLifecycle(RavenPersisterSettings settings) : IAsyncDisposable +sealed class RavenReadOnlySourceLifecycle(RavenPersisterSettings settings, SettingsRootNamespace settingsRoot) : IAsyncDisposable { public RavenPersisterSettings Settings => settings; + public SettingsRootNamespace SettingsRoot => settingsRoot; + public IDocumentStore DocumentStore => documentStore ?? throw new InvalidOperationException($"The migration source is not open. Call {nameof(Open)} first."); public async Task Open(CancellationToken cancellationToken = default) @@ -40,9 +43,7 @@ public async Task Open(CancellationToken cancellationToken = default) await StartupChecks.EnsureServerVersion(documentStore, cancellationToken); } - // The persister cannot reach the host's Settings class for the root namespace, so it is spelled - // out here: a customer reads these two keys back out of app.config, not out of code. - await EnsureReadable(settings.DatabaseName, $"ServiceControl/{RavenBootstrapper.DatabaseNameKey}", cancellationToken); + await EnsureReadable(settings.DatabaseName, $"{settingsRoot}/{RavenBootstrapper.DatabaseNameKey}", cancellationToken); await EnsureReadable(settings.ThroughputDatabaseName, $"{ThroughputSettings.SettingsNamespace}/{ThroughputSettings.DatabaseNameKey}", cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -61,18 +62,6 @@ public IAsyncDocumentSession OpenSession(string databaseName) => DocumentStore.OpenAsyncSession(new SessionOptions { Database = databaseName, NoTracking = true }); async Task EnsureReadable(string databaseName, string settingKey, CancellationToken cancellationToken) - { - var record = await DocumentStore.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(databaseName), cancellationToken); - - if (record is null) - { - throw new InvalidOperationException($"The RavenDB migration source at {Located()} has no database named '{databaseName}'. That name comes from the '{settingKey}' setting. Correct it before migrating: a wrong name reads a database that is not there rather than the one that is."); - } - - await LoadDatabase(databaseName, settingKey, cancellationToken); - } - - async Task LoadDatabase(string databaseName, string settingKey, CancellationToken cancellationToken) { while (true) { @@ -93,6 +82,14 @@ async Task LoadDatabase(string databaseName, string settingKey, CancellationToke { throw; } + catch (DatabaseDoesNotExistException e) + { + throw new InvalidOperationException($"The RavenDB migration source at {Located()} has no database named '{databaseName}'. That name comes from the '{settingKey}' setting. Correct it before migrating: a wrong name reads a database that is not there rather than the one that is.", e); + } + catch (AuthorizationException e) + { + throw new InvalidOperationException($"The RavenDB migration source at {Located()} refused its client certificate access to the database '{databaseName}'. Grant that certificate Read access to '{databaseName}', or supply one that has it in '{settingsRoot}/{RavenBootstrapper.ClientCertificateBase64Key}' or '{settingsRoot}/{RavenBootstrapper.ClientCertificatePathKey}'. If '{databaseName}' is the wrong name, correct the '{settingKey}' setting instead: RavenDB refuses a certificate that has no access to a database whether or not that database exists.", e); + } catch (Exception e) when (e is not DatabaseLoadTimeoutException) { throw new InvalidOperationException($"The RavenDB migration source at {Located()} has a database named '{databaseName}', from the '{settingKey}' setting, but could not load it.", e); @@ -100,9 +97,11 @@ async Task LoadDatabase(string databaseName, string settingKey, CancellationToke } } - string Located() => settings.UseEmbeddedServer - ? $"{settings.ServerUrl} (embedded, data directory '{settings.DatabasePath}', from 'ServiceControl/DBPath')" - : settings.ConnectionString; + string Located() => Located(settings, settingsRoot); + + internal static string Located(RavenPersisterSettings sourceSettings, SettingsRootNamespace root) => sourceSettings.UseEmbeddedServer + ? $"{sourceSettings.ServerUrl} (embedded, data directory '{sourceSettings.DatabasePath}', from '{root}/{RavenBootstrapper.DatabasePathKey}')" + : sourceSettings.ConnectionString; string StartEmbedded() { @@ -147,7 +146,7 @@ static bool IsRead(HttpMethod method, string path) if (method == HttpMethod.Get || method == HttpMethod.Head) { // HiLo persists the id range it hands out, so it writes despite being a GET. - return !path.Contains("/hilo/", StringComparison.OrdinalIgnoreCase); + return !path.Contains(HiLoPathSegment, StringComparison.OrdinalIgnoreCase); } return method == HttpMethod.Post && Array.Exists(ReadOnlyPostPaths, suffix => path.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)); @@ -168,6 +167,7 @@ public async ValueTask DisposeAsync() } } + const string HiLoPathSegment = "/hilo/"; static readonly string[] ReadOnlyPostPaths = ["/queries", "/multi_get", "/streams/queries"]; static readonly TimeSpan EmbeddedShutdownTimeout = TimeSpan.FromSeconds(30); static readonly TimeSpan EmbeddedLoadRetryDelay = TimeSpan.FromMilliseconds(500); diff --git a/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs b/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs index 787813ed3c..c3e0bd7f40 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs @@ -93,7 +93,7 @@ public IPersistence Create(PersistenceSettings settings) return new RavenPersistence(specificSettings); } - public IMigrationSource CreateSource(PersistenceSettings settings) => - new RavenMigrationSource(new RavenReadOnlySourceLifecycle((RavenPersisterSettings)settings)); + public IMigrationSource CreateSource(SettingsRootNamespace settingsRoot) => + new RavenMigrationSource(new RavenReadOnlySourceLifecycle((RavenPersisterSettings)CreateSettings(settingsRoot), settingsRoot)); } } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/ReadOnlySourceLifecycleTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/ReadOnlySourceLifecycleTests.cs index e7ce5edc04..85b8fbd919 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/ReadOnlySourceLifecycleTests.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/DataMigration/ReadOnlySourceLifecycleTests.cs @@ -2,6 +2,8 @@ namespace ServiceControl.Persistence.Tests.RavenDB.DataMigration; using System; using System.IO; +using System.Linq; +using System.Text; using System.Threading.Tasks; using NUnit.Framework; using Raven.Client.Documents; @@ -11,7 +13,9 @@ namespace ServiceControl.Persistence.Tests.RavenDB.DataMigration; using Raven.Client.Documents.Session; using Raven.Client.ServerWide; using Raven.Client.ServerWide.Operations; +using ServiceControl.Configuration; using ServiceControl.MessageFailures; +using ServiceControl.Operations.BodyStorage.RavenAttachments; using ServiceControl.Persistence.DataMigration; using ServiceControl.Persistence.RavenDB; using ServiceControl.Persistence.RavenDB.DataMigration; @@ -20,6 +24,8 @@ namespace ServiceControl.Persistence.Tests.RavenDB.DataMigration; [TestFixture] class ReadOnlySourceLifecycleTests { + static readonly SettingsRootNamespace SettingsRoot = new("ServiceControl"); + string databaseName; IDocumentStore bootstrapStore; RavenPersisterSettings sourceSettings; @@ -61,7 +67,7 @@ public async Task SetUp() [Test] public async Task Opening_the_source_creates_no_index() { - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); await lifecycle.Open(); var statistics = await bootstrapStore.Maintenance.ForDatabase(databaseName).SendAsync(new GetStatisticsOperation()); @@ -74,7 +80,7 @@ public async Task Opening_the_source_creates_no_database() var absentThroughput = $"{databaseName}-absent"; sourceSettings.ThroughputDatabaseName = absentThroughput; - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); var exception = Assert.ThrowsAsync(async () => await lifecycle.Open()); @@ -87,7 +93,7 @@ public async Task Opening_the_source_writes_no_database_settings() { var before = (await bootstrapStore.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(databaseName))).Settings; - await using (var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings)) + await using (var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot)) { await lifecycle.Open(); } @@ -100,7 +106,7 @@ public async Task Opening_the_source_writes_no_database_settings() [Test] public async Task Opening_the_source_configures_no_expiration() { - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); await lifecycle.Open(); var record = await bootstrapStore.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(databaseName)); @@ -111,7 +117,7 @@ public async Task Opening_the_source_configures_no_expiration() [Test] public async Task Writing_through_the_source_store_throws() { - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); await lifecycle.Open(); using var session = lifecycle.DocumentStore.OpenAsyncSession(new SessionOptions { Database = databaseName }); @@ -126,7 +132,7 @@ public async Task Writing_through_the_source_store_throws() [Test] public async Task A_source_session_cannot_even_stage_a_write() { - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); await lifecycle.Open(); using var session = lifecycle.OpenSession(databaseName); @@ -134,13 +140,13 @@ public async Task A_source_session_cannot_even_stage_a_write() var exception = Assert.ThrowsAsync(async () => await session.StoreAsync(new FailedMessage { UniqueMessageId = "def", Status = FailedMessageStatus.Unresolved }, "FailedMessages/def")); - Assert.That(exception.Message, Does.Contain("tracking is disabled"), "OpenSession is NoTracking, so a write through it fails at Store rather than reaching the OnBeforeStore refusal. Both guards have to hold: this one is the only one a copier's own sessions ever meet."); + Assert.That(exception.Message, Does.Contain("tracking is disabled"), "OpenSession is NoTracking, so a write through it fails at Store rather than reaching the RefuseWrite request hook. Both guards have to hold: this one is the only one a copier's own sessions ever meet."); } [Test] public async Task Failed_message_status_reads_back_as_stored() { - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); await lifecycle.Open(); using var session = lifecycle.OpenSession(databaseName); @@ -154,7 +160,7 @@ public async Task A_failed_open_leaves_no_store_behind() { sourceSettings.ThroughputDatabaseName = $"{databaseName}-absent"; - var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); Assert.ThrowsAsync(async () => await lifecycle.Open()); @@ -165,29 +171,27 @@ public async Task A_failed_open_leaves_no_store_behind() await lifecycle.DisposeAsync(); } [Test] - public async Task The_RavenDB_configuration_opens_a_source_that_describes_itself() + public async Task A_source_describes_itself_as_facts_naming_the_setting_each_came_from() { - var factory = (IMigrationSourceFactory)new RavenPersistenceConfiguration(); - - await using var source = factory.CreateSource(sourceSettings); + await using var source = new RavenMigrationSource(new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot)); await source.Open(); var description = await source.Describe(); + var facts = description.Facts.ToDictionary(fact => fact.Label); Assert.Multiple(() => { - Assert.That(description.Embedded, Is.False); - Assert.That(description.PrimaryDatabase, Is.EqualTo(databaseName)); - Assert.That(description.ThroughputDatabase, Is.EqualTo($"{databaseName}-throughput")); - Assert.That(description.ServerVersion, Does.StartWith("6.")); + Assert.That(description.Version, Does.StartWith("6.")); + Assert.That(facts["Mode"].Value, Is.EqualTo("external")); + Assert.That(facts["Primary database"].Value, Is.EqualTo(databaseName)); + Assert.That(facts["Primary database"].SettingKey, Is.EqualTo("ServiceControl/RavenDB/DatabaseName")); + Assert.That(facts["Throughput database"].Value, Is.EqualTo($"{databaseName}-throughput")); }); } [Test] public async Task An_unopened_source_refuses_to_describe_itself() { - var factory = (IMigrationSourceFactory)new RavenPersistenceConfiguration(); - - await using var source = factory.CreateSource(sourceSettings); + await using var source = new RavenMigrationSource(new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot)); var exception = Assert.ThrowsAsync(async () => await source.Describe()); @@ -195,21 +199,29 @@ public async Task An_unopened_source_refuses_to_describe_itself() } [Test] - public async Task The_source_counts_the_collections_it_finds() + public async Task The_source_inventories_every_collection_by_database() { - var factory = (IMigrationSourceFactory)new RavenPersistenceConfiguration(); - - await using var source = factory.CreateSource(sourceSettings); + await using var source = new RavenMigrationSource(new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot)); await source.Open(); - var collections = await source.CountCollections(MigrationSourceDatabase.Primary); + var inventory = await source.Inventory(); - Assert.That(collections["FailedMessages"], Is.EqualTo(1)); + Assert.That(inventory.Single(entry => entry.Scope == databaseName && entry.Name == "FailedMessages").Count, Is.EqualTo(1)); + } + + [Test] + public void An_embedded_source_names_the_data_directory_setting() + { + sourceSettings.ConnectionString = null; + + var location = RavenReadOnlySourceLifecycle.Located(sourceSettings, SettingsRoot); + + Assert.That(location, Does.Contain("'ServiceControl/DbPath'"), "An operator told the embedded source cannot be read needs the setting that points at its data directory."); } [Test] public async Task Opening_the_source_twice_is_refused() { - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); await lifecycle.Open(); var exception = Assert.ThrowsAsync(async () => await lifecycle.Open()); @@ -223,7 +235,7 @@ public async Task An_embedded_open_that_cannot_start_leaves_nothing_behind() sourceSettings.ConnectionString = null; sourceSettings.DatabasePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n")); - var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); Assert.CatchAsync(async () => await lifecycle.Open()); @@ -237,7 +249,7 @@ public async Task An_embedded_open_that_cannot_start_leaves_nothing_behind() [Test] public async Task Generating_an_id_is_refused() { - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); await lifecycle.Open(); using var session = lifecycle.DocumentStore.OpenAsyncSession(new SessionOptions { Database = databaseName }); @@ -251,7 +263,7 @@ public async Task Generating_an_id_is_refused() [Test] public async Task A_patch_against_the_source_is_refused() { - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); await lifecycle.Open(); Assert.CatchAsync(async () => @@ -264,7 +276,7 @@ await lifecycle.DocumentStore.Operations.ForDatabase(databaseName).SendAsync( [Test] public async Task A_bulk_insert_into_the_source_is_refused() { - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); await lifecycle.Open(); Assert.CatchAsync(async () => @@ -279,7 +291,7 @@ public async Task A_bulk_insert_into_the_source_is_refused() [Test] public async Task Reconfiguring_expiry_on_the_source_is_refused() { - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); await lifecycle.Open(); Assert.CatchAsync(async () => @@ -294,7 +306,7 @@ await lifecycle.DocumentStore.Maintenance.ForDatabase(databaseName).SendAsync( [Test] public async Task Deleting_an_untracked_document_is_refused() { - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); await lifecycle.Open(); using var session = lifecycle.DocumentStore.OpenAsyncSession(new SessionOptions { Database = databaseName }); @@ -308,7 +320,7 @@ public async Task Deleting_an_untracked_document_is_refused() [Test] public async Task Reading_the_source_still_works() { - await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings); + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); await lifecycle.Open(); using var session = lifecycle.OpenSession(databaseName); @@ -323,6 +335,31 @@ public async Task Reading_the_source_still_works() }); } + [Test] + public async Task A_source_session_reads_a_message_body_attachment() + { + using (var session = bootstrapStore.OpenAsyncSession()) + { + using var stored = new MemoryStream(Encoding.UTF8.GetBytes("1")); + session.Advanced.Attachments.Store("FailedMessages/abc", RavenAttachmentsBodyStorage.AttachmentName, stored, "text/xml"); + await session.SaveChangesAsync(); + } + + await using var lifecycle = new RavenReadOnlySourceLifecycle(sourceSettings, SettingsRoot); + await lifecycle.Open(); + + using var sourceSession = lifecycle.OpenSession(databaseName); + using var attachment = await sourceSession.Advanced.Attachments.GetAsync("FailedMessages/abc", RavenAttachmentsBodyStorage.AttachmentName); + using var read = new MemoryStream(); + await attachment.Stream.CopyToAsync(read); + + Assert.Multiple(() => + { + Assert.That(Encoding.UTF8.GetString(read.ToArray()), Is.EqualTo("1"), "The body copy reads every attachment through this no-tracking session, so a read that needed tracking would need a second kind of source session."); + Assert.That(attachment.Details.ContentType, Is.EqualTo("text/xml")); + }); + } + async Task AssertAbsent(string documentId) { using var session = bootstrapStore.OpenAsyncSession(); diff --git a/src/ServiceControl.Persistence/DataMigration/HaltThreshold.cs b/src/ServiceControl.Persistence/DataMigration/HaltThreshold.cs new file mode 100644 index 0000000000..3c098fffdc --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/HaltThreshold.cs @@ -0,0 +1,17 @@ +namespace ServiceControl.Persistence.DataMigration; + +public static class HaltThreshold +{ + // Both must be exceeded: the floor protects a tiny category from one bad row, and the + // proportion protects a huge one from grinding through thousands of failures as "only a fraction". + public static bool Exceeded(long skippedCount, long totalCount, int percentThreshold, int minimumFloor) + { + if (skippedCount <= minimumFloor || totalCount == 0) + { + return false; + } + + var percent = skippedCount * 100m / totalCount; + return percent > percentThreshold; + } +} diff --git a/src/ServiceControl.Persistence/DataMigration/IMigrationCheckpointStore.cs b/src/ServiceControl.Persistence/DataMigration/IMigrationCheckpointStore.cs new file mode 100644 index 0000000000..5d20b9af13 --- /dev/null +++ b/src/ServiceControl.Persistence/DataMigration/IMigrationCheckpointStore.cs @@ -0,0 +1,40 @@ +namespace ServiceControl.Persistence.DataMigration; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +public enum MigrationCategoryState +{ + NotStarted, + InProgress, + Complete, + CompleteWithErrors, + Halted, + Abandoned +} + +///