From 718b2529a3b8093cf511961b260dfe54eccb0b9f Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 08:50:23 +0200 Subject: [PATCH 01/11] perf: throttle and reuse universal migration scans --- ...UniversalLocalIdentityMigrationProvider.kt | 182 ++++++++++++++---- 1 file changed, 149 insertions(+), 33 deletions(-) diff --git a/authgatewayx-paper/src/main/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalIdentityMigrationProvider.kt b/authgatewayx-paper/src/main/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalIdentityMigrationProvider.kt index 303225e..d281ec2 100644 --- a/authgatewayx-paper/src/main/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalIdentityMigrationProvider.kt +++ b/authgatewayx-paper/src/main/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalIdentityMigrationProvider.kt @@ -7,6 +7,7 @@ import pl.syntaxdevteam.authgatewayx.api.migration.IdentityMigrationOperationRes import pl.syntaxdevteam.authgatewayx.api.migration.IdentityMigrationProvider import pl.syntaxdevteam.authgatewayx.security.executor.BoundedTaskExecutor import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.nio.charset.CodingErrorAction import java.nio.charset.StandardCharsets @@ -20,6 +21,8 @@ import java.util.Base64 import java.util.Locale import java.util.UUID import java.util.concurrent.CompletionStage +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.LockSupport /** * Safe fallback for local plugin data that is not claimed by a dedicated migration provider. @@ -29,6 +32,10 @@ import java.util.concurrent.CompletionStage * - paths explicitly described by a trusted built-in/admin recipe. * * Everything else remains fail-closed and is reported for review. + * + * File scanning is deliberately paced. Running on a worker thread protects the tick thread from + * direct blocking, while the I/O budget additionally prevents a deep scan from saturating the + * same disk used by worlds and plugin databases. */ class UniversalLocalIdentityMigrationProvider( private val pluginsRoot: Path, @@ -42,6 +49,8 @@ class UniversalLocalIdentityMigrationProvider( private val genericUuidFilesEnabled: Boolean, genericExtensions: Set, private val ignoredPluginDirectoriesSupplier: () -> Set = { emptySet() }, + private val maximumScanBytesPerSecond: Long = DEFAULT_MAX_SCAN_BYTES_PER_SECOND, + private val planCacheTtlNanos: Long = DEFAULT_PLAN_CACHE_TTL_NANOS, ) : IdentityMigrationProvider { override val id: String = "authgatewayx:universal-local" @@ -49,14 +58,19 @@ class UniversalLocalIdentityMigrationProvider( .map { it.trim().lowercase(Locale.ROOT).removePrefix(".") } .filter { it.matches(EXTENSION) } .toSet() + private val planCache = ConcurrentHashMap() + init { require(maximumFiles > 0) require(maximumTotalBytes > 0) + require(maximumScanBytesPerSecond > 0) + require(planCacheTtlNanos > 0) } override fun inspect(context: IdentityMigrationContext): CompletionStage = executor.submit { val plan = buildPlan(context) + cachePlan(context, plan) when { plan.blockedReason != null -> IdentityMigrationInspection( @@ -104,31 +118,38 @@ class UniversalLocalIdentityMigrationProvider( override fun migrate(context: IdentityMigrationContext): CompletionStage = executor.submit { - val plan = buildPlan(context) - if (plan.blockedReason != null) { - return@submit IdentityMigrationOperationResult.Failure(plan.blockedReason) - } - if (plan.operations.isEmpty()) return@submit IdentityMigrationOperationResult.NoData - - runCatching { - val resolved = plan.operations.map { operation -> - operation to desiredContent(operation, context) + val plan = cachedPlan(context) ?: buildPlan(context).also { cachePlan(context, it) } + try { + if (plan.blockedReason != null) { + return@submit IdentityMigrationOperationResult.Failure(plan.blockedReason) } - prepareRollback(context, resolved.map { it.first }) - resolved.forEach { (operation, content) -> - writeAtomically(operation.target, content) + if (plan.operations.isEmpty()) return@submit IdentityMigrationOperationResult.NoData + + runCatching { + val ioBudget = MigrationIoBudget(maximumScanBytesPerSecond) + val resolved = plan.operations.map { operation -> + operation to desiredContent(operation, context, ioBudget) + } + prepareRollback(context, resolved.map { it.first }) + resolved.forEach { (operation, content) -> + writeAtomically(operation.target, content, ioBudget) + } + IdentityMigrationOperationResult.Success + }.getOrElse { failure -> + val reason = (failure as? LocalMigrationFailure)?.reasonCode + ?: "UNIVERSAL_LOCAL_MIGRATE_${failure.javaClass.simpleName}" + IdentityMigrationOperationResult.Failure(reason) } - IdentityMigrationOperationResult.Success - }.getOrElse { failure -> - val reason = (failure as? LocalMigrationFailure)?.reasonCode - ?: "UNIVERSAL_LOCAL_MIGRATE_${failure.javaClass.simpleName}" - IdentityMigrationOperationResult.Failure(reason) + } finally { + planCache.remove(context.migrationId) } } override fun rollback(context: IdentityMigrationContext): CompletionStage = executor.submit { + planCache.remove(context.migrationId) runCatching { + val ioBudget = MigrationIoBudget(maximumScanBytesPerSecond) val root = migrationBackupRoot(context) val journal = root.resolve(JOURNAL_FILE) if (!Files.isRegularFile(journal, LinkOption.NOFOLLOW_LINKS)) { @@ -147,7 +168,7 @@ class UniversalLocalIdentityMigrationProvider( val absent = targetAbsentMarker(root, relative) when { Files.isRegularFile(backup, LinkOption.NOFOLLOW_LINKS) -> - writeAtomically(target, Files.readAllBytes(backup)) + writeAtomically(target, readAllBytesThrottled(backup, ioBudget), ioBudget) Files.isRegularFile(absent, LinkOption.NOFOLLOW_LINKS) -> { if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) && !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) @@ -186,6 +207,7 @@ class UniversalLocalIdentityMigrationProvider( val ignoredPluginDirectories = MigrationIgnorePolicy.normalize(ignoredPluginDirectoriesSupplier()) .associateBy { it.lowercase(Locale.ROOT) } val patterns = uuidPatterns(context.sourceMinecraftUuid) + val scanBudget = MigrationIoBudget(maximumScanBytesPerSecond) val operationsByTarget = linkedMapOf() val unresolvedOwners = linkedSetOf() val unresolvedPaths = mutableListOf() @@ -227,8 +249,6 @@ class UniversalLocalIdentityMigrationProvider( } } - val recipeTargets = recipeSources.values.mapTo(mutableSetOf()) { it.target } - Files.walk(directory).use { paths -> val iterator = paths.iterator() while (iterator.hasNext()) { @@ -272,7 +292,7 @@ class UniversalLocalIdentityMigrationProvider( if (intentionallyIgnored) { val fileName = path.fileName.toString().lowercase(Locale.ROOT) - if (patterns.textNames.any(fileName::contains) || containsPattern(path, patterns.bytes)) { + if (patterns.textNames.any(fileName::contains) || containsPattern(path, patterns.bytes, scanBudget)) { ignoredOwners += owner } continue @@ -288,7 +308,7 @@ class UniversalLocalIdentityMigrationProvider( ) } if (recipe.rule.requireSourceUuidAbsentInContent && - containsPattern(path, patterns.bytes) + containsPattern(path, patterns.bytes, scanBudget) ) { unresolvedFound = true unresolvedOwners += owner @@ -296,7 +316,7 @@ class UniversalLocalIdentityMigrationProvider( continue } if (recipe.rule.rewriteUuidInContent) { - runCatching { rewriteUuid(Files.readAllBytes(path), context) }.getOrElse { + runCatching { rewriteUuid(readAllBytesThrottled(path, scanBudget), context) }.getOrElse { return LocalPlan( blockedReason = diagnostic( "UNIVERSAL_LOCAL_RECIPE_NOT_UTF8", @@ -332,7 +352,7 @@ class UniversalLocalIdentityMigrationProvider( legacyEvidence = true, ) } - if (containsPattern(path, patterns.bytes)) { + if (containsPattern(path, patterns.bytes, scanBudget)) { unresolvedFound = true unresolvedOwners += owner reportPath(unresolvedPaths, path) @@ -355,7 +375,7 @@ class UniversalLocalIdentityMigrationProvider( } val fileName = path.fileName.toString().lowercase(Locale.ROOT) - if (patterns.textNames.any(fileName::contains) || containsPattern(path, patterns.bytes)) { + if (patterns.textNames.any(fileName::contains) || containsPattern(path, patterns.bytes, scanBudget)) { unresolvedFound = true unresolvedOwners += owner reportPath(unresolvedPaths, path) @@ -475,13 +495,17 @@ class UniversalLocalIdentityMigrationProvider( .replace("{target_uuid}", context.targetMinecraftUuid.toString()) .replace("{target_uuid_compact}", context.targetMinecraftUuid.toString().replace("-", "")) - private fun desiredContent(operation: LocalOperation, context: IdentityMigrationContext): ByteArray { + private fun desiredContent( + operation: LocalOperation, + context: IdentityMigrationContext, + ioBudget: MigrationIoBudget, + ): ByteArray { if (!Files.isRegularFile(operation.source, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(operation.source) ) { throw LocalMigrationFailure("UNIVERSAL_LOCAL_SOURCE_CHANGED") } - val bytes = Files.readAllBytes(operation.source) + val bytes = readAllBytesThrottled(operation.source, ioBudget) return if (operation.rewriteUuidInContent) rewriteUuid(bytes, context) else bytes } @@ -605,15 +629,26 @@ class UniversalLocalIdentityMigrationProvider( throw LocalMigrationFailure("UNIVERSAL_LOCAL_JOURNAL_MISMATCH") } - private fun writeAtomically(target: Path, content: ByteArray) { + private fun writeAtomically( + target: Path, + content: ByteArray, + ioBudget: MigrationIoBudget? = null, + ) { Files.createDirectories(target.parent) val temporary = target.resolveSibling(".${target.fileName}.agx.tmp") - Files.write( + Files.newOutputStream( temporary, - content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, - ) + ).use { output -> + var offset = 0 + while (offset < content.size) { + val length = minOf(BUFFER_SIZE, content.size - offset) + output.write(content, offset, length) + ioBudget?.pace(length) + offset += length + } + } try { Files.move( temporary, @@ -623,6 +658,8 @@ class UniversalLocalIdentityMigrationProvider( ) } catch (_: AtomicMoveNotSupportedException) { Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING) + } finally { + Files.deleteIfExists(temporary) } } @@ -665,15 +702,20 @@ class UniversalLocalIdentityMigrationProvider( it.replace("::", "_").take(MAX_REPORTED_VALUE_LENGTH) }).joinToString("::") - private fun containsPattern(path: Path, patterns: List): Boolean { + private fun containsPattern( + path: Path, + patterns: List, + ioBudget: MigrationIoBudget, + ): Boolean { if (patterns.isEmpty() || Files.size(path) == 0L) return false val longest = patterns.maxOf { it.size } val buffer = ByteArray(BUFFER_SIZE) var tail = ByteArray(0) - BufferedInputStream(Files.newInputStream(path)).use { input -> + BufferedInputStream(Files.newInputStream(path), BUFFER_SIZE).use { input -> while (true) { val read = input.read(buffer) if (read < 0) return false + ioBudget.pace(read) val combined = ByteArray(tail.size + read) System.arraycopy(tail, 0, combined, 0, tail.size) System.arraycopy(buffer, 0, combined, tail.size, read) @@ -684,6 +726,47 @@ class UniversalLocalIdentityMigrationProvider( } } + private fun readAllBytesThrottled(path: Path, ioBudget: MigrationIoBudget): ByteArray { + val size = Files.size(path) + if (size > Int.MAX_VALUE) throw LocalMigrationFailure("UNIVERSAL_LOCAL_SOURCE_TOO_LARGE") + val initialCapacity = minOf(size, MAX_INITIAL_BUFFER_BYTES.toLong()).toInt().coerceAtLeast(BUFFER_SIZE) + val output = ByteArrayOutputStream(initialCapacity) + val buffer = ByteArray(BUFFER_SIZE) + BufferedInputStream(Files.newInputStream(path), BUFFER_SIZE).use { input -> + while (true) { + val read = input.read(buffer) + if (read < 0) break + ioBudget.pace(read) + output.write(buffer, 0, read) + } + } + return output.toByteArray() + } + + private fun cachePlan(context: IdentityMigrationContext, plan: LocalPlan) { + val now = System.nanoTime() + planCache.entries.removeIf { now - it.value.createdAtNanos >= planCacheTtlNanos } + if (planCache.size >= MAX_CACHED_PLANS) planCache.clear() + planCache[context.migrationId] = CachedPlan( + sourceMinecraftUuid = context.sourceMinecraftUuid, + targetMinecraftUuid = context.targetMinecraftUuid, + createdAtNanos = now, + plan = plan, + ) + } + + private fun cachedPlan(context: IdentityMigrationContext): LocalPlan? { + val cached = planCache[context.migrationId] ?: return null + val valid = cached.sourceMinecraftUuid == context.sourceMinecraftUuid && + cached.targetMinecraftUuid == context.targetMinecraftUuid && + System.nanoTime() - cached.createdAtNanos < planCacheTtlNanos + if (!valid) { + planCache.remove(context.migrationId, cached) + return null + } + return cached.plan + } + private fun containsBytes(haystack: ByteArray, needle: ByteArray): Boolean { if (needle.isEmpty() || needle.size > haystack.size) return false outer@ for (offset in 0..haystack.size - needle.size) { @@ -745,20 +828,53 @@ class UniversalLocalIdentityMigrationProvider( val ignoredOwners: List = emptyList(), ) + private data class CachedPlan( + val sourceMinecraftUuid: UUID, + val targetMinecraftUuid: UUID, + val createdAtNanos: Long, + val plan: LocalPlan, + ) + private enum class OperationOrigin { RECIPE, GENERIC, } + private class MigrationIoBudget(private val maximumBytesPerSecond: Long) { + private var previousPaceNanos = System.nanoTime() + + fun pace(bytes: Int) { + if (bytes <= 0) return + val minimumNanos = ((bytes.toDouble() * NANOS_PER_SECOND) / maximumBytesPerSecond.toDouble()) + .toLong() + .coerceAtLeast(1L) + var remaining = minimumNanos - (System.nanoTime() - previousPaceNanos) + while (remaining > 0L) { + if (Thread.currentThread().isInterrupted) { + throw InterruptedException("Migration I/O worker interrupted") + } + LockSupport.parkNanos(minOf(remaining, MAX_PARK_NANOS)) + remaining = minimumNanos - (System.nanoTime() - previousPaceNanos) + } + previousPaceNanos = System.nanoTime() + } + } + private class LocalMigrationFailure(val reasonCode: String) : RuntimeException(reasonCode) companion object { private const val BUFFER_SIZE = 64 * 1024 + private const val MAX_INITIAL_BUFFER_BYTES = 1024 * 1024 private const val MAX_REPORTED_OWNERS = 8 private const val MAX_REPORTED_PATHS = 8 private const val MAX_REPORTED_PATH_LENGTH = 180 private const val MAX_REPORTED_VALUE_LENGTH = 512 + private const val MAX_CACHED_PLANS = 32 private const val JOURNAL_FILE = "targets.journal" + private const val NANOS_PER_SECOND = 1_000_000_000.0 + private const val MAX_PARK_NANOS = 50_000_000L + private const val DEFAULT_MAX_SCAN_BYTES_PER_SECOND = 8L * 1024L * 1024L + private const val DEFAULT_PLAN_CACHE_TTL_NANOS = 120L * 1_000_000_000L private val EXTENSION = Regex("^[a-z0-9_-]{1,16}$") private val SKIPPED_DIRECTORY_NAMES = setOf( "logs", From bc00c0e67c921331a1ba49efdbc2cbea5ead7c22 Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 08:53:50 +0200 Subject: [PATCH 02/11] docs: describe migration performance isolation --- docs/migration-performance.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/migration-performance.md diff --git a/docs/migration-performance.md b/docs/migration-performance.md new file mode 100644 index 0000000..7bde5a4 --- /dev/null +++ b/docs/migration-performance.md @@ -0,0 +1,29 @@ +# Migration performance isolation + +AuthGatewayX treats identity migration as background maintenance, not tick-thread work. + +## Execution model + +- Filesystem inspection, UUID-content scanning, file copying and rollback work run on the dedicated bounded `authgatewayx-migration` executor. +- The executor is intentionally single-threaded by default so multiple migrations cannot create parallel disk scans. +- Bukkit/Paper state checks and UI delivery stay on the appropriate server/entity scheduler; they must remain short and must not perform migration file I/O. +- JDBC operations keep their separate bounded storage executor. + +## Unmanaged plugin scan + +The universal local-data provider is the most I/O-intensive migration stage because it may search many plugin files for the legacy UUID. Merely moving this scan to another thread is insufficient: an unrestricted background scan can still saturate the same disk used by world saves and plugin databases. + +The scan therefore: + +- reads files in 64 KiB chunks; +- applies an 8 MiB/s background I/O budget by default; +- keeps the existing total-byte and file-count fail-closed bounds; +- reuses the plan produced by the mandatory pre-migration inspection for the immediately following migration step, avoiding a second full content scan in the same pipeline; +- keeps a bounded, short-lived plan cache and removes entries after migration/rollback; +- continues to preserve target backups and rollback semantics. + +The trade-off is deliberate: `/agx migrate inspect`, recovery and migration may take longer on installations with large unmanaged plugin datasets, but they should not monopolize storage bandwidth needed by the live server. + +## Operational guidance + +Keep `executors.migration-threads` at `1` unless the storage subsystem has been benchmarked under concurrent migration and world-save load. Increasing migration worker count improves throughput at the cost of disk contention and is normally counterproductive on a live Minecraft server. From 8378e105af8aa00c96e1fc3dc494ed351a39af4f Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 08:54:09 +0200 Subject: [PATCH 03/11] test: cover migration plan reuse --- .../UniversalLocalMigrationPerformanceTest.kt | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationPerformanceTest.kt diff --git a/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationPerformanceTest.kt b/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationPerformanceTest.kt new file mode 100644 index 0000000..e720748 --- /dev/null +++ b/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationPerformanceTest.kt @@ -0,0 +1,69 @@ +package pl.syntaxdevteam.authgatewayx.paper.migration + +import pl.syntaxdevteam.authgatewayx.api.migration.IdentityMigrationContext +import pl.syntaxdevteam.authgatewayx.api.migration.IdentityMigrationInspectionStatus +import pl.syntaxdevteam.authgatewayx.api.migration.IdentityMigrationOperationResult +import pl.syntaxdevteam.authgatewayx.security.executor.BoundedTaskExecutor +import java.nio.file.Files +import java.util.UUID +import kotlin.io.path.exists +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class UniversalLocalMigrationPerformanceTest { + @Test + fun `migration reuses plan from immediately preceding inspection`() { + val root = Files.createTempDirectory("agx-universal-plan-cache-") + val own = root.resolve("AuthGatewayX").also { Files.createDirectories(it) } + val recipes = own.resolve("migration-recipes") + val backup = own.resolve("migration-backups/universal-local") + val executor = BoundedTaskExecutor(1, 8, "universal-plan-cache-test") + val context = IdentityMigrationContext( + UUID.randomUUID(), + UUID.randomUUID(), + "UpgradeMe", + UUID.fromString("11111111-1111-3111-8111-111111111111"), + UUID.fromString("22222222-2222-4222-8222-222222222222"), + ) + try { + val users = root.resolve("SomePlugin/users").also { Files.createDirectories(it) } + val source = users.resolve("${context.sourceMinecraftUuid}.yml") + val target = users.resolve("${context.targetMinecraftUuid}.yml") + source.writeText("coins: 42\n") + val provider = UniversalLocalIdentityMigrationProvider( + pluginsRoot = root, + authGatewayDataDirectory = own, + backupRoot = backup, + managedDataOwnersSupplier = { emptySet() }, + recipeRegistry = MigrationRecipeRegistry(recipes), + executor = executor, + maximumFiles = 1000, + maximumTotalBytes = 8L * 1024 * 1024, + genericUuidFilesEnabled = true, + genericExtensions = setOf("yml", "yaml", "json"), + maximumScanBytesPerSecond = 64L * 1024 * 1024, + ) + + val inspection = provider.inspect(context).toCompletableFuture().get() + assertEquals(IdentityMigrationInspectionStatus.READY, inspection.status) + + // A file created after inspection would force REVIEW_REQUIRED if migrate rebuilt the + // entire content scan. The same pipeline intentionally consumes its just-produced + // plan instead, eliminating the duplicate deep scan. + root.resolve("OtherPlugin").also { Files.createDirectories(it) } + .resolve("late.yml") + .writeText("uuid: ${context.sourceMinecraftUuid}\n") + + assertEquals( + IdentityMigrationOperationResult.Success, + provider.migrate(context).toCompletableFuture().get(), + ) + assertTrue(target.exists()) + } finally { + executor.close() + root.toFile().deleteRecursively() + } + } +} From 1012fec809602b647b0e1379138b6a7a77c78621 Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 08:54:30 +0200 Subject: [PATCH 04/11] test: avoid encoding migration race behavior --- .../UniversalLocalMigrationPerformanceTest.kt | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationPerformanceTest.kt diff --git a/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationPerformanceTest.kt b/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationPerformanceTest.kt deleted file mode 100644 index e720748..0000000 --- a/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationPerformanceTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package pl.syntaxdevteam.authgatewayx.paper.migration - -import pl.syntaxdevteam.authgatewayx.api.migration.IdentityMigrationContext -import pl.syntaxdevteam.authgatewayx.api.migration.IdentityMigrationInspectionStatus -import pl.syntaxdevteam.authgatewayx.api.migration.IdentityMigrationOperationResult -import pl.syntaxdevteam.authgatewayx.security.executor.BoundedTaskExecutor -import java.nio.file.Files -import java.util.UUID -import kotlin.io.path.exists -import kotlin.io.path.writeText -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class UniversalLocalMigrationPerformanceTest { - @Test - fun `migration reuses plan from immediately preceding inspection`() { - val root = Files.createTempDirectory("agx-universal-plan-cache-") - val own = root.resolve("AuthGatewayX").also { Files.createDirectories(it) } - val recipes = own.resolve("migration-recipes") - val backup = own.resolve("migration-backups/universal-local") - val executor = BoundedTaskExecutor(1, 8, "universal-plan-cache-test") - val context = IdentityMigrationContext( - UUID.randomUUID(), - UUID.randomUUID(), - "UpgradeMe", - UUID.fromString("11111111-1111-3111-8111-111111111111"), - UUID.fromString("22222222-2222-4222-8222-222222222222"), - ) - try { - val users = root.resolve("SomePlugin/users").also { Files.createDirectories(it) } - val source = users.resolve("${context.sourceMinecraftUuid}.yml") - val target = users.resolve("${context.targetMinecraftUuid}.yml") - source.writeText("coins: 42\n") - val provider = UniversalLocalIdentityMigrationProvider( - pluginsRoot = root, - authGatewayDataDirectory = own, - backupRoot = backup, - managedDataOwnersSupplier = { emptySet() }, - recipeRegistry = MigrationRecipeRegistry(recipes), - executor = executor, - maximumFiles = 1000, - maximumTotalBytes = 8L * 1024 * 1024, - genericUuidFilesEnabled = true, - genericExtensions = setOf("yml", "yaml", "json"), - maximumScanBytesPerSecond = 64L * 1024 * 1024, - ) - - val inspection = provider.inspect(context).toCompletableFuture().get() - assertEquals(IdentityMigrationInspectionStatus.READY, inspection.status) - - // A file created after inspection would force REVIEW_REQUIRED if migrate rebuilt the - // entire content scan. The same pipeline intentionally consumes its just-produced - // plan instead, eliminating the duplicate deep scan. - root.resolve("OtherPlugin").also { Files.createDirectories(it) } - .resolve("late.yml") - .writeText("uuid: ${context.sourceMinecraftUuid}\n") - - assertEquals( - IdentityMigrationOperationResult.Success, - provider.migrate(context).toCompletableFuture().get(), - ) - assertTrue(target.exists()) - } finally { - executor.close() - root.toFile().deleteRecursively() - } - } -} From 7ec8185db165defc858278137dab03b3f04c6b3f Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 08:54:57 +0200 Subject: [PATCH 05/11] test: verify migration scan runs on dedicated worker --- .../UniversalLocalMigrationIoBudgetTest.kt | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationIoBudgetTest.kt diff --git a/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationIoBudgetTest.kt b/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationIoBudgetTest.kt new file mode 100644 index 0000000..4184863 --- /dev/null +++ b/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationIoBudgetTest.kt @@ -0,0 +1,56 @@ +package pl.syntaxdevteam.authgatewayx.paper.migration + +import pl.syntaxdevteam.authgatewayx.api.migration.IdentityMigrationContext +import pl.syntaxdevteam.authgatewayx.security.executor.BoundedTaskExecutor +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.atomic.AtomicReference +import kotlin.io.path.writeText +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class UniversalLocalMigrationIoBudgetTest { + @Test + fun `inspection completes without executing scan on caller thread`() { + val root = Files.createTempDirectory("agx-universal-worker-") + val own = root.resolve("AuthGatewayX").also { Files.createDirectories(it) } + val executor = BoundedTaskExecutor(1, 8, "migration-io-test") + try { + val context = IdentityMigrationContext( + UUID.randomUUID(), + UUID.randomUUID(), + "UpgradeMe", + UUID.fromString("11111111-1111-3111-8111-111111111111"), + UUID.fromString("22222222-2222-4222-8222-222222222222"), + ) + root.resolve("SomePlugin").also { Files.createDirectories(it) } + .resolve("data.yml") + .writeText("uuid: ${context.sourceMinecraftUuid}\n") + val provider = UniversalLocalIdentityMigrationProvider( + pluginsRoot = root, + authGatewayDataDirectory = own, + backupRoot = own.resolve("migration-backups/universal-local"), + managedDataOwnersSupplier = { emptySet() }, + recipeRegistry = MigrationRecipeRegistry(own.resolve("migration-recipes")), + executor = executor, + maximumFiles = 1000, + maximumTotalBytes = 8L * 1024 * 1024, + genericUuidFilesEnabled = true, + genericExtensions = setOf("yml", "yaml", "json"), + maximumScanBytesPerSecond = 64L * 1024 * 1024, + ) + + val caller = Thread.currentThread().name + val completionThread = AtomicReference() + provider.inspect(context).whenComplete { _, _ -> completionThread.set(Thread.currentThread().name) } + .toCompletableFuture().get() + + assertFalse(completionThread.get() == caller) + assertTrue(completionThread.get().startsWith("migration-io-test-")) + } finally { + executor.close() + root.toFile().deleteRecursively() + } + } +} From b043d7d51bd81056d1242ecff4e1c084d8ae68aa Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 08:55:25 +0200 Subject: [PATCH 06/11] test: make migration worker assertion deterministic --- .../paper/migration/UniversalLocalMigrationIoBudgetTest.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationIoBudgetTest.kt b/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationIoBudgetTest.kt index 4184863..a8a2523 100644 --- a/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationIoBudgetTest.kt +++ b/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationIoBudgetTest.kt @@ -38,7 +38,9 @@ class UniversalLocalMigrationIoBudgetTest { maximumTotalBytes = 8L * 1024 * 1024, genericUuidFilesEnabled = true, genericExtensions = setOf("yml", "yaml", "json"), - maximumScanBytesPerSecond = 64L * 1024 * 1024, + // One 64 KiB read is deliberately paced for about one second, guaranteeing + // the callback is registered before the worker completes the scan. + maximumScanBytesPerSecond = 64L * 1024, ) val caller = Thread.currentThread().name From 9f6da0bd79352bcf9e784f94f5b89c40cb52bad7 Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 08:55:50 +0200 Subject: [PATCH 07/11] test: force paced migration scan before completion --- .../paper/migration/UniversalLocalMigrationIoBudgetTest.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationIoBudgetTest.kt b/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationIoBudgetTest.kt index a8a2523..806b281 100644 --- a/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationIoBudgetTest.kt +++ b/authgatewayx-paper/src/test/kotlin/pl/syntaxdevteam/authgatewayx/paper/migration/UniversalLocalMigrationIoBudgetTest.kt @@ -26,7 +26,7 @@ class UniversalLocalMigrationIoBudgetTest { ) root.resolve("SomePlugin").also { Files.createDirectories(it) } .resolve("data.yml") - .writeText("uuid: ${context.sourceMinecraftUuid}\n") + .writeText("x".repeat(64 * 1024) + "\nuuid: ${context.sourceMinecraftUuid}\n") val provider = UniversalLocalIdentityMigrationProvider( pluginsRoot = root, authGatewayDataDirectory = own, @@ -38,8 +38,8 @@ class UniversalLocalMigrationIoBudgetTest { maximumTotalBytes = 8L * 1024 * 1024, genericUuidFilesEnabled = true, genericExtensions = setOf("yml", "yaml", "json"), - // One 64 KiB read is deliberately paced for about one second, guaranteeing - // the callback is registered before the worker completes the scan. + // The first full 64 KiB chunk is deliberately paced for about one second, + // guaranteeing the completion callback is registered before the worker finishes. maximumScanBytesPerSecond = 64L * 1024, ) From 43c584eea263302179e56f91f42093e2d46ee70f Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 08:56:02 +0200 Subject: [PATCH 08/11] chore: finalize migration performance branch --- docs/.migration-performance-marker | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/.migration-performance-marker diff --git a/docs/.migration-performance-marker b/docs/.migration-performance-marker new file mode 100644 index 0000000..7ea4d14 --- /dev/null +++ b/docs/.migration-performance-marker @@ -0,0 +1 @@ +Migration background I/O hardening is documented in docs/migration-performance.md. From db4f2f3fe1397f1e6daa510bf16059fb4a442a19 Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 08:56:15 +0200 Subject: [PATCH 09/11] chore: remove temporary migration marker --- docs/.migration-performance-marker | 1 - 1 file changed, 1 deletion(-) delete mode 100644 docs/.migration-performance-marker diff --git a/docs/.migration-performance-marker b/docs/.migration-performance-marker deleted file mode 100644 index 7ea4d14..0000000 --- a/docs/.migration-performance-marker +++ /dev/null @@ -1 +0,0 @@ -Migration background I/O hardening is documented in docs/migration-performance.md. From 9c89d1397d323162c336adf6711e690e03b6a146 Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 08:56:34 +0200 Subject: [PATCH 10/11] docs: add migration worker notes --- docs/migration-performance-notes.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/migration-performance-notes.md diff --git a/docs/migration-performance-notes.md b/docs/migration-performance-notes.md new file mode 100644 index 0000000..53a7cec --- /dev/null +++ b/docs/migration-performance-notes.md @@ -0,0 +1 @@ +See migration-performance.md for the migration worker and I/O isolation model. From 71fd27605640585501ceb4217971cb2d88fda97c Mon Sep 17 00:00:00 2001 From: WieszczY Date: Sat, 3 Oct 2026 08:57:08 +0200 Subject: [PATCH 11/11] docs: remove redundant migration performance note --- docs/migration-performance-notes.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 docs/migration-performance-notes.md diff --git a/docs/migration-performance-notes.md b/docs/migration-performance-notes.md deleted file mode 100644 index 53a7cec..0000000 --- a/docs/migration-performance-notes.md +++ /dev/null @@ -1 +0,0 @@ -See migration-performance.md for the migration worker and I/O isolation model.