From 7ff1dccbea560a5ec6eae35534929ff75675993f Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Mon, 20 Jul 2026 11:06:46 +0200 Subject: [PATCH 1/5] feat: add release readiness verification and new benchmarking tools Introduced a `verify-release-readiness.js` script to validate release candidate readiness against performance reports. Added `FrozenCanonicalDigestBenchmark` to benchmark identity calculations for frozen nodes. Implemented `IncrementalMergingProcessorCapability`, `FrozenJsonPatch`, `PatchImpact`, and `PatchImpactAnalyzer` to enhance processing and patch impact evaluation capabilities. --- .github/scripts/verify-release-readiness.js | 33 + .github/workflows/release-rc.yml | 114 +- .github/workflows/release.yml | 16 +- CHANGELOG.md | 35 + build.gradle | 133 +- .../processor/PatchSequenceBenchmark.java | 2 +- .../FrozenCanonicalDigestBenchmark.java | 44 + src/main/java/blue/language/Blue.java | 2204 ++++++++++++++--- .../java/blue/language/BlueCachePolicy.java | 170 ++ .../java/blue/language/BlueCacheStats.java | 130 + src/main/java/blue/language/BlueViewPath.java | 22 +- .../java/blue/language/WeightedLruCache.java | 143 ++ .../conformance/ConformanceEngine.java | 70 +- ...IncrementalMergingProcessorCapability.java | 17 + .../processor/SequentialMergingProcessor.java | 21 +- src/main/java/blue/language/model/Node.java | 131 +- .../blue/language/model/NodeDeserializer.java | 26 +- .../language/processor/BatchPatchRecord.java | 7 + .../processor/BatchPatchTransaction.java | 40 +- .../language/processor/ChannelRunner.java | 6 +- .../processor/ContractEffectBuffer.java | 96 +- .../language/processor/ContractLoader.java | 128 +- .../processor/ContractMatchingService.java | 34 +- .../processor/ContractProcessorRegistry.java | 168 +- .../processor/DeclaredTypeLineageMatcher.java | 62 +- .../processor/DocumentProcessingRuntime.java | 104 +- .../language/processor/DocumentProcessor.java | 276 ++- .../blue/language/processor/GasMeter.java | 21 +- .../processor/ImmutableJsonPatch.java | 61 +- .../processor/ImmutablePatchPlanner.java | 72 + .../blue/language/processor/PatchImpact.java | 285 +++ .../processor/PatchImpactAnalyzer.java | 670 +++++ .../blue/language/processor/PatchInput.java | 94 + .../processor/PatchPlanningEngine.java | 172 +- .../processor/ProcessingMetricsSink.java | 360 +++ .../processor/ProcessingMetricsSnapshot.java | 45 + .../processor/ProcessingSnapshotManager.java | 15 + .../language/processor/ProcessorEngine.java | 8 + .../processor/ProcessorExecutionContext.java | 106 +- .../RecordingProcessingMetricsSink.java | 71 + .../language/processor/ScopeExecutor.java | 81 +- .../SequentialPatchPlanningSession.java | 22 +- .../language/processor/WorkingDocument.java | 124 +- .../processor/model/FrozenJsonPatch.java | 196 ++ .../processor/util/NodeCanonicalizer.java | 18 +- .../snapshot/FrozenCanonicalDigester.java | 735 ++++++ .../snapshot/FrozenCanonicalWriter.java | 619 +++++ .../blue/language/snapshot/FrozenNode.java | 717 +++++- .../snapshot/ResolvedReferenceCache.java | 881 ++++++- .../language/utils/FrozenTypeMatcher.java | 197 +- .../blue/language/utils/NodePathAccessor.java | 17 +- .../language/utils/TypeClassResolver.java | 73 +- .../blue/language/BlueCacheLifecycleTest.java | 1357 ++++++++++ .../blue/language/BlueCachePolicyTest.java | 39 + .../java/blue/language/BlueViewPathTest.java | 14 + .../blue/language/NodeDeserializerTest.java | 19 + .../blue/language/WeightedLruCacheTest.java | 60 + .../DocumentProcessorBoundaryTest.java | 346 +++ .../processor/DocumentProcessorGasTest.java | 9 +- .../processor/DocumentUpdateChannelTest.java | 3 +- .../processor/FrozenJsonPatchApiTest.java | 428 ++++ .../processor/ImmutableJsonPatchTest.java | 19 + .../PatchImpactIncrementalResolutionTest.java | 517 ++++ ...tchSequenceRandomizedDifferentialTest.java | 2 +- .../processor/PreparedPatchSequenceTest.java | 142 ++ .../processor/ProcessEmbeddedTest.java | 4 +- .../ProcessingSnapshotProviderPatchTest.java | 6 +- .../ProcessorOwnedCacheLifecycleTest.java | 171 ++ .../ProcessorPreviewOwnershipTest.java | 329 +++ .../RecordingProcessingMetricsSinkTest.java | 83 + .../snapshot/FrozenCanonicalDigesterTest.java | 453 ++++ .../FrozenNodeRetainedWeightTest.java | 99 + .../language/snapshot/FrozenNodeTest.java | 277 +++ .../ResolvedReferenceCacheContractTest.java | 359 +++ .../FrozenTypeMatcherCachePolicyTest.java | 74 + .../language/utils/NodePathAccessorTest.java | 9 + .../language/utils/TypeClassResolverTest.java | 29 + tools/check-binary-api.sh | 5 + tools/check_binary_api.py | 276 +++ tools/sanitize-jmh-results.js | 21 + 80 files changed, 14342 insertions(+), 700 deletions(-) create mode 100644 .github/scripts/verify-release-readiness.js create mode 100644 src/jmh/java/blue/language/snapshot/FrozenCanonicalDigestBenchmark.java create mode 100644 src/main/java/blue/language/BlueCachePolicy.java create mode 100644 src/main/java/blue/language/BlueCacheStats.java create mode 100644 src/main/java/blue/language/WeightedLruCache.java create mode 100644 src/main/java/blue/language/merge/IncrementalMergingProcessorCapability.java create mode 100644 src/main/java/blue/language/processor/PatchImpact.java create mode 100644 src/main/java/blue/language/processor/PatchImpactAnalyzer.java create mode 100644 src/main/java/blue/language/processor/PatchInput.java create mode 100644 src/main/java/blue/language/processor/ProcessingMetricsSnapshot.java create mode 100644 src/main/java/blue/language/processor/RecordingProcessingMetricsSink.java create mode 100644 src/main/java/blue/language/processor/model/FrozenJsonPatch.java create mode 100644 src/main/java/blue/language/snapshot/FrozenCanonicalDigester.java create mode 100644 src/main/java/blue/language/snapshot/FrozenCanonicalWriter.java create mode 100644 src/test/java/blue/language/BlueCacheLifecycleTest.java create mode 100644 src/test/java/blue/language/BlueCachePolicyTest.java create mode 100644 src/test/java/blue/language/WeightedLruCacheTest.java create mode 100644 src/test/java/blue/language/processor/FrozenJsonPatchApiTest.java create mode 100644 src/test/java/blue/language/processor/PatchImpactIncrementalResolutionTest.java create mode 100644 src/test/java/blue/language/processor/ProcessorOwnedCacheLifecycleTest.java create mode 100644 src/test/java/blue/language/processor/ProcessorPreviewOwnershipTest.java create mode 100644 src/test/java/blue/language/processor/RecordingProcessingMetricsSinkTest.java create mode 100644 src/test/java/blue/language/snapshot/FrozenCanonicalDigesterTest.java create mode 100644 src/test/java/blue/language/snapshot/FrozenNodeRetainedWeightTest.java create mode 100644 src/test/java/blue/language/utils/FrozenTypeMatcherCachePolicyTest.java create mode 100644 src/test/java/blue/language/utils/TypeClassResolverTest.java create mode 100755 tools/check-binary-api.sh create mode 100644 tools/check_binary_api.py create mode 100644 tools/sanitize-jmh-results.js diff --git a/.github/scripts/verify-release-readiness.js b/.github/scripts/verify-release-readiness.js new file mode 100644 index 0000000..bc95c5b --- /dev/null +++ b/.github/scripts/verify-release-readiness.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); + +const REPORT = 'docs/performance/results/order-scale-language-summary.json'; +const expectedVersion = process.env.RELEASE_VERSION; + +if (!expectedVersion) { + throw new Error('RELEASE_VERSION is required'); +} + +const summary = JSON.parse(fs.readFileSync(REPORT, 'utf8')); +const candidate = summary.candidate || {}; +const expectedCoordinate = `blue.language:blue-language-java:${expectedVersion}`; +const failures = []; + +if (candidate.coordinate !== expectedCoordinate) { + failures.push(`candidate coordinate must be ${expectedCoordinate}`); +} +if (candidate.releaseReady !== true) { + failures.push('candidate.releaseReady must be true'); +} +if (candidate.verdict !== 'YES') { + failures.push('candidate.verdict must be YES'); +} + +if (failures.length > 0) { + console.error(`Release authorization failed in ${REPORT}:`); + failures.forEach((failure) => console.error(`- ${failure}`)); + process.exit(1); +} + +console.log(`Release authorization confirmed for ${expectedCoordinate}`); diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml index 3e0b9a6..1a5063d 100644 --- a/.github/workflows/release-rc.yml +++ b/.github/workflows/release-rc.yml @@ -6,17 +6,30 @@ on: - next paths: - '.cz.toml' + - '.github/**' + - 'CHANGELOG.md' + - 'LICENSE*' + - 'README*' - 'build.gradle' - - 'settings.gradle.kts' + - 'settings.gradle*' + - 'docs/**' - 'gradle.properties' - 'gradle/wrapper/**' - 'gradlew' - 'gradlew.bat' - 'src/**' + - 'tools/**' + +permissions: + contents: read env: CI: true - GITHUB_TOKEN: ${{ secrets.WORKFLOW_PAT }} + BLUE_RELEASE_CHANNEL: rc + +concurrency: + group: release-rc-${{ github.ref }} + cancel-in-progress: false jobs: ReleaseRC: @@ -27,7 +40,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.WORKFLOW_PAT }} + persist-credentials: false - name: Fetch master and tags run: git fetch origin master:refs/remotes/origin/master --tags @@ -56,14 +69,100 @@ jobs: id: version run: node .github/scripts/prepare-rc-release.js + - name: Verify release authorization + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: node .github/scripts/verify-release-readiness.js + - name: Commit and tag RC version run: | git add .cz.toml - git commit -m "chore: release ${{ steps.version.outputs.version }}" + git commit --allow-empty -m "chore: release ${{ steps.version.outputs.version }}" git tag -a "v${{ steps.version.outputs.version }}" -m "Release ${{ steps.version.outputs.version }}" - name: Execute Gradle build - run: ./gradlew clean build + run: >- + ./gradlew clean build identityDifferentialTest + patchSequenceDifferentialTest memoryIntegrationTest cacheLifecycleTest + jmhClasses sourceReleaseArchive + + - name: Verify binary API compatibility + run: | + baseline_root="$(mktemp -d)" + master_dir="${baseline_root}/master" + previous_rc_dir="${baseline_root}/previous-rc" + current_version='${{ steps.version.outputs.version }}' + rc_series="${current_version%-rc.*}" + previous_rc_tag='' + while IFS= read -r candidate_tag; do + if [[ "$candidate_tag" != "v${current_version}" ]]; then + previous_rc_tag="$candidate_tag" + break + fi + done < <(git tag --list "v${rc_series}-rc.*" --sort=-version:refname) + + git worktree add --detach "$master_dir" origin/master + if [[ -n "$previous_rc_tag" ]]; then + git worktree add --detach "$previous_rc_dir" "$previous_rc_tag" + fi + cleanup() { + git worktree remove --force "$master_dir" || true + if [[ -n "$previous_rc_tag" ]]; then + git worktree remove --force "$previous_rc_dir" || true + fi + } + trap cleanup EXIT + + BLUE_RELEASE_CHANNEL=stable "$master_dir/gradlew" -p "$master_dir" jar --no-daemon + if [[ -n "$previous_rc_tag" ]]; then + BLUE_RELEASE_CHANNEL=rc "$previous_rc_dir/gradlew" -p "$previous_rc_dir" jar --no-daemon + fi + mapfile -t master_jars < <(find "$master_dir/build/libs" -maxdepth 1 -type f \ + -name 'blue-language-java-*.jar' \ + ! -name '*-sources.jar' ! -name '*-javadoc.jar' ! -name '*-jmh.jar') + mapfile -t candidate_jars < <(find build/libs -maxdepth 1 -type f \ + -name 'blue-language-java-*.jar' \ + ! -name '*-sources.jar' ! -name '*-javadoc.jar' ! -name '*-jmh.jar') + test "${#master_jars[@]}" -eq 1 + test "${#candidate_jars[@]}" -eq 1 + tools/check-binary-api.sh \ + "${master_jars[0]}" \ + "${candidate_jars[0]}" \ + build/reports/binary-api/master-to-candidate.txt + if [[ -n "$previous_rc_tag" ]]; then + mapfile -t previous_rc_jars < <(find "$previous_rc_dir/build/libs" -maxdepth 1 -type f \ + -name 'blue-language-java-*.jar' \ + ! -name '*-sources.jar' ! -name '*-javadoc.jar' ! -name '*-jmh.jar') + test "${#previous_rc_jars[@]}" -eq 1 + tools/check-binary-api.sh \ + "${previous_rc_jars[0]}" \ + "${candidate_jars[0]}" \ + build/reports/binary-api/previous-rc-to-candidate.txt + printf 'Previous RC baseline: %s\n' "$previous_rc_tag" \ + > build/reports/binary-api/previous-rc-baseline.txt + else + printf 'Previous RC baseline: none; master is the only baseline\n' \ + > build/reports/binary-api/previous-rc-baseline.txt + fi + + # Publish the unique version reservation before any remote artifact upload. + # A failed release can then advance to a new RC instead of reusing a + # coordinate that a previous attempt may already have uploaded. + - name: Push release commit and tag + env: + GH_TOKEN: ${{ secrets.WORKFLOW_PAT }} + run: | + gh auth setup-git + git push origin HEAD:next --follow-tags + + - name: Verify immutable release tag provenance + run: | + release_commit="$(git rev-parse HEAD)" + local_tag_commit="$(git rev-parse 'v${{ steps.version.outputs.version }}^{}')" + remote_tag_commit="$(git ls-remote origin 'refs/tags/v${{ steps.version.outputs.version }}^{}' | awk '{print $1}')" + test -n "$remote_tag_commit" + test "$release_commit" = "$local_tag_commit" + test "$release_commit" = "$remote_tag_commit" - name: Execute Gradle publish run: ./gradlew publish @@ -78,9 +177,6 @@ jobs: JRELEASER_GPG_SECRET_KEY: ${{ secrets.GPG_SECRET_KEY }} run: ./gradlew jreleaserFullRelease - - name: Push release commit and tag - run: git push origin HEAD:next --follow-tags - - name: Archive artifacts uses: actions/upload-artifact@v4 if: always() @@ -89,4 +185,6 @@ jobs: path: | build/libs build/publications + build/release + build/reports/binary-api build/jreleaser diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b72ab59..4604039 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,17 +3,25 @@ name: Release on: workflow_dispatch: +permissions: + contents: read + +concurrency: + group: release-stable + cancel-in-progress: false + jobs: Release: runs-on: ubuntu-latest env: CI: true + BLUE_RELEASE_CHANNEL: stable steps: - name: Check out uses: actions/checkout@v4 with: fetch-depth: 0 - token: ${{ secrets.WORKFLOW_PAT }} + persist-credentials: false - name: Check if branch is master run: | @@ -38,7 +46,10 @@ jobs: uses: gradle/gradle-build-action@v2 - name: Execute Gradle build - run: ./gradlew clean build + run: >- + ./gradlew clean build identityDifferentialTest + patchSequenceDifferentialTest memoryIntegrationTest cacheLifecycleTest + jmhClasses sourceReleaseArchive - name: Execute Gradle publish run: ./gradlew publish @@ -61,4 +72,5 @@ jobs: path: | build/libs build/publications + build/release build/jreleaser diff --git a/CHANGELOG.md b/CHANGELOG.md index e40654b..d138c31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +## Unreleased (next 3.1.0 release candidate) + +### Feat + +- add immutable `FrozenJsonPatch` APIs for direct frozen patch-value handoff +- add configurable per-runtime cache policy, cache statistics, and idempotent runtime close +- add production-path processing metrics snapshots and conservative patch-impact classification + +### Performance + +- stream supported strict frozen canonical inputs directly into BlueId digests while retaining + the generic JCS fallback and compatibility oracle +- reuse resolved metadata for dependency-proven basic scalar typed-leaf replacements +- bound reloadable derived snapshots, aliases, recent-processing state, shared verified-reference + acceleration, and structural interning while preserving authoritative and active pinned evidence +- replace hot implicit decimal/index regex compilation with exact ASCII scans + +### Compatibility + +- retain all existing mutable patch APIs and Java 8 bytecode targeting +- preserve full-resolution fallbacks for schema, fixed-value type, reference, collection, + contracts-changing, custom-merger, and unknown-capability cases +- add reproducible source-release archives and JVM descriptor compatibility reporting + +### Fix + +- keep nested transient planning scopes from closing parent reference state +- serialize shared processor-registry and type-resolution updates against processing and reject + lock upgrades instead of deadlocking +- defensively own mutable raw map, list, and array payloads while preserving Jackson/JCS enum + and array behavior +- drain work admitted through `Blue` runtime APIs during close and reject new or re-entrant + cache-sensitive work +- isolate retained conformance views from refreshed cache generations + ## v2.0.0 (2026-05-13) ### Feat diff --git a/build.gradle b/build.gradle index 7242b73..2ac3143 100644 --- a/build.gradle +++ b/build.gradle @@ -13,7 +13,22 @@ plugins { } group = "blue.language" -version = determineProjectVersion() +version = project.findProperty('releaseVersion') ?: determineProjectVersion() + +def releaseChannel = System.getenv('BLUE_RELEASE_CHANNEL') +if (releaseChannel != null && !['rc', 'stable'].contains(releaseChannel)) { + throw new GradleException("BLUE_RELEASE_CHANNEL must be either 'rc' or 'stable'") +} +def releaseVersion = project.version.toString() +if (releaseChannel == 'rc' && !(releaseVersion ==~ /\d+\.\d+\.\d+-rc\.\d+/)) { + throw new GradleException( + "BLUE_RELEASE_CHANNEL=rc requires an x.y.z-rc.n version, found '${releaseVersion}'") +} +if (releaseChannel == 'stable' && !(releaseVersion ==~ /\d+\.\d+\.\d+/)) { + throw new GradleException( + "BLUE_RELEASE_CHANNEL=stable requires an x.y.z version, found '${releaseVersion}'") +} +def isReleaseCandidate = releaseChannel == 'rc' base { archivesName = "blue-language-java" @@ -104,6 +119,7 @@ tasks.register('identityDifferentialTest', Test) { includeTestsMatching 'blue.language.utils.BlueIdCalculatorTest' includeTestsMatching 'blue.language.snapshot.FrozenNodeTest' includeTestsMatching 'blue.language.snapshot.FrozenNodeStructuralInternerTest' + includeTestsMatching 'blue.language.snapshot.FrozenCanonicalDigesterTest' } } @@ -128,6 +144,20 @@ tasks.register('memoryIntegrationTest', Test) { } } +tasks.register('cacheLifecycleTest', Test) { + configureFocusedTest(delegate) + description = 'Runs weighted-cache, reference-cache, and runtime lifecycle contracts.' + filter { + includeTestsMatching 'blue.language.BlueCacheLifecycleTest' + includeTestsMatching 'blue.language.BlueCachePolicyTest' + includeTestsMatching 'blue.language.WeightedLruCacheTest' + includeTestsMatching 'blue.language.processor.ProcessorOwnedCacheLifecycleTest' + includeTestsMatching 'blue.language.snapshot.FrozenNodeRetainedWeightTest' + includeTestsMatching 'blue.language.snapshot.ResolvedReferenceCacheContractTest' + includeTestsMatching 'blue.language.utils.FrozenTypeMatcherCachePolicyTest' + } +} + jmh { includeTests = true jmhVersion = '1.37' @@ -159,6 +189,96 @@ tasks.withType(GenerateModuleMetadata) { enabled = false } +def sourceReleaseMetadataDir = layout.buildDirectory.dir('generated/source-release-metadata') +def sourceReleaseChecksumFile = layout.buildDirectory.file( + "release/blue-language-java-${project.version}-source-release.zip.sha256") +tasks.register('generateSourceReleaseMetadata') { + inputs.file('.cz.toml') + inputs.property('releaseVersion', project.version.toString()) + outputs.file(sourceReleaseMetadataDir.map { it.file('.cz.toml') }) + doLast { + def output = sourceReleaseMetadataDir.get().file('.cz.toml').asFile + output.parentFile.mkdirs() + output.text = file('.cz.toml').getText('UTF-8').replaceFirst( + /(?m)^version\s*=\s*"[^"]+"/, + "version = \"${project.version}\"") + } +} + +tasks.register('sourceReleaseArchive', Zip) { + group = 'distribution' + description = 'Creates a reproducible, metadata-free source archive for public release review.' + archiveBaseName = 'blue-language-java' + archiveVersion = project.version + archiveClassifier = 'source-release' + destinationDirectory = layout.buildDirectory.dir('release') + preserveFileTimestamps = false + reproducibleFileOrder = true + dependsOn tasks.named('generateSourceReleaseMetadata') + outputs.file(sourceReleaseChecksumFile) + eachFile { details -> + details.permissions { permissions -> + permissions.unix(details.path.endsWith('/gradlew') || details.path.endsWith('.sh') + ? 0755 + : 0644) + } + } + + into("blue-language-java-${project.version}") { + from(rootDir) { + include 'CHANGELOG.md' + include 'LICENSE*' + include 'README*' + include 'build.gradle' + include 'settings.gradle*' + include 'gradle.properties' + include 'gradlew' + include 'gradlew.bat' + include 'gradle/**' + include '.github/**' + include 'docs/**' + include 'src/**' + include 'tools/**' + + exclude '**/.DS_Store' + exclude '**/._*' + exclude '**/*.jfr' + exclude '**/*.hprof' + exclude '**/*.heapdump' + exclude '**/*.db' + exclude '**/*.sqlite*' + exclude '**/node_modules/**' + exclude '**/.gradle/**' + exclude '**/build/**' + exclude '**/*.zip' + exclude '**/*.tar' + exclude '**/*.tar.gz' + exclude '**/*.tgz' + } + from(sourceReleaseMetadataDir) { + include '.cz.toml' + } + } + + doLast { + def archive = archiveFile.get().asFile + def digest = java.security.MessageDigest.getInstance('SHA-256') + archive.withInputStream { input -> + byte[] buffer = new byte[8192] + int read + while ((read = input.read(buffer)) != -1) { + digest.update(buffer, 0, read) + } + } + def hash = digest.digest().collect { + String.format('%02x', ((byte) it) & 0xff) + }.join() + def checksum = sourceReleaseChecksumFile.get().asFile + checksum.parentFile.mkdirs() + checksum.setText("${hash} ${archive.name}\n", 'UTF-8') + } +} + publishing { publications { maven(MavenPublication) { @@ -215,6 +335,17 @@ if (System.getenv('CI')) { description = 'Java client library for Blue Language' copyright = '© 2024 Blue Company. Licensed under the MIT License' } + if (isReleaseCandidate) { + release { + github { + // The RC workflow creates, pushes, and verifies the annotated + // tag before upload. Stable releases retain JReleaser defaults. + skipTag = true + prerelease.enabled = true + makeLatest = 'false' + } + } + } deploy { maven { diff --git a/src/jmh/java/blue/language/processor/PatchSequenceBenchmark.java b/src/jmh/java/blue/language/processor/PatchSequenceBenchmark.java index 275278e..f92df1f 100644 --- a/src/jmh/java/blue/language/processor/PatchSequenceBenchmark.java +++ b/src/jmh/java/blue/language/processor/PatchSequenceBenchmark.java @@ -80,7 +80,7 @@ public Node publicAtomicBatch(SequenceState state) { @State(Scope.Thread) public static class SequenceState { - @Param({"1", "8", "64"}) + @Param({"1", "8", "64", "128"}) public int patchCount; @Param({"medium-sibling", "medium-repeated", "deep-sibling", "deep-repeated"}) diff --git a/src/jmh/java/blue/language/snapshot/FrozenCanonicalDigestBenchmark.java b/src/jmh/java/blue/language/snapshot/FrozenCanonicalDigestBenchmark.java new file mode 100644 index 0000000..14a5d5a --- /dev/null +++ b/src/jmh/java/blue/language/snapshot/FrozenCanonicalDigestBenchmark.java @@ -0,0 +1,44 @@ +package blue.language.snapshot; + +import blue.language.model.Node; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; + +/** + * Compares the frozen-native streaming identity path with the retained generic + * map/list + Jackson + JCS compatibility oracle over increasingly wide roots. + */ +@State(Scope.Thread) +public class FrozenCanonicalDigestBenchmark { + + @Param({"50", "500", "5000", "25000"}) + public int width; + + private FrozenNode root; + + @Setup(Level.Trial) + public void setUp() { + Node document = new Node(); + for (int index = 0; index < width; index++) { + document.properties("field-" + index, + new Node().properties( + "status", new Node().value("value-" + index), + "sequence", new Node().value(index))); + } + root = FrozenNode.fromNode(document); + } + + @Benchmark + public String streamingFrozenIdentity() { + return FrozenCanonicalDigester.calculateBlueId(root); + } + + @Benchmark + public String genericJcsOracleIdentity() { + return FrozenCanonicalDigester.calculateGenericOracle(root); + } +} diff --git a/src/main/java/blue/language/Blue.java b/src/main/java/blue/language/Blue.java index e89ac29..0705161 100644 --- a/src/main/java/blue/language/Blue.java +++ b/src/main/java/blue/language/Blue.java @@ -7,6 +7,7 @@ import blue.language.dictionary.ExportContext; import blue.language.dictionary.TypeDictionary; import blue.language.merge.Merger; +import blue.language.merge.IncrementalMergingProcessorCapability; import blue.language.merge.MergingProcessor; import blue.language.merge.NodeResolver; import blue.language.merge.processor.*; @@ -37,6 +38,7 @@ import blue.language.utils.limits.ExcludedPathLimits; import blue.language.utils.limits.Limits; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -60,9 +62,17 @@ import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; import static blue.language.utils.limits.Limits.NO_LIMITS; -public class Blue implements NodeResolver { +public class Blue implements NodeResolver, AutoCloseable { private static final int RECENT_PROCESSING_DOCUMENT_SNAPSHOT_LIMIT = 32; + private static final String PINNED_SNAPSHOT_CACHE = "pinnedAuthoritativeSnapshots"; + private static final String DERIVED_SNAPSHOT_CACHE = "derivedResolvedSnapshots"; + private static final String CANONICAL_ALIAS_CACHE = "canonicalAliases"; + private static final String RECENT_PROCESSING_CACHE = "recentProcessingSnapshots"; + private static final String VERIFIED_REFERENCE_CACHE = "verifiedReferences"; + private static final String TRANSIENT_REFERENCE_CACHE = "transientTrustedReferences"; + private static final String STRUCTURAL_INTERNER_CACHE = "resolvedStructuralInterner"; + private static final String PROCESSOR_PLAN_CACHE = "processorPlans"; private NodeProvider nodeProvider; private NodeProvider originalNodeProvider; @@ -71,43 +81,106 @@ public class Blue implements NodeResolver { private Map preprocessingAliases = new HashMap<>(); private Limits globalLimits = NO_LIMITS; private DocumentProcessor documentProcessor; - private final ConcurrentMap resolvedSnapshotsByBlueId = new ConcurrentHashMap<>(); + private boolean documentProcessorOwned; + private final BlueCachePolicy cachePolicy; + private final ConcurrentMap pinnedSnapshotsByBlueId = new ConcurrentHashMap<>(); private final ConcurrentMap - resolvedSnapshotsByCanonicalRepresentation = new ConcurrentHashMap<>(); + pinnedSnapshotsByCanonicalRepresentation = new ConcurrentHashMap<>(); + private final WeightedLruCache + derivedSnapshotsByCanonicalRepresentation; + private final WeightedLruCache> + derivedSnapshotsByBlueId; private final ConcurrentMap externalContractTypeNodes = new ConcurrentHashMap<>(); - private final List recentProcessingDocumentSnapshots = new ArrayList<>(); - private final ResolvedReferenceCache resolvedReferenceCache = new ResolvedReferenceCache(); + private final WeightedLruCache + recentProcessingDocumentSnapshots; + private final ResolvedReferenceCache resolvedReferenceCache; private final DictionaryRegistry dictionaryRegistry = new DictionaryRegistry(); private final Set managedProcessorConformanceEngines = Collections.newSetFromMap(new WeakHashMap()); + private final Object lifecycleLock = new Object(); + private final ThreadLocal activeProcessingCacheStamp = + new ThreadLocal<>(); + private final ThreadLocal directCacheOperationDepth = new ThreadLocal<>(); + private volatile ProcessingMetricsSink lifecycleMetricsSink = ProcessingMetricsSink.NOOP; + private volatile boolean closed; + private volatile boolean closeInProgress; + private Thread closingThread; + private Throwable lifecycleCloseFailure; + private long pinnedSnapshotWeightBytes; + private long pinnedSnapshotHighWaterBytes; + private long processorPlanCacheHighWaterBytes; + /** Guarded by lifecycleLock. Advances whenever runtime-owned caches are invalidated. */ + private long runtimeCacheGeneration; + /** Guarded by lifecycleLock. Replaced whenever the active processor/configuration changes. */ + private Object processorOwnerToken = new Object(); + /** Guarded by lifecycleLock; excludes provider/merger invalidation from direct resolution. */ + private int activeDirectCacheOperations; + /** Guarded by lifecycleLock; counts Blue wrapper calls through their final cache publication. */ + private int activeProcessingOperations; + /** Guarded by lifecycleLock; prevents new work from entering an invalidation handoff. */ + private boolean cacheInvalidationInProgress; + /** Guarded by lifecycleLock; identifies unsupported same-thread invalidation reentry. */ + private Thread cacheInvalidationThread; public Blue() { - this(node -> null); + this(node -> null, null, null, BlueCachePolicy.boundedDefaults()); } public Blue(NodeProvider nodeProvider) { - this.originalNodeProvider = nodeProvider; - this.nodeProvider = NodeProviderWrapper.wrap(nodeProvider); - this.mergingProcessor = createDefaultNodeProcessor(); - this.documentProcessor = createDefaultDocumentProcessor(); + this(nodeProvider, null, null, BlueCachePolicy.boundedDefaults()); } public Blue(NodeProvider nodeProvider, MergingProcessor mergingProcessor) { - this(nodeProvider, mergingProcessor, null); + this(nodeProvider, mergingProcessor, null, BlueCachePolicy.boundedDefaults()); } public Blue(NodeProvider nodeProvider, TypeClassResolver typeClassResolver) { - this(nodeProvider, null, typeClassResolver); + this(nodeProvider, null, typeClassResolver, BlueCachePolicy.boundedDefaults()); } public Blue(NodeProvider nodeProvider, MergingProcessor mergingProcessor, TypeClassResolver typeClassResolver) { + this(nodeProvider, mergingProcessor, typeClassResolver, BlueCachePolicy.boundedDefaults()); + } + + /** Creates a default runtime with explicit bounded acceleration-cache policy. */ + public static Blue withCachePolicy(BlueCachePolicy cachePolicy) { + return new Blue(node -> null, null, null, cachePolicy); + } + + /** + * Additive constructor for hosts that need explicit per-runtime cache bounds. + * Existing constructors continue to use {@link BlueCachePolicy#boundedDefaults()}. + */ + public Blue(NodeProvider nodeProvider, + MergingProcessor mergingProcessor, + TypeClassResolver typeClassResolver, + BlueCachePolicy cachePolicy) { this.originalNodeProvider = nodeProvider; this.nodeProvider = NodeProviderWrapper.wrap(nodeProvider); this.mergingProcessor = mergingProcessor != null ? mergingProcessor : createDefaultNodeProcessor(); this.typeClassResolver = typeClassResolver; + this.cachePolicy = Objects.requireNonNull(cachePolicy, "cachePolicy"); + this.derivedSnapshotsByCanonicalRepresentation = new WeightedLruCache<>( + cachePolicy.derivedSnapshotMaxEntries(), + cachePolicy.derivedSnapshotMaxWeightBytes(), + cachePolicy.maximumDerivedEntryWeightBytes(), + Blue::approximateSnapshotWeightBytes); + this.derivedSnapshotsByBlueId = new WeightedLruCache<>( + cachePolicy.canonicalAliasMaxEntries(), + cachePolicy.canonicalAliasMaxWeightBytes(), + Math.min(cachePolicy.maximumDerivedEntryWeightBytes(), 512L), + ignored -> 64L); + this.recentProcessingDocumentSnapshots = new WeightedLruCache<>( + Math.min(RECENT_PROCESSING_DOCUMENT_SNAPSHOT_LIMIT, + cachePolicy.derivedSnapshotMaxEntries()), + cachePolicy.derivedSnapshotMaxWeightBytes(), + cachePolicy.maximumDerivedEntryWeightBytes(), + Blue::approximateSnapshotWeightBytes); + this.resolvedReferenceCache = new ResolvedReferenceCache(cachePolicy); this.documentProcessor = createDefaultDocumentProcessor(); + this.documentProcessorOwned = true; } public Node resolve(Node node) { @@ -116,9 +189,14 @@ public Node resolve(Node node) { @Override public Node resolve(Node node, Limits limits) { - Limits effectiveLimits = combineWithGlobalLimits(limits); - Merger merger = new Merger(mergingProcessor, nodeProvider, resolvedReferenceCache); - return merger.resolve(node, effectiveLimits); + beginDirectCacheOperation(); + try { + Limits effectiveLimits = combineWithGlobalLimits(limits); + Merger merger = new Merger(mergingProcessor, nodeProvider, resolvedReferenceCache); + return merger.resolve(node, effectiveLimits); + } finally { + endDirectCacheOperation(); + } } public Node resolvePreservingPaths(Node node, Collection preservedPaths) { @@ -126,28 +204,34 @@ public Node resolvePreservingPaths(Node node, Collection preservedPaths) } public Node resolvePreservingPaths(Node node, Limits limits, Collection preservedPaths) { - if (node == null) { - throw new IllegalArgumentException("node must not be null"); - } - Set canonicalPreservedPaths = canonicalPreservedPaths(preservedPaths); - if (canonicalPreservedPaths.isEmpty()) { - return resolve(node.clone(), limits); - } - if (canonicalPreservedPaths.contains("/")) { - return node.clone(); - } + beginDirectCacheOperation(); + try { + if (node == null) { + throw new IllegalArgumentException("node must not be null"); + } + Set canonicalPreservedPaths = canonicalPreservedPaths(preservedPaths); + if (canonicalPreservedPaths.isEmpty()) { + return resolve(node.clone(), limits); + } + if (canonicalPreservedPaths.contains("/")) { + return node.clone(); + } - Limits preservingLimits = limits == NO_LIMITS - ? ExcludedPathLimits.excluding(canonicalPreservedPaths) - : new CompositeLimits(limits, ExcludedPathLimits.excluding(canonicalPreservedPaths)); - Node resolved = resolve(node.clone(), preservingLimits); - for (String path : canonicalPreservedPaths) { - Node preserved = NodePathEditor.getOrNull(node, path); - if (preserved != null) { - NodePathEditor.put(resolved, path, preserved.clone()); + Limits preservingLimits = limits == NO_LIMITS + ? ExcludedPathLimits.excluding(canonicalPreservedPaths) + : new CompositeLimits( + limits, ExcludedPathLimits.excluding(canonicalPreservedPaths)); + Node resolved = resolve(node.clone(), preservingLimits); + for (String path : canonicalPreservedPaths) { + Node preserved = NodePathEditor.getOrNull(node, path); + if (preserved != null) { + NodePathEditor.put(resolved, path, preserved.clone()); + } } + return resolved; + } finally { + endDirectCacheOperation(); } - return resolved; } public List selectPaths(Node node, Collection pathPatterns, Predicate predicate) { @@ -164,7 +248,13 @@ public Node resolvePreservingMatchingPaths(Node node, Limits limits, Collection pathPatterns, Predicate predicate) { - return resolvePreservingPaths(node, limits, selectPaths(node, pathPatterns, predicate)); + beginDirectCacheOperation(); + try { + return resolvePreservingPaths( + node, limits, selectPaths(node, pathPatterns, predicate)); + } finally { + endDirectCacheOperation(); + } } /** @@ -184,28 +274,53 @@ public Node reverse(Node node) { */ @Deprecated public Node reverse(Object object) { - return reverse(objectToNode(object)); + beginDirectCacheOperation(); + try { + return reverse(objectToNode(object)); + } finally { + endDirectCacheOperation(); + } } public Node canonicalize(Node node) { - Node preprocessed = preprocess(node.clone()); - Node resolved = resolve(preprocessed.clone()); - return new MergeReverser().reverseToCanonicalOverlay(resolved, preprocessed); + beginDirectCacheOperation(); + try { + Node preprocessed = preprocess(node.clone()); + Node resolved = resolve(preprocessed.clone()); + return new MergeReverser().reverseToCanonicalOverlay(resolved, preprocessed); + } finally { + endDirectCacheOperation(); + } } public Node canonicalize(Object object) { - return canonicalize(objectToNode(object)); + beginDirectCacheOperation(); + try { + return canonicalize(objectToNode(object)); + } finally { + endDirectCacheOperation(); + } } public Node expand(Node node) { - if (node == null) { - throw new IllegalArgumentException("node must not be null"); + beginDirectCacheOperation(); + try { + if (node == null) { + throw new IllegalArgumentException("node must not be null"); + } + return expandReferences(node); + } finally { + endDirectCacheOperation(); } - return expandReferences(node); } public Node expand(Object object) { - return expand(objectToNode(object)); + beginDirectCacheOperation(); + try { + return expand(objectToNode(object)); + } finally { + endDirectCacheOperation(); + } } public Node collapse(Node node) { @@ -216,42 +331,69 @@ public Node collapse(Node node) { } public Node collapse(Object object) { - return collapse(objectToNode(object)); + beginDirectCacheOperation(); + try { + return collapse(objectToNode(object)); + } finally { + endDirectCacheOperation(); + } } public ResolvedSnapshot resolveToSnapshot(Node node) { - Node preprocessed = preprocess(node.clone()); - Limits limits = combineWithGlobalLimits(NO_LIMITS); - Merger merger = new Merger(mergingProcessor, nodeProvider, resolvedReferenceCache); - return cacheSnapshot(ResolvedSnapshot.fromResolverResult( - merger.resolveSnapshot(preprocessed, limits))); + beginDirectCacheOperation(); + try { + Node preprocessed = preprocess(node.clone()); + Limits limits = combineWithGlobalLimits(NO_LIMITS); + Merger merger = new Merger(mergingProcessor, nodeProvider, resolvedReferenceCache); + return cacheSnapshot(ResolvedSnapshot.fromResolverResult( + merger.resolveSnapshot(preprocessed, limits))); + } finally { + endDirectCacheOperation(); + } } public ResolvedSnapshot resolveToSnapshot(Object object) { - return resolveToSnapshot(objectToNode(object)); + beginDirectCacheOperation(); + try { + return resolveToSnapshot(objectToNode(object)); + } finally { + endDirectCacheOperation(); + } } public ResolvedSnapshot loadSnapshot(Node canonical) { - FrozenNode canonicalRoot = FrozenNode.fromNode(canonical); - ResolvedSnapshot cached = resolvedSnapshotsByCanonicalRepresentation.get( - canonicalRoot.resolvedStructuralKey()); - if (cached != null && cached.verifiedReferenceResolution() != null) { - return cached; + beginDirectCacheOperation(); + try { + FrozenNode canonicalRoot = FrozenNode.fromNode(canonical); + ResolvedSnapshot cached = cachedSnapshotByCanonical( + canonicalRoot.resolvedStructuralKey()); + if (cached != null && cached.verifiedReferenceResolution() != null) { + return cached; + } + return snapshotFromVerifiedCanonical(canonicalRoot); + } finally { + endDirectCacheOperation(); } - return snapshotFromVerifiedCanonical(canonicalRoot); } public ResolvedSnapshot loadSnapshot(String blueId) { - ResolvedSnapshot cached = resolvedSnapshotsByBlueId.get(blueId); - if (cached != null) { - return cached; - } - List nodes = nodeProvider.fetchByBlueId(blueId); - if (nodes == null || nodes.isEmpty()) { - throw new IllegalArgumentException("No content found for blueId: " + blueId); + beginDirectCacheOperation(); + try { + ResolvedSnapshot cached = cachedSnapshotByBlueId(blueId); + if (cached != null) { + return cached; + } + List nodes = nodeProvider.fetchByBlueId(blueId); + if (nodes == null || nodes.isEmpty()) { + throw new IllegalArgumentException("No content found for blueId: " + blueId); + } + Node canonical = nodes.size() == 1 + ? providerContentWithoutRootIdentity(nodes.get(0)) + : new Node().items(providerContentWithoutRootIdentity(nodes)); + return snapshotFromVerifiedCanonical(FrozenNode.fromNode(canonical)); + } finally { + endDirectCacheOperation(); } - Node canonical = nodes.size() == 1 ? providerContentWithoutRootIdentity(nodes.get(0)) : new Node().items(providerContentWithoutRootIdentity(nodes)); - return snapshotFromVerifiedCanonical(FrozenNode.fromNode(canonical)); } private Node providerContentWithoutRootIdentity(Node node) { @@ -348,25 +490,46 @@ public CanonicalPatchResult applyCanonicalPatch(Node canonical, JsonPatch patch) } public ResolvedSnapshot applyCanonicalPatch(ResolvedSnapshot snapshot, JsonPatch patch) { - return applyCanonicalPatch(snapshot, patch, this::snapshotFromVerifiedCanonical); + beginDirectCacheOperation(); + try { + return applyCanonicalPatch(snapshot, patch, this::snapshotFromVerifiedCanonical); + } finally { + endDirectCacheOperation(); + } } public Blue cacheResolvedSnapshot(ResolvedSnapshot snapshot) { - cacheSnapshot(snapshot); - return this; + beginDirectCacheOperation(); + try { + pinSnapshot(snapshot); + return this; + } finally { + endDirectCacheOperation(); + } } public Blue cacheResolvedSnapshots(Collection snapshots) { - snapshots.forEach(this::cacheResolvedSnapshot); - return this; + beginDirectCacheOperation(); + try { + snapshots.forEach(this::cacheResolvedSnapshot); + return this; + } finally { + endDirectCacheOperation(); + } } public Optional cachedResolvedSnapshot(String blueId) { - return Optional.ofNullable(resolvedSnapshotsByBlueId.get(blueId)); + beginDirectCacheOperation(); + try { + return Optional.ofNullable(cachedSnapshotByBlueId(blueId)); + } finally { + endDirectCacheOperation(); + } } public int resolvedSnapshotCacheSize() { - return resolvedSnapshotsByCanonicalRepresentation.size(); + return pinnedSnapshotsByCanonicalRepresentation.size() + + derivedSnapshotsByCanonicalRepresentation.size(); } public int resolvedReferenceCacheSize() { @@ -378,16 +541,123 @@ public int resolvedStructuralCacheSize() { } public void clearResolvedSnapshotCache() { - resolvedSnapshotsByBlueId.clear(); - resolvedSnapshotsByCanonicalRepresentation.clear(); - synchronized (recentProcessingDocumentSnapshots) { - recentProcessingDocumentSnapshots.clear(); + DocumentProcessor ownedProcessor; + ProcessingMetricsSink metrics; + CacheGaugeSnapshot gauges; + synchronized (lifecycleLock) { + beginCacheInvalidation(); + ownedProcessor = documentProcessorOwned ? documentProcessor : null; + } + try { + if (ownedProcessor != null) { + ownedProcessor.clearCaches(); + } + synchronized (lifecycleLock) { + ensureOpen(); + clearAllRuntimeCaches(); + metrics = metricsSink(); + gauges = captureCacheGauges(); + endCacheInvalidation(); + } + } catch (RuntimeException | Error exception) { + synchronized (lifecycleLock) { + endCacheInvalidation(); + } + throw exception; + } + gauges.emit(metrics); + } + + /** Returns the immutable cache policy selected when this runtime was created. */ + public BlueCachePolicy cachePolicy() { + return cachePolicy; + } + + /** Returns approximate retained weights and ownership counters by cache region. */ + public BlueCacheStats cacheStats() { + Map regions = new LinkedHashMap<>(); + synchronized (lifecycleLock) { + regions.put(PINNED_SNAPSHOT_CACHE, new BlueCacheStats.Region( + pinnedSnapshotsByCanonicalRepresentation.size(), + pinnedSnapshotWeightBytes, + pinnedSnapshotHighWaterBytes, + 0L, + 0L, + 0L, + 0L, + true)); + regions.put(DERIVED_SNAPSHOT_CACHE, cacheRegion( + derivedSnapshotsByCanonicalRepresentation, false)); + regions.put(CANONICAL_ALIAS_CACHE, cacheRegion( + derivedSnapshotsByBlueId, false)); + regions.put(RECENT_PROCESSING_CACHE, cacheRegion( + recentProcessingDocumentSnapshots, false)); + ResolvedReferenceCache.CacheStats reference = resolvedReferenceCache.cacheStats(); + regions.put(VERIFIED_REFERENCE_CACHE, new BlueCacheStats.Region( + reference.verifiedEntries(), + reference.verifiedCurrentWeightBytes(), + reference.verifiedHighWaterWeightBytes(), + 0L, + 0L, + reference.verifiedEvictions(), + reference.verifiedOversizedRejections(), + reference.pinnedVerifiedEntries() > 0)); + regions.put(TRANSIENT_REFERENCE_CACHE, new BlueCacheStats.Region( + reference.transientTrustedEntries(), + reference.transientTrustedCurrentWeightBytes(), + reference.transientTrustedHighWaterWeightBytes(), + 0L, + 0L, + 0L, + 0L, + true)); + regions.put(STRUCTURAL_INTERNER_CACHE, new BlueCacheStats.Region( + reference.structuralEntries(), + reference.structuralCurrentWeightBytes(), + reference.structuralHighWaterWeightBytes(), + 0L, + 0L, + reference.structuralEvictions(), + reference.structuralOversizedRejections(), + false)); + int processorEntries = documentProcessorOwned && documentProcessor != null + ? documentProcessor.cacheEntryCount() : 0; + long processorWeight = documentProcessorOwned && documentProcessor != null + ? documentProcessor.cacheWeightBytes() : 0L; + processorPlanCacheHighWaterBytes = Math.max( + processorPlanCacheHighWaterBytes, processorWeight); + regions.put(PROCESSOR_PLAN_CACHE, new BlueCacheStats.Region( + processorEntries, + processorWeight, + processorPlanCacheHighWaterBytes, + 0L, + 0L, + 0L, + 0L, + false)); + return new BlueCacheStats(regions, closed); } - resolvedReferenceCache.clear(); } + /** + * Returns a conformance handle bound to the provider and merger generation + * current at creation time. The handle sees a snapshot of currently pinned + * verified references and owns an otherwise independent bounded cache, so + * retaining it across later runtime reconfiguration cannot contaminate this + * Blue instance; callers should close it when no longer needed. + */ public ConformanceEngine conformanceEngine() { - return new ConformanceEngine(nodeProvider, mergingProcessor, resolvedReferenceCache); + beginDirectCacheOperation(); + try { + // A caller may retain this handle across provider or merger replacement. + // Its cache snapshots pinned authoritative evidence, but otherwise is + // deliberately independent from Blue's current generation so stale + // evidence can never be published into runtime state. + return ConformanceEngine.withIsolatedCache( + nodeProvider, mergingProcessor, resolvedReferenceCache); + } finally { + endDirectCacheOperation(); + } } public String languageVersion() { @@ -434,33 +704,67 @@ public BlueContractsConformanceReport runContractsConformanceSuite() { } public void extend(Node node, Limits limits) { - Limits effectiveLimits = combineWithGlobalLimits(limits); - new NodeExtender(nodeProvider).extend(node, effectiveLimits); + beginDirectCacheOperation(); + try { + Limits effectiveLimits = combineWithGlobalLimits(limits); + new NodeExtender(nodeProvider).extend(node, effectiveLimits); + } finally { + endDirectCacheOperation(); + } } public Node objectToNode(Object object) { - String json = JSON_MAPPER.writeValueAsString(object); - return jsonToNode(json); + beginDirectCacheOperation(); + try { + String json = JSON_MAPPER.writeValueAsString(object); + return jsonToNode(json); + } finally { + endDirectCacheOperation(); + } } public T convertObject(Object object, Class clazz) { - return nodeToObject(objectToNode(object).clone(), clazz); + beginDirectCacheOperation(); + try { + return nodeToObject(objectToNode(object).clone(), clazz); + } finally { + endDirectCacheOperation(); + } } public boolean nodeMatchesType(Node node, Node type) { - return new NodeTypeMatcher(this).matchesType(node, type, globalLimits); + beginDirectCacheOperation(); + try { + return new NodeTypeMatcher(this).matchesType(node, type, globalLimits); + } finally { + endDirectCacheOperation(); + } } public boolean nodeMatchesType(FrozenNode resolvedNode, FrozenNode resolvedType) { - return new NodeTypeMatcher(this).matchesResolvedType(resolvedNode, resolvedType); + beginDirectCacheOperation(); + try { + return new NodeTypeMatcher(this).matchesResolvedType(resolvedNode, resolvedType); + } finally { + endDirectCacheOperation(); + } } public boolean nodeMatchesType(ResolvedSnapshot snapshot, String pointer, FrozenNode resolvedType) { - return new NodeTypeMatcher(this).matchesResolvedType(snapshot, pointer, resolvedType); + beginDirectCacheOperation(); + try { + return new NodeTypeMatcher(this).matchesResolvedType(snapshot, pointer, resolvedType); + } finally { + endDirectCacheOperation(); + } } public void setGlobalLimits(Limits globalLimits) { - this.globalLimits = globalLimits != null ? globalLimits : NO_LIMITS; + ConfigurationRefresh refresh = refreshRuntimeConfiguration(() -> + this.globalLimits = globalLimits != null ? globalLimits : NO_LIMITS, + false); + closeProcessor(refresh.processorToClose); + refresh.gauges.emit(refresh.metrics); } public Limits getGlobalLimits() { @@ -468,11 +772,21 @@ public Limits getGlobalLimits() { } public Node yamlToNode(String yaml) { - return preprocess(parseSourceYaml(yaml)); + beginDirectCacheOperation(); + try { + return preprocess(parseSourceYaml(yaml)); + } finally { + endDirectCacheOperation(); + } } public Node jsonToNode(String json) { - return preprocess(parseSourceJson(json)); + beginDirectCacheOperation(); + try { + return preprocess(parseSourceJson(json)); + } finally { + endDirectCacheOperation(); + } } public Node parseSourceYaml(String yaml) { @@ -522,23 +836,48 @@ public String nodeToSimpleJson(Node node) { } public String objectToYaml(Object object) { - return nodeToYaml(objectToNode(object)); + beginDirectCacheOperation(); + try { + return nodeToYaml(objectToNode(object)); + } finally { + endDirectCacheOperation(); + } } public String objectToSimpleYaml(Object object) { - return nodeToSimpleYaml(objectToNode(object)); + beginDirectCacheOperation(); + try { + return nodeToSimpleYaml(objectToNode(object)); + } finally { + endDirectCacheOperation(); + } } public String objectToJson(Object object) { - return nodeToJson(objectToNode(object)); + beginDirectCacheOperation(); + try { + return nodeToJson(objectToNode(object)); + } finally { + endDirectCacheOperation(); + } } public String objectToJson(Object object, ExportContext exportContext) { - return nodeToJson(objectToNode(object), exportContext); + beginDirectCacheOperation(); + try { + return nodeToJson(objectToNode(object), exportContext); + } finally { + endDirectCacheOperation(); + } } public String objectToSimpleJson(Object object) { - return nodeToSimpleJson(objectToNode(object)); + beginDirectCacheOperation(); + try { + return nodeToSimpleJson(objectToNode(object)); + } finally { + endDirectCacheOperation(); + } } public Node exportNode(Node node, ExportContext exportContext) { @@ -546,12 +885,18 @@ public Node exportNode(Node node, ExportContext exportContext) { } public Blue registerTypeDictionary(TypeDictionary dictionary) { - dictionaryRegistry.register(dictionary); + synchronized (lifecycleLock) { + ensureOpen(); + dictionaryRegistry.register(dictionary); + } return this; } public Blue registerTypeDictionaries(Collection dictionaries) { - dictionaryRegistry.registerAll(dictionaries); + synchronized (lifecycleLock) { + ensureOpen(); + dictionaryRegistry.registerAll(dictionaries); + } return this; } @@ -568,10 +913,15 @@ public T clone(T object) { return (T) ((Node) object).clone(); } - Class clazz = (Class) object.getClass(); - Node node = objectToNode(object); - Node clonedNode = node.clone(); - return nodeToObject(clonedNode, clazz); + beginDirectCacheOperation(); + try { + Class clazz = (Class) object.getClass(); + Node node = objectToNode(object); + Node clonedNode = node.clone(); + return nodeToObject(clonedNode, clazz); + } finally { + endDirectCacheOperation(); + } } public String calculateBlueId(Node node) { @@ -579,7 +929,12 @@ public String calculateBlueId(Node node) { } public String calculateBlueId(Object object) { - return calculateBlueId(objectToNode(object)); + beginDirectCacheOperation(); + try { + return calculateBlueId(objectToNode(object)); + } finally { + endDirectCacheOperation(); + } } public String calculateSemanticBlueId(Node node) { @@ -587,32 +942,49 @@ public String calculateSemanticBlueId(Node node) { } public String calculateSemanticBlueId(Object object) { - return calculateSemanticBlueId(objectToNode(object)); + beginDirectCacheOperation(); + try { + return calculateSemanticBlueId(objectToNode(object)); + } finally { + endDirectCacheOperation(); + } } public void addPreprocessingAliases(Map aliases) { - preprocessingAliases.putAll(aliases); + ConfigurationRefresh refresh = refreshRuntimeConfiguration(() -> { + Map nextAliases = new HashMap<>(preprocessingAliases); + nextAliases.putAll(aliases); + preprocessingAliases = nextAliases; + }, false); + closeProcessor(refresh.processorToClose); + refresh.gauges.emit(refresh.metrics); } public Blue registerContractProcessor(ContractProcessor processor) { + ensureOpen(); if (processor == null) { throw new IllegalArgumentException("processor must not be null"); } - if (documentProcessor == null) { - documentProcessor = createDefaultDocumentProcessor(); + DocumentProcessor target = beginDocumentProcessorMutation(); + try { + target.registerContractProcessor(processor); + } finally { + endDocumentProcessorMutation(); } - documentProcessor.registerContractProcessor(processor); return this; } public Blue registerContractProcessor(String blueId, ContractProcessor processor) { + ensureOpen(); if (processor == null) { throw new IllegalArgumentException("processor must not be null"); } - if (documentProcessor == null) { - documentProcessor = createDefaultDocumentProcessor(); + DocumentProcessor target = beginDocumentProcessorMutation(); + try { + target.registerContractProcessor(blueId, processor); + } finally { + endDocumentProcessorMutation(); } - documentProcessor.registerContractProcessor(blueId, processor); return this; } @@ -625,17 +997,48 @@ public Blue registerContractProcessor(String blueId, public Blue registerExternalContractType(String blueId, Node canonicalTypeNode, ContractProcessor processor) { - registerExternalTypeNode(blueId, canonicalTypeNode); - return registerContractProcessor(blueId, processor); + // Preserve the lifecycle contract even when the supplied registration + // arguments are invalid: closed runtimes reject all runtime work first. + ensureOpen(); + if (processor == null) { + throw new IllegalArgumentException("processor must not be null"); + } + Node validatedCanonicalType = validatedExternalTypeNode(blueId, canonicalTypeNode); + DocumentProcessor target = beginDocumentProcessorMutation(); + ProcessingMetricsSink metrics; + CacheGaugeSnapshot gauges; + try { + target.registerContractProcessor(blueId, processor); + synchronized (lifecycleLock) { + externalContractTypeNodes.put(blueId, validatedCanonicalType); + // The extension provider is consulted by snapshot resolution. Any + // unresolved/false result produced before registration is stale. + clearReloadableRuntimeCaches(); + metrics = metricsSink(); + gauges = captureCacheGauges(); + } + } finally { + endDocumentProcessorMutation(); + } + gauges.emit(metrics); + return this; } public DocumentProcessingResult processDocument(Node document, Node event) { - DocumentProcessor processor = ensureDocumentProcessor(); + ProcessingOperation operation = beginProcessingOperation(); + DocumentProcessor processor = operation.processor; + CacheGenerationStamp previousStamp = activeProcessingCacheStamp.get(); + activeProcessingCacheStamp.set(operation.stamp); long start = System.nanoTime(); try { - return attachProcessingSnapshot(processor, processor.processDocument(document, event)); + return attachProcessingSnapshot( + operation, processor.processDocument(document, event)); } finally { - processor.processingMetricsSink().addBlueProcessDocumentNanos(System.nanoTime() - start); + try { + processor.processingMetricsSink().addBlueProcessDocumentNanos(System.nanoTime() - start); + } finally { + finishProcessingOperation(previousStamp); + } } } @@ -648,30 +1051,77 @@ public DocumentProcessingResult processDocument(Node document, Node event) { * @return the processing result and its authoritative snapshot */ public DocumentProcessingResult processDocument(ResolvedSnapshot snapshot, Node event) { - DocumentProcessor processor = ensureDocumentProcessor(); + ProcessingOperation operation = beginProcessingOperation(); + DocumentProcessor processor = operation.processor; + CacheGenerationStamp previousStamp = activeProcessingCacheStamp.get(); + activeProcessingCacheStamp.set(operation.stamp); long start = System.nanoTime(); try { - return rememberProcessingResultSnapshot(processor.processDocument(snapshot, event)); + return rememberProcessingResultSnapshot( + processor.processDocument(snapshot, event), operation.stamp); } finally { - processor.processingMetricsSink().addBlueProcessDocumentNanos(System.nanoTime() - start); + try { + processor.processingMetricsSink().addBlueProcessDocumentNanos(System.nanoTime() - start); + } finally { + finishProcessingOperation(previousStamp); + } } } + /** + * Returns the active processor handle. Operations invoked directly on this + * handle are outside Blue's operation-admission accounting; callers must + * finish and externally coordinate such work before reconfiguring or closing + * this runtime. Prefer the processing methods on {@code Blue} when lifecycle + * coordination is required. + */ public DocumentProcessor getDocumentProcessor() { - return ensureDocumentProcessor(); + synchronized (lifecycleLock) { + awaitCacheInvalidation(); + ensureOpen(); + return ensureDocumentProcessor(); + } } public Blue documentProcessor(DocumentProcessor documentProcessor) { if (documentProcessor == null) { throw new IllegalArgumentException("documentProcessor must not be null"); } - this.documentProcessor = documentProcessor; + DocumentProcessor processorToClose; + synchronized (lifecycleLock) { + ensureOpen(); + if (this.documentProcessor == documentProcessor) { + return this; + } + beginCacheInvalidation(); + try { + processorToClose = documentProcessorOwned + ? this.documentProcessor : null; + processorOwnerToken = new Object(); + clearReloadableRuntimeCaches(); + this.documentProcessor = documentProcessor; + // Public injection is a borrowed dependency. Preserve the historical + // setter contract: replacing or closing Blue must not close a + // processor that may be shared by another runtime. + this.documentProcessorOwned = false; + } finally { + endCacheInvalidation(); + } + } + closeProcessor(processorToClose); return this; } public DocumentProcessingResult initializeDocument(Node document) { - DocumentProcessor processor = ensureDocumentProcessor(); - return attachProcessingSnapshot(processor, processor.initializeDocument(document)); + ProcessingOperation operation = beginProcessingOperation(); + CacheGenerationStamp previousStamp = activeProcessingCacheStamp.get(); + activeProcessingCacheStamp.set(operation.stamp); + try { + return attachProcessingSnapshot( + operation, operation.processor.initializeDocument(document)); + } finally { + finishProcessingOperation(previousStamp); + } } /** @@ -682,52 +1132,106 @@ public DocumentProcessingResult initializeDocument(Node document) { * @return the initialization result and its authoritative snapshot */ public DocumentProcessingResult initializeDocument(ResolvedSnapshot snapshot) { - return rememberProcessingResultSnapshot(ensureDocumentProcessor().initializeDocument(snapshot)); + ProcessingOperation operation = beginProcessingOperation(); + CacheGenerationStamp previousStamp = activeProcessingCacheStamp.get(); + activeProcessingCacheStamp.set(operation.stamp); + try { + return rememberProcessingResultSnapshot( + operation.processor.initializeDocument(snapshot), operation.stamp); + } finally { + finishProcessingOperation(previousStamp); + } } public boolean isInitialized(Node document) { - return ensureDocumentProcessor().isInitialized(document); + beginDirectCacheOperation(); + try { + return ensureDocumentProcessor().isInitialized(document); + } finally { + endDirectCacheOperation(); + } } public boolean isInitialized(ResolvedSnapshot snapshot) { - return ensureDocumentProcessor().isInitialized(snapshot); + beginDirectCacheOperation(); + try { + return ensureDocumentProcessor().isInitialized(snapshot); + } finally { + endDirectCacheOperation(); + } } public Node preprocess(Node node) { + beginDirectCacheOperation(); + try { + return preprocess(node, nodeProvider, preprocessingAliases); + } finally { + endDirectCacheOperation(); + } + } + + private Node preprocess(Node node, + NodeProvider preprocessingNodeProvider, + Map aliases) { if (node.getBlue() != null && node.getBlue().getValue() instanceof String) { String blueValue = (String) node.getBlue().getValue(); - if (preprocessingAliases.containsKey(blueValue)) { + if (aliases.containsKey(blueValue)) { Node clonedNode = node.clone(); - clonedNode.blue(new Node().blueId(preprocessingAliases.get(blueValue))); - return new Preprocessor(nodeProvider).preprocessWithDefaultBlue(clonedNode); + clonedNode.blue(new Node().blueId(aliases.get(blueValue))); + return new Preprocessor(preprocessingNodeProvider) + .preprocessWithDefaultBlue(clonedNode); } else if (BlueIds.isPotentialBlueId(blueValue)) { Node clonedNode = node.clone(); clonedNode.blue(new Node().blueId(blueValue)); - return new Preprocessor(nodeProvider).preprocessWithDefaultBlue(clonedNode); + return new Preprocessor(preprocessingNodeProvider) + .preprocessWithDefaultBlue(clonedNode); } else { throw new IllegalArgumentException("Invalid blue value: " + blueValue); } } - return new Preprocessor(nodeProvider).preprocessWithDefaultBlue(node); + return new Preprocessor(preprocessingNodeProvider).preprocessWithDefaultBlue(node); } public Optional> determineClass(Node node) { - if (typeClassResolver != null) { - Class clazz = typeClassResolver.resolveClass(node); - if (clazz != null) - return Optional.of(clazz); + beginDirectCacheOperation(); + try { + TypeClassResolver capturedResolver; + synchronized (lifecycleLock) { + capturedResolver = typeClassResolver; + } + if (capturedResolver != null) { + Class clazz = capturedResolver.resolveClass(node); + if (clazz != null) + return Optional.of(clazz); + } + return Optional.empty(); + } finally { + endDirectCacheOperation(); } - return Optional.empty(); } public T nodeToObject(Node node, Class clazz) { - return new NodeToObjectConverter(typeClassResolver).convert(node, clazz); + beginDirectCacheOperation(); + try { + TypeClassResolver capturedResolver; + synchronized (lifecycleLock) { + capturedResolver = typeClassResolver; + } + return new NodeToObjectConverter(capturedResolver).convert(node, clazz); + } finally { + endDirectCacheOperation(); + } } public boolean isNodeSubtypeOf(Node candidateNode, Node superTypeNode) { - return Types.isSubtype(candidateNode, superTypeNode, nodeProvider); + beginDirectCacheOperation(); + try { + return Types.isSubtype(candidateNode, superTypeNode, nodeProvider); + } finally { + endDirectCacheOperation(); + } } public NodeProvider getNodeProvider() { @@ -743,72 +1247,266 @@ public TypeClassResolver getTypeClassResolver() { } public Map getPreprocessingAliases() { - return preprocessingAliases; + synchronized (lifecycleLock) { + return Collections.unmodifiableMap(new HashMap<>(preprocessingAliases)); + } } public Blue nodeProvider(NodeProvider nodeProvider) { - this.originalNodeProvider = nodeProvider; - this.nodeProvider = NodeProviderWrapper.wrap(nodeProvider); - clearResolvedSnapshotCache(); - refreshDocumentProcessorConformanceEngine(); + ConfigurationRefresh refresh = refreshRuntimeConfiguration(() -> { + this.originalNodeProvider = nodeProvider; + this.nodeProvider = NodeProviderWrapper.wrap(nodeProvider); + }, true); + closeProcessor(refresh.processorToClose); + refresh.gauges.emit(refresh.metrics); return this; } public Blue mergingProcessor(MergingProcessor mergingProcessor) { - this.mergingProcessor = mergingProcessor; - clearResolvedSnapshotCache(); - refreshDocumentProcessorConformanceEngine(); + ConfigurationRefresh refresh = refreshRuntimeConfiguration(() -> + this.mergingProcessor = mergingProcessor, true); + closeProcessor(refresh.processorToClose); + refresh.gauges.emit(refresh.metrics); return this; } public Blue typeClassResolver(TypeClassResolver typeClassResolver) { - this.typeClassResolver = typeClassResolver; - return this; + synchronized (lifecycleLock) { + ensureOpen(); + this.typeClassResolver = typeClassResolver; + return this; + } } public Blue preprocessingAliases(Map preprocessingAliases) { - this.preprocessingAliases = preprocessingAliases; + ConfigurationRefresh refresh = refreshRuntimeConfiguration(() -> + this.preprocessingAliases = preprocessingAliases != null + ? new HashMap<>(preprocessingAliases) + : new HashMap<>(), false); + closeProcessor(refresh.processorToClose); + refresh.gauges.emit(refresh.metrics); return this; } private DocumentProcessor ensureDocumentProcessor() { - if (documentProcessor == null) { - documentProcessor = createDefaultDocumentProcessor(); + synchronized (lifecycleLock) { + ensureOpen(); + if (documentProcessor == null) { + documentProcessor = createDefaultDocumentProcessor(); + documentProcessorOwned = true; + } + return documentProcessor; + } + } + + private DocumentProcessor beginDocumentProcessorMutation() { + synchronized (lifecycleLock) { + beginCacheInvalidation(); + try { + return ensureDocumentProcessor(); + } catch (RuntimeException | Error exception) { + endCacheInvalidation(); + throw exception; + } + } + } + + private void endDocumentProcessorMutation() { + synchronized (lifecycleLock) { + endCacheInvalidation(); + } + } + + private ProcessingOperation beginProcessingOperation() { + synchronized (lifecycleLock) { + CacheGenerationStamp activeStamp = activeProcessingCacheStamp.get(); + if (activeStamp == null) { + awaitCacheInvalidation(); + } + ensureOpen(); + DocumentProcessor processor = ensureDocumentProcessor(); + activeProcessingOperations++; + return new ProcessingOperation(processor, + activeStamp != null + ? activeStamp + : new CacheGenerationStamp( + processorOwnerToken, runtimeCacheGeneration), + nodeProvider, + processorSnapshotNodeProvider(), + mergingProcessor, + Collections.unmodifiableMap(new HashMap<>(preprocessingAliases)), + globalLimits); + } + } + + private void finishProcessingOperation(CacheGenerationStamp previousStamp) { + restoreProcessingCacheStamp(previousStamp); + synchronized (lifecycleLock) { + activeProcessingOperations--; + lifecycleLock.notifyAll(); + } + } + + private void beginDirectCacheOperation() { + synchronized (lifecycleLock) { + Integer depth = directCacheOperationDepth.get(); + if (depth == null || depth == 0) { + awaitCacheInvalidation(); + ensureOpen(); + activeDirectCacheOperations++; + directCacheOperationDepth.set(1); + } else { + ensureOpen(); + directCacheOperationDepth.set(depth + 1); + } + } + } + + private void endDirectCacheOperation() { + synchronized (lifecycleLock) { + Integer depth = directCacheOperationDepth.get(); + if (depth == null || depth <= 0) { + throw new IllegalStateException("Direct cache operation was not active"); + } + if (depth == 1) { + directCacheOperationDepth.remove(); + activeDirectCacheOperations--; + lifecycleLock.notifyAll(); + } else { + directCacheOperationDepth.set(depth - 1); + } + } + } + + /** Caller holds lifecycleLock. */ + private void beginCacheInvalidation() { + if (activeProcessingCacheStamp.get() != null + || directCacheOperationDepth.get() != null) { + throw new IllegalStateException( + "Blue caches cannot be invalidated during active runtime work"); + } + awaitCacheInvalidation(); + ensureOpen(); + cacheInvalidationInProgress = true; + cacheInvalidationThread = Thread.currentThread(); + try { + while (activeProcessingOperations > 0 || activeDirectCacheOperations > 0) { + try { + lifecycleLock.wait(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while waiting to invalidate Blue caches", exception); + } + } + ensureOpen(); + } catch (RuntimeException | Error exception) { + cacheInvalidationInProgress = false; + cacheInvalidationThread = null; + lifecycleLock.notifyAll(); + throw exception; + } + } + + /** Caller holds lifecycleLock. */ + private void endCacheInvalidation() { + cacheInvalidationInProgress = false; + cacheInvalidationThread = null; + lifecycleLock.notifyAll(); + } + + /** Caller holds lifecycleLock. */ + private void awaitCacheInvalidation() { + while (cacheInvalidationInProgress) { + if (cacheInvalidationThread == Thread.currentThread()) { + throw new IllegalStateException( + "Blue runtime work cannot reenter cache invalidation"); + } + try { + lifecycleLock.wait(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while waiting for Blue cache invalidation", exception); + } + } + } + + private void restoreProcessingCacheStamp(CacheGenerationStamp previousStamp) { + if (previousStamp == null) { + activeProcessingCacheStamp.remove(); + } else { + activeProcessingCacheStamp.set(previousStamp); + } + } + + private CacheGenerationStamp currentCacheStamp(Object expectedOwnerToken) { + synchronized (lifecycleLock) { + if (closed || processorOwnerToken != expectedOwnerToken) { + return CacheGenerationStamp.invalid(expectedOwnerToken); + } + return new CacheGenerationStamp(expectedOwnerToken, runtimeCacheGeneration); + } + } + + private boolean isCurrentCacheStampLocked(CacheGenerationStamp stamp) { + return !closed + && stamp != null + && stamp.ownerToken == processorOwnerToken + && stamp.generation == runtimeCacheGeneration; + } + + private boolean isCurrentCacheStamp(CacheGenerationStamp stamp) { + synchronized (lifecycleLock) { + return isCurrentCacheStampLocked(stamp); } - return documentProcessor; } private DocumentProcessor createDefaultDocumentProcessor() { + Object ownerToken = processorOwnerToken; + NodeProvider capturedPreprocessingProvider = nodeProvider; + NodeProvider capturedSnapshotProvider = processorSnapshotNodeProvider(); + MergingProcessor capturedMergingProcessor = mergingProcessor; + Map capturedAliases = Collections.unmodifiableMap( + new HashMap<>(preprocessingAliases)); + Limits capturedLimits = globalLimits; return DocumentProcessor.builder() - .withConformanceEngine(processorConformanceEngine()) - .withSnapshotManager(processingSnapshotManager()) + .withConformanceEngine(processorConformanceEngine( + capturedSnapshotProvider, capturedMergingProcessor)) + .withSnapshotManager(new BlueProcessingSnapshotManager( + ownerToken, + capturedPreprocessingProvider, + capturedSnapshotProvider, + capturedMergingProcessor, + capturedAliases, + capturedLimits, + null, + null)) .withMatchingService(new ContractMatchingService(this)) .build(); } - private ConformanceEngine processorConformanceEngine() { + private ConformanceEngine processorConformanceEngine(NodeProvider snapshotNodeProvider, + MergingProcessor snapshotMergingProcessor) { ConformanceEngine engine = new ConformanceEngine( - processorSnapshotNodeProvider(), mergingProcessor, resolvedReferenceCache); + snapshotNodeProvider, snapshotMergingProcessor, resolvedReferenceCache); synchronized (managedProcessorConformanceEngines) { managedProcessorConformanceEngines.add(engine); } return engine; } - private boolean isManagedProcessorConformanceEngine(ConformanceEngine engine) { - synchronized (managedProcessorConformanceEngines) { - return managedProcessorConformanceEngines.contains(engine); - } - } - - private DocumentProcessingResult attachProcessingSnapshot(DocumentProcessor processor, DocumentProcessingResult result) { + private DocumentProcessingResult attachProcessingSnapshot(ProcessingOperation operation, + DocumentProcessingResult result) { + DocumentProcessor processor = operation.processor; if (result == null || result.capabilityFailure() || result.snapshot() != null) { - return rememberProcessingResultSnapshot(result); + return rememberProcessingResultSnapshot(result, operation.stamp); } long start = System.nanoTime(); try { - DocumentProcessingResult attached = result.withSnapshot(resolveProcessingSnapshot(result.document())); - return rememberProcessingResultSnapshot(attached); + DocumentProcessingResult attached = result.withSnapshot( + resolveProcessingSnapshot(result.document(), operation)); + return rememberProcessingResultSnapshot(attached, operation.stamp); } finally { long nanos = System.nanoTime() - start; processor.processingMetricsSink().addResultSnapshotAttachNanos(nanos); @@ -816,14 +1514,17 @@ private DocumentProcessingResult attachProcessingSnapshot(DocumentProcessor proc } } - private DocumentProcessingResult rememberProcessingResultSnapshot(DocumentProcessingResult result) { + private DocumentProcessingResult rememberProcessingResultSnapshot(DocumentProcessingResult result, + CacheGenerationStamp stamp) { if (result != null && result.snapshot() != null && result.document() != null) { - rememberProcessingSnapshot(result.document(), result.snapshot()); + rememberProcessingSnapshot(result.document(), result.snapshot(), stamp); } return result; } - private ResolvedSnapshot cachedProcessingSnapshotFor(Node document, ProcessingMetricsSink metrics) { + private ResolvedSnapshot cachedProcessingSnapshotFor(Node document, + ProcessingMetricsSink metrics, + CacheGenerationStamp stamp) { if (document == null) { return null; } @@ -834,7 +1535,7 @@ private ResolvedSnapshot cachedProcessingSnapshotFor(Node document, ProcessingMe metrics.incrementProcessingSnapshotCacheMisses(); return null; } - ResolvedSnapshot cached = recentProcessingSnapshot(selectedKey); + ResolvedSnapshot cached = recentProcessingSnapshot(selectedKey, stamp); if (cached != null) { metrics.incrementProcessingSnapshotCacheHits(); return cached; @@ -854,178 +1555,394 @@ private FrozenNode.ResolvedStructuralKey selectedStructuralKey(Node document) { } } - private ResolvedSnapshot recentProcessingSnapshot(FrozenNode.ResolvedStructuralKey selectedKey) { - synchronized (recentProcessingDocumentSnapshots) { - for (ProcessingDocumentSnapshot entry : recentProcessingDocumentSnapshots) { - if (entry.selectedKey.equals(selectedKey)) { - return entry.snapshot; - } - } + private ResolvedSnapshot recentProcessingSnapshot( + FrozenNode.ResolvedStructuralKey selectedKey, + CacheGenerationStamp stamp) { + synchronized (lifecycleLock) { + return isCurrentCacheStampLocked(stamp) + ? recentProcessingDocumentSnapshots.get(selectedKey) + : null; } - return null; } - private void rememberProcessingSnapshot(Node document, ResolvedSnapshot snapshot) { + private void rememberProcessingSnapshot(Node document, + ResolvedSnapshot snapshot, + CacheGenerationStamp stamp) { FrozenNode.ResolvedStructuralKey selectedKey = selectedStructuralKey(document); if (selectedKey == null) { return; } - synchronized (recentProcessingDocumentSnapshots) { - for (int i = recentProcessingDocumentSnapshots.size() - 1; i >= 0; i--) { - ProcessingDocumentSnapshot entry = recentProcessingDocumentSnapshots.get(i); - if (entry.selectedKey.equals(selectedKey)) { - recentProcessingDocumentSnapshots.remove(i); - } + CacheMutationMetrics mutation; + ProcessingMetricsSink metrics; + synchronized (lifecycleLock) { + if (!isCurrentCacheStampLocked(stamp)) { + return; } - recentProcessingDocumentSnapshots.add(0, new ProcessingDocumentSnapshot(selectedKey, snapshot)); - while (recentProcessingDocumentSnapshots.size() > RECENT_PROCESSING_DOCUMENT_SNAPSHOT_LIMIT) { - recentProcessingDocumentSnapshots.remove(recentProcessingDocumentSnapshots.size() - 1); - } - } - } - - private static final class ProcessingDocumentSnapshot { - final FrozenNode.ResolvedStructuralKey selectedKey; - final ResolvedSnapshot snapshot; - - ProcessingDocumentSnapshot(FrozenNode.ResolvedStructuralKey selectedKey, ResolvedSnapshot snapshot) { - this.selectedKey = selectedKey; - this.snapshot = snapshot; + long evictionsBefore = recentProcessingDocumentSnapshots.evictions(); + long oversizedBefore = recentProcessingDocumentSnapshots.oversizedRejections(); + recentProcessingDocumentSnapshots.put(selectedKey, snapshot); + mutation = captureCacheMutation(RECENT_PROCESSING_CACHE, + recentProcessingDocumentSnapshots, + evictionsBefore, + oversizedBefore); + metrics = metricsSink(); } + mutation.emit(metrics); } - private void refreshDocumentProcessorConformanceEngine() { + /** Swaps the processor while holding lifecycleLock and returns only owned state to close. */ + private DocumentProcessor refreshDocumentProcessorConformanceEngine() { if (documentProcessor != null) { - documentProcessor = new DocumentProcessor(documentProcessor.getContractRegistry(), - documentProcessor.getContractTypeResolver(), - processorConformanceEngine(), - processingSnapshotManager(), + DocumentProcessor previous = documentProcessor; + boolean previousOwned = documentProcessorOwned; + Object ownerToken = processorOwnerToken; + NodeProvider capturedPreprocessingProvider = nodeProvider; + NodeProvider capturedSnapshotProvider = processorSnapshotNodeProvider(); + MergingProcessor capturedMergingProcessor = mergingProcessor; + Map capturedAliases = Collections.unmodifiableMap( + new HashMap<>(preprocessingAliases)); + Limits capturedLimits = globalLimits; + documentProcessor = new DocumentProcessor(previous.getContractRegistry(), + previous.getContractTypeResolver(), + processorConformanceEngine( + capturedSnapshotProvider, capturedMergingProcessor), + new BlueProcessingSnapshotManager( + ownerToken, + capturedPreprocessingProvider, + capturedSnapshotProvider, + capturedMergingProcessor, + capturedAliases, + capturedLimits, + null, + null), new ContractMatchingService(this), - documentProcessor.processingMetricsSink()); + previous.processingMetricsSink()); + documentProcessorOwned = true; + return previousOwned ? previous : null; } + return null; } - private ProcessingSnapshotManager processingSnapshotManager() { - return processingSnapshotManager(null); + private ConfigurationRefresh refreshRuntimeConfiguration( + Runnable mutation, + boolean replaceBorrowedProcessor) { + synchronized (lifecycleLock) { + beginCacheInvalidation(); + try { + mutation.run(); + processorOwnerToken = new Object(); + clearReloadableRuntimeCaches(); + DocumentProcessor processorToClose = documentProcessor != null + && (documentProcessorOwned || replaceBorrowedProcessor) + ? refreshDocumentProcessorConformanceEngine() + : null; + return new ConfigurationRefresh( + processorToClose, metricsSink(), captureCacheGauges()); + } finally { + endCacheInvalidation(); + } + } } - private ProcessingSnapshotManager processingSnapshotManager( - ResolvedReferenceCache sequenceReferenceCache) { - return new ProcessingSnapshotManager() { - @Override - public ResolvedSnapshot fromDocument(Node document) { - ProcessingMetricsSink metrics = documentProcessor != null - ? documentProcessor.processingMetricsSink() - : ProcessingMetricsSink.NOOP; - ResolvedSnapshot cached = cachedProcessingSnapshotFor(document, metrics); - if (cached != null) { - return cached; - } - return sequenceReferenceCache == null - ? resolveProcessingSnapshot(document) - : resolveProcessingSnapshot(document, false, sequenceReferenceCache); + private final class BlueProcessingSnapshotManager implements ProcessingSnapshotManager { + private final Object ownerToken; + private final NodeProvider preprocessingNodeProvider; + private final NodeProvider snapshotNodeProvider; + private final MergingProcessor snapshotMergingProcessor; + private final Map aliases; + private final Limits limits; + private final ResolvedReferenceCache sequenceReferenceCache; + private final CacheGenerationStamp fixedStamp; + private final ThreadLocal directOperationStamp = new ThreadLocal<>(); + + private BlueProcessingSnapshotManager(Object ownerToken, + NodeProvider preprocessingNodeProvider, + NodeProvider snapshotNodeProvider, + MergingProcessor snapshotMergingProcessor, + Map aliases, + Limits limits, + ResolvedReferenceCache sequenceReferenceCache, + CacheGenerationStamp fixedStamp) { + this.ownerToken = ownerToken; + this.preprocessingNodeProvider = preprocessingNodeProvider; + this.snapshotNodeProvider = snapshotNodeProvider; + this.snapshotMergingProcessor = snapshotMergingProcessor; + this.aliases = aliases; + this.limits = limits; + this.sequenceReferenceCache = sequenceReferenceCache; + this.fixedStamp = fixedStamp; + } + + private CacheGenerationStamp operationStamp() { + if (fixedStamp != null) { + return fixedStamp; + } + CacheGenerationStamp active = activeProcessingCacheStamp.get(); + if (active != null) { + return active.ownerToken == ownerToken + ? active + : CacheGenerationStamp.invalid(ownerToken); } + CacheGenerationStamp local = directOperationStamp.get(); + if (local == null || !isCurrentCacheStamp(local)) { + local = currentCacheStamp(ownerToken); + directOperationStamp.set(local); + } + return local; + } - @Override - public ResolvedSnapshot fromDocumentTransient(Node document) { - ProcessingMetricsSink metrics = documentProcessor != null + private ProcessingMetricsSink processingMetrics() { + synchronized (lifecycleLock) { + return processorOwnerToken == ownerToken && documentProcessor != null ? documentProcessor.processingMetricsSink() : ProcessingMetricsSink.NOOP; - ResolvedSnapshot cached = cachedProcessingSnapshotFor(document, metrics); - if (cached != null) { - return cached; - } - ResolvedReferenceCache transientCache = sequenceReferenceCache != null - ? sequenceReferenceCache - : resolvedReferenceCache.transientChild(); - return resolveProcessingSnapshot(document, false, transientCache); } + } - @Override - public ProcessingSnapshotManager transientSequence() { - return sequenceReferenceCache != null - ? this - : processingSnapshotManager(resolvedReferenceCache.transientChild()); + @Override + public ResolvedSnapshot fromDocument(Node document) { + CacheGenerationStamp stamp = operationStamp(); + ResolvedSnapshot cached = cachedProcessingSnapshotFor( + document, processingMetrics(), stamp); + if (cached != null) { + return cached; } + if (sequenceReferenceCache != null) { + return resolveProcessingSnapshot(document, + sequenceReferenceCache, + preprocessingNodeProvider, + aliases, + snapshotNodeProvider, + snapshotMergingProcessor, + limits); + } + ResolvedReferenceCache oneShot = resolvedReferenceCache.transientChild(); + try { + ResolvedSnapshot resolved = resolveProcessingSnapshot(document, + oneShot, + preprocessingNodeProvider, + aliases, + snapshotNodeProvider, + snapshotMergingProcessor, + limits); + return publishProcessingSnapshot(resolved, oneShot, stamp); + } finally { + oneShot.close(); + } + } - @Override - public ProcessingSnapshotManager forkTransientSequence() { - return sequenceReferenceCache != null - ? processingSnapshotManager(sequenceReferenceCache.forkTransient()) - : transientSequence(); + @Override + public ResolvedSnapshot fromDocumentTransient(Node document) { + CacheGenerationStamp stamp = operationStamp(); + ResolvedSnapshot cached = cachedProcessingSnapshotFor( + document, processingMetrics(), stamp); + if (cached != null) { + return cached; + } + if (sequenceReferenceCache != null) { + return resolveProcessingSnapshot(document, + sequenceReferenceCache, + preprocessingNodeProvider, + aliases, + snapshotNodeProvider, + snapshotMergingProcessor, + limits); + } + ResolvedReferenceCache oneShot = resolvedReferenceCache.transientChild(); + try { + return resolveProcessingSnapshot(document, + oneShot, + preprocessingNodeProvider, + aliases, + snapshotNodeProvider, + snapshotMergingProcessor, + limits); + } finally { + oneShot.close(); } + } - @Override - public void retainTransientState(FrozenNode canonicalRoot, FrozenNode resolvedRoot) { - if (sequenceReferenceCache != null) { - sequenceReferenceCache.retainOnlyReachableFrom(canonicalRoot, resolvedRoot); + @Override + public ProcessingSnapshotManager transientSequence() { + if (sequenceReferenceCache != null) { + return new BlueProcessingSnapshotManager( + ownerToken, + preprocessingNodeProvider, + snapshotNodeProvider, + snapshotMergingProcessor, + aliases, + limits, + sequenceReferenceCache.transientChild(), + fixedStamp); + } + synchronized (lifecycleLock) { + CacheGenerationStamp active = activeProcessingCacheStamp.get(); + if (active == null) { + awaitCacheInvalidation(); } + ensureOpen(); + Object currentOwnerToken = processorOwnerToken; + CacheGenerationStamp currentStamp = active != null + && active.ownerToken == currentOwnerToken + ? active + : new CacheGenerationStamp(currentOwnerToken, runtimeCacheGeneration); + return new BlueProcessingSnapshotManager( + currentOwnerToken, + nodeProvider, + processorSnapshotNodeProvider(), + mergingProcessor, + Collections.unmodifiableMap(new HashMap<>(preprocessingAliases)), + globalLimits, + resolvedReferenceCache.transientChild(), + currentStamp); } + } - @Override - public boolean isTransientStateCurrent() { - return sequenceReferenceCache == null - || sequenceReferenceCache.isCurrentGeneration(); + @Override + public ProcessingSnapshotManager forkTransientSequence() { + if (sequenceReferenceCache == null) { + return transientSequence(); } - - @Override - public ConformanceEngine transientConformanceEngine(ConformanceEngine conformanceEngine) { - if (conformanceEngine == null) { - return null; - } - ConformanceEngine currentConformanceEngine = - isManagedProcessorConformanceEngine(conformanceEngine) - ? processorConformanceEngine() - : conformanceEngine; - return sequenceReferenceCache != null - ? currentConformanceEngine.transientView(sequenceReferenceCache) - : currentConformanceEngine.transientView(); + return new BlueProcessingSnapshotManager( + ownerToken, + preprocessingNodeProvider, + snapshotNodeProvider, + snapshotMergingProcessor, + aliases, + limits, + sequenceReferenceCache.forkTransient(), + fixedStamp); + } + + @Override + public void retainTransientState(FrozenNode canonicalRoot, FrozenNode resolvedRoot) { + if (sequenceReferenceCache != null) { + sequenceReferenceCache.retainOnlyReachableFrom(canonicalRoot, resolvedRoot); } + } - @Override - public ResolvedSnapshot applyPatch(ResolvedSnapshot snapshot, JsonPatch patch) { - return applyProcessingCanonicalPatch(snapshot, patch); + @Override + public void releaseTransientState() { + if (sequenceReferenceCache != null) { + sequenceReferenceCache.close(); } + } - @Override - public ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { - if (sequenceReferenceCache != null - && !sequenceReferenceCache.isCurrentGeneration()) { - return snapshot; - } - if (sequenceReferenceCache != null) { - sequenceReferenceCache.promoteReferencesReachableFrom( - snapshot.frozenCanonicalRoot()); + @Override + public boolean isTransientStateCurrent() { + return isCurrentCacheStamp(operationStamp()) + && (sequenceReferenceCache == null + || sequenceReferenceCache.isCurrentGeneration()); + } + + @Override + public boolean supportsIncrementalValueResolution() { + return snapshotMergingProcessor instanceof IncrementalMergingProcessorCapability + && ((IncrementalMergingProcessorCapability) snapshotMergingProcessor) + .supportsIncrementalValueResolution(); + } + + @Override + public ConformanceEngine transientConformanceEngine(ConformanceEngine conformanceEngine) { + if (conformanceEngine == null) { + return null; + } + synchronized (managedProcessorConformanceEngines) { + if (managedProcessorConformanceEngines.contains(conformanceEngine)) { + return new ConformanceEngine( + snapshotNodeProvider, + snapshotMergingProcessor, + sequenceReferenceCache != null + ? sequenceReferenceCache + : resolvedReferenceCache); } - return Blue.this.cacheProcessingSnapshot(snapshot); } - }; - } - - private ResolvedSnapshot resolveProcessingSnapshot(Node node) { - return resolveProcessingSnapshot(node, true); - } + return sequenceReferenceCache != null + ? conformanceEngine.transientView(sequenceReferenceCache) + : conformanceEngine.transientView(); + } + + @Override + public ResolvedSnapshot applyPatch(ResolvedSnapshot snapshot, JsonPatch patch) { + operationStamp(); + if (sequenceReferenceCache != null) { + return applyProcessingCanonicalPatch(snapshot, + patch, + snapshotNodeProvider, + snapshotMergingProcessor, + limits, + sequenceReferenceCache); + } + ResolvedReferenceCache oneShot = resolvedReferenceCache.transientChild(); + try { + return applyProcessingCanonicalPatch(snapshot, + patch, + snapshotNodeProvider, + snapshotMergingProcessor, + limits, + oneShot); + } finally { + oneShot.close(); + } + } - private ResolvedSnapshot resolveProcessingSnapshot(Node node, boolean publish) { - ResolvedReferenceCache resolutionCache = publish - ? resolvedReferenceCache - : resolvedReferenceCache.transientChild(); - return resolveProcessingSnapshot(node, publish, resolutionCache); + @Override + public ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { + return publishProcessingSnapshot( + snapshot, sequenceReferenceCache, operationStamp()); + } } private ResolvedSnapshot resolveProcessingSnapshot(Node node, - boolean publish, - ResolvedReferenceCache resolutionCache) { - Node preprocessed = preprocess(node.clone()); - Node resolved = new Merger(mergingProcessor, processorSnapshotNodeProvider(), resolutionCache) - .resolve(preprocessed.clone()); - return snapshotFromResolved(preprocessed, resolved, null, publish, resolutionCache); - } - - private ResolvedSnapshot applyProcessingCanonicalPatch(ResolvedSnapshot snapshot, JsonPatch patch) { - NodeProvider snapshotNodeProvider = processorSnapshotNodeProvider(); + ProcessingOperation operation) { + ResolvedReferenceCache oneShot = resolvedReferenceCache.transientChild(); + try { + ResolvedSnapshot resolved = resolveProcessingSnapshot(node, + oneShot, + operation.preprocessingNodeProvider, + operation.aliases, + operation.snapshotNodeProvider, + operation.snapshotMergingProcessor, + operation.limits); + return publishProcessingSnapshot(resolved, oneShot, operation.stamp); + } finally { + oneShot.close(); + } + } + + private ResolvedSnapshot resolveProcessingSnapshot( + Node node, + ResolvedReferenceCache resolutionCache, + NodeProvider preprocessingNodeProvider, + Map aliases, + NodeProvider snapshotNodeProvider, + MergingProcessor snapshotMergingProcessor, + Limits limits) { + Node preprocessed = preprocess(node.clone(), preprocessingNodeProvider, aliases); + Node resolved = new Merger(snapshotMergingProcessor, + snapshotNodeProvider, + resolutionCache) + .resolve(preprocessed.clone(), limits); + FrozenNode canonicalRoot = FrozenNode.fromNode(new MergeReverser() + .reverseToCanonicalOverlay(resolved.clone(), preprocessed)); + FrozenNode resolvedRoot = resolutionCache.freezeResolved(resolved); + return new ResolvedSnapshot(canonicalRoot, resolvedRoot, canonicalRoot.blueId()); + } + + private ResolvedSnapshot applyProcessingCanonicalPatch( + ResolvedSnapshot snapshot, + JsonPatch patch, + NodeProvider snapshotNodeProvider, + MergingProcessor snapshotMergingProcessor, + Limits limits, + ResolvedReferenceCache resolutionCache) { return applyCanonicalPatch(snapshot, patch, - canonicalRoot -> snapshotFromCanonical(canonicalRoot, snapshotNodeProvider)); + canonicalRoot -> snapshotFromCanonical( + canonicalRoot, + snapshotNodeProvider, + snapshotMergingProcessor, + limits, + resolutionCache)); } private ResolvedSnapshot applyCanonicalPatch( @@ -1057,7 +1974,7 @@ private ResolvedSnapshot applyCanonicalPatch( } private ResolvedSnapshot snapshotFromVerifiedCanonical(FrozenNode canonicalRoot) { - ResolvedSnapshot cached = resolvedSnapshotsByCanonicalRepresentation.get( + ResolvedSnapshot cached = cachedSnapshotByCanonical( canonicalRoot.resolvedStructuralKey()); if (cached != null && cached.verifiedReferenceResolution() != null) { return cached; @@ -1069,7 +1986,7 @@ private ResolvedSnapshot snapshotFromVerifiedCanonical(FrozenNode canonicalRoot) private ResolvedSnapshot snapshotFromCanonical(FrozenNode canonicalRoot, NodeProvider snapshotNodeProvider) { - ResolvedSnapshot cached = resolvedSnapshotsByCanonicalRepresentation.get( + ResolvedSnapshot cached = cachedSnapshotByCanonical( canonicalRoot.resolvedStructuralKey()); if (cached != null) { return cached; @@ -1080,6 +1997,20 @@ private ResolvedSnapshot snapshotFromCanonical(FrozenNode canonicalRoot, return snapshotFromResolved(canonical, resolved, canonicalRoot); } + private ResolvedSnapshot snapshotFromCanonical( + FrozenNode canonicalRoot, + NodeProvider snapshotNodeProvider, + MergingProcessor snapshotMergingProcessor, + Limits limits, + ResolvedReferenceCache resolutionCache) { + Merger merger = new Merger( + snapshotMergingProcessor, snapshotNodeProvider, resolutionCache); + Node canonical = canonicalRoot.toNode(); + Node resolved = merger.resolve(canonical.clone(), limits); + FrozenNode resolvedRoot = resolutionCache.freezeResolved(resolved); + return new ResolvedSnapshot(canonicalRoot, resolvedRoot, canonicalRoot.blueId()); + } + private ResolvedSnapshot snapshotFromResolved(Node preprocessedSource, Node resolved, FrozenNode authoritativeCanonicalRoot) { @@ -1209,7 +2140,7 @@ private NodeProvider registeredExtensionTypeProvider() { }; } - private void registerExternalTypeNode(String blueId, Node canonicalTypeNode) { + private Node validatedExternalTypeNode(String blueId, Node canonicalTypeNode) { if (blueId == null || blueId.isEmpty()) { throw new IllegalArgumentException("blueId must not be empty"); } @@ -1220,34 +2151,677 @@ private void registerExternalTypeNode(String blueId, Node canonicalTypeNode) { throw new IllegalArgumentException("External contract type node hashes to " + calculated + ", not declared BlueId " + blueId); } - externalContractTypeNodes.put(blueId, canonical); + return canonical; } private ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { + Objects.requireNonNull(snapshot, "snapshot"); + CacheSnapshotPublication publication; + synchronized (lifecycleLock) { + ensureOpen(); + publication = cacheSnapshotLocked(snapshot); + } + publication.emit(); + return publication.result; + } + + /** Caller holds lifecycleLock, which linearizes publication with invalidation. */ + private CacheSnapshotPublication cacheSnapshotLocked(ResolvedSnapshot snapshot) { if (snapshot.verifiedReferenceResolution() != null) { resolvedReferenceCache.putVerifiedResolved(snapshot.verifiedReferenceResolution()); - resolvedSnapshotsByBlueId.putIfAbsent(snapshot.blueId(), snapshot); } resolvedReferenceCache.rememberResolvedGraph(snapshot.frozenResolvedRoot()); FrozenNode.ResolvedStructuralKey key = snapshot.frozenCanonicalRoot().resolvedStructuralKey(); - while (true) { - ResolvedSnapshot existing = resolvedSnapshotsByCanonicalRepresentation.get(key); - if (existing == null) { - existing = resolvedSnapshotsByCanonicalRepresentation.putIfAbsent(key, snapshot); - if (existing == null) { - return snapshot; + + ResolvedSnapshot result; + boolean promoteVerifiedEvidenceToPinned = false; + CacheMutationMetrics derivedMutation = null; + CacheMutationMetrics aliasMutation = null; + CacheGaugeSnapshot gauges = null; + ResolvedSnapshot pinned = pinnedSnapshotsByCanonicalRepresentation.get(key); + if (pinned != null) { + ResolvedSnapshot selected = preferVerified(pinned, snapshot); + if (selected != pinned) { + replacePinnedSnapshot(key, pinned, selected); + gauges = captureCacheGauges(); + } + promoteVerifiedEvidenceToPinned = selected.verifiedReferenceResolution() != null; + result = selected; + } else { + ResolvedSnapshot existing = derivedSnapshotsByCanonicalRepresentation.peek(key); + ResolvedSnapshot selected = existing != null + ? preferVerified(existing, snapshot) + : snapshot; + long evictionsBefore = derivedSnapshotsByCanonicalRepresentation.evictions(); + long oversizedBefore = derivedSnapshotsByCanonicalRepresentation.oversizedRejections(); + derivedSnapshotsByCanonicalRepresentation.put(key, selected); + ResolvedSnapshot retained = derivedSnapshotsByCanonicalRepresentation.peek(key); + derivedMutation = captureCacheMutation(DERIVED_SNAPSHOT_CACHE, + derivedSnapshotsByCanonicalRepresentation, + evictionsBefore, + oversizedBefore); + if (retained != null && retained.verifiedReferenceResolution() != null) { + aliasMutation = putDerivedBlueIdAlias(retained); + } + result = retained != null ? retained : selected; + } + if (promoteVerifiedEvidenceToPinned && result.verifiedReferenceResolution() != null) { + resolvedReferenceCache.putPinnedVerifiedResolved( + result.verifiedReferenceResolution()); + } + return new CacheSnapshotPublication(result, + metricsSink(), + derivedMutation, + aliasMutation, + gauges); + } + + private ResolvedSnapshot publishProcessingSnapshot( + ResolvedSnapshot snapshot, + ResolvedReferenceCache transientReferenceCache, + CacheGenerationStamp stamp) { + CacheSnapshotPublication publication; + synchronized (lifecycleLock) { + if (!isCurrentCacheStampLocked(stamp) + || transientReferenceCache != null + && !transientReferenceCache.isCurrentGeneration()) { + return snapshot; + } + if (transientReferenceCache != null) { + transientReferenceCache.promoteReferencesReachableFrom( + snapshot.frozenCanonicalRoot()); + } + publication = cacheSnapshotLocked(snapshot); + } + publication.emit(); + return publication.result; + } + + private void pinSnapshot(ResolvedSnapshot snapshot) { + Objects.requireNonNull(snapshot, "snapshot"); + ensureOpen(); + if (snapshot.verifiedReferenceResolution() != null) { + resolvedReferenceCache.putPinnedVerifiedResolved(snapshot.verifiedReferenceResolution()); + } + resolvedReferenceCache.rememberResolvedGraph(snapshot.frozenResolvedRoot()); + FrozenNode.ResolvedStructuralKey key = + snapshot.frozenCanonicalRoot().resolvedStructuralKey(); + ResolvedSnapshot selected; + CacheGaugeSnapshot gauges; + ProcessingMetricsSink metrics; + synchronized (lifecycleLock) { + ensureOpen(); + ResolvedSnapshot pinned = pinnedSnapshotsByCanonicalRepresentation.get(key); + ResolvedSnapshot derived = derivedSnapshotsByCanonicalRepresentation.peek(key); + selected = preferVerified( + pinned != null ? pinned : derived, + snapshot); + if (pinned == null) { + pinnedSnapshotsByCanonicalRepresentation.put(key, selected); + pinnedSnapshotWeightBytes = saturatedAdd( + pinnedSnapshotWeightBytes, + approximateSnapshotWeightBytes(selected)); + } else if (selected != pinned) { + replacePinnedSnapshot(key, pinned, selected); + } + pinnedSnapshotHighWaterBytes = Math.max( + pinnedSnapshotHighWaterBytes, + pinnedSnapshotWeightBytes); + derivedSnapshotsByCanonicalRepresentation.remove(key); + if (selected.verifiedReferenceResolution() != null) { + pinnedSnapshotsByBlueId.put(selected.blueId(), selected); + derivedSnapshotsByBlueId.remove(selected.blueId()); + } + gauges = captureCacheGauges(); + metrics = metricsSink(); + } + if (selected.verifiedReferenceResolution() != null) { + resolvedReferenceCache.putPinnedVerifiedResolved( + selected.verifiedReferenceResolution()); + } + gauges.emit(metrics); + } + + private void replacePinnedSnapshot(FrozenNode.ResolvedStructuralKey key, + ResolvedSnapshot previous, + ResolvedSnapshot replacement) { + pinnedSnapshotsByCanonicalRepresentation.put(key, replacement); + pinnedSnapshotWeightBytes = Math.max(0L, + pinnedSnapshotWeightBytes - approximateSnapshotWeightBytes(previous)); + pinnedSnapshotWeightBytes = saturatedAdd( + pinnedSnapshotWeightBytes, + approximateSnapshotWeightBytes(replacement)); + pinnedSnapshotHighWaterBytes = Math.max( + pinnedSnapshotHighWaterBytes, + pinnedSnapshotWeightBytes); + if (replacement.verifiedReferenceResolution() != null) { + pinnedSnapshotsByBlueId.put(replacement.blueId(), replacement); + } + } + + private ResolvedSnapshot preferVerified(ResolvedSnapshot existing, + ResolvedSnapshot candidate) { + if (existing == null) { + return candidate; + } + return existing.verifiedReferenceResolution() == null + && candidate.verifiedReferenceResolution() != null + ? candidate + : existing; + } + + private ResolvedSnapshot cachedSnapshotByCanonical( + FrozenNode.ResolvedStructuralKey key) { + ensureOpen(); + ResolvedSnapshot pinned = pinnedSnapshotsByCanonicalRepresentation.get(key); + if (pinned != null) { + metricsSink().incrementCacheHits(PINNED_SNAPSHOT_CACHE); + return pinned; + } + ResolvedSnapshot derived = derivedSnapshotsByCanonicalRepresentation.get(key); + if (derived != null) { + metricsSink().incrementCacheHits(DERIVED_SNAPSHOT_CACHE); + } else { + metricsSink().incrementCacheMisses(DERIVED_SNAPSHOT_CACHE); + } + return derived; + } + + private ResolvedSnapshot cachedSnapshotByBlueId(String blueId) { + ensureOpen(); + ResolvedSnapshot pinned = pinnedSnapshotsByBlueId.get(blueId); + if (pinned != null) { + metricsSink().incrementCacheHits(PINNED_SNAPSHOT_CACHE); + return pinned; + } + WeakReference reference = derivedSnapshotsByBlueId.get(blueId); + ResolvedSnapshot derived = reference != null ? reference.get() : null; + if (derived == null) { + if (reference != null) { + derivedSnapshotsByBlueId.remove(blueId); + } + metricsSink().incrementCacheMisses(CANONICAL_ALIAS_CACHE); + } else { + metricsSink().incrementCacheHits(CANONICAL_ALIAS_CACHE); + } + return derived; + } + + private CacheMutationMetrics putDerivedBlueIdAlias(ResolvedSnapshot snapshot) { + long evictionsBefore = derivedSnapshotsByBlueId.evictions(); + long oversizedBefore = derivedSnapshotsByBlueId.oversizedRejections(); + derivedSnapshotsByBlueId.put(snapshot.blueId(), new WeakReference<>(snapshot)); + return captureCacheMutation(CANONICAL_ALIAS_CACHE, + derivedSnapshotsByBlueId, + evictionsBefore, + oversizedBefore); + } + + private CacheMutationMetrics captureCacheMutation( + String cacheName, + WeightedLruCache cache, + long evictionsBefore, + long oversizedBefore) { + return new CacheMutationMetrics( + cacheName, + cache.evictions() - evictionsBefore, + cache.oversizedRejections() - oversizedBefore, + cache.currentWeight(), + cache.highWaterWeight(), + cache.size()); + } + + private CacheGaugeSnapshot captureCacheGauges() { + List gauges = new ArrayList<>(); + gauges.add(new CacheGauge( + PINNED_SNAPSHOT_CACHE, + pinnedSnapshotWeightBytes, + pinnedSnapshotHighWaterBytes, + pinnedSnapshotsByCanonicalRepresentation.size(), + pinnedSnapshotsByCanonicalRepresentation.size(), + -1)); + gauges.add(new CacheGauge( + DERIVED_SNAPSHOT_CACHE, + derivedSnapshotsByCanonicalRepresentation.currentWeight(), + derivedSnapshotsByCanonicalRepresentation.highWaterWeight(), + derivedSnapshotsByCanonicalRepresentation.size(), + -1, + derivedSnapshotsByCanonicalRepresentation.size())); + gauges.add(new CacheGauge( + CANONICAL_ALIAS_CACHE, + derivedSnapshotsByBlueId.currentWeight(), + derivedSnapshotsByBlueId.highWaterWeight(), + derivedSnapshotsByBlueId.size(), + -1, + derivedSnapshotsByBlueId.size())); + gauges.add(new CacheGauge( + RECENT_PROCESSING_CACHE, + recentProcessingDocumentSnapshots.currentWeight(), + recentProcessingDocumentSnapshots.highWaterWeight(), + recentProcessingDocumentSnapshots.size(), + -1, + recentProcessingDocumentSnapshots.size())); + ResolvedReferenceCache.CacheStats reference = resolvedReferenceCache.cacheStats(); + gauges.add(new CacheGauge( + VERIFIED_REFERENCE_CACHE, + reference.verifiedCurrentWeightBytes(), + reference.verifiedHighWaterWeightBytes(), + reference.verifiedEntries(), + reference.pinnedVerifiedEntries(), + reference.verifiedEntries() - reference.pinnedVerifiedEntries())); + gauges.add(new CacheGauge( + TRANSIENT_REFERENCE_CACHE, + reference.transientTrustedCurrentWeightBytes(), + reference.transientTrustedHighWaterWeightBytes(), + reference.transientTrustedEntries(), + -1, + -1)); + gauges.add(new CacheGauge( + STRUCTURAL_INTERNER_CACHE, + reference.structuralCurrentWeightBytes(), + reference.structuralHighWaterWeightBytes(), + reference.structuralEntries(), + -1, + -1)); + return new CacheGaugeSnapshot(gauges); + } + + private static final class CacheSnapshotPublication { + private final ResolvedSnapshot result; + private final ProcessingMetricsSink metrics; + private final CacheMutationMetrics derivedMutation; + private final CacheMutationMetrics aliasMutation; + private final CacheGaugeSnapshot gauges; + + private CacheSnapshotPublication(ResolvedSnapshot result, + ProcessingMetricsSink metrics, + CacheMutationMetrics derivedMutation, + CacheMutationMetrics aliasMutation, + CacheGaugeSnapshot gauges) { + this.result = result; + this.metrics = metrics; + this.derivedMutation = derivedMutation; + this.aliasMutation = aliasMutation; + this.gauges = gauges; + } + + private void emit() { + if (derivedMutation != null) { + derivedMutation.emit(metrics); + } + if (aliasMutation != null) { + aliasMutation.emit(metrics); + } + if (gauges != null) { + gauges.emit(metrics); + } + } + } + + private static final class CacheMutationMetrics { + private final String cacheName; + private final long evictionDelta; + private final long oversizedDelta; + private final long currentWeight; + private final long highWaterWeight; + private final int entries; + + private CacheMutationMetrics(String cacheName, + long evictionDelta, + long oversizedDelta, + long currentWeight, + long highWaterWeight, + int entries) { + this.cacheName = cacheName; + this.evictionDelta = evictionDelta; + this.oversizedDelta = oversizedDelta; + this.currentWeight = currentWeight; + this.highWaterWeight = highWaterWeight; + this.entries = entries; + } + + private void emit(ProcessingMetricsSink metrics) { + if (evictionDelta > 0L) { + metrics.addMetric("cache." + cacheName + ".evictions", evictionDelta); + } + if (oversizedDelta > 0L) { + metrics.addMetric( + "cache." + cacheName + ".oversizedRejections", oversizedDelta); + } + metrics.setCacheCurrentWeightBytes(cacheName, currentWeight); + metrics.recordCacheHighWaterBytes(cacheName, highWaterWeight); + metrics.setCacheEntries(cacheName, entries); + } + } + + private static final class CacheGaugeSnapshot { + private final List gauges; + + private CacheGaugeSnapshot(List gauges) { + this.gauges = gauges; + } + + private void emit(ProcessingMetricsSink metrics) { + for (CacheGauge gauge : gauges) { + metrics.setCacheCurrentWeightBytes(gauge.cacheName, gauge.currentWeight); + metrics.recordCacheHighWaterBytes(gauge.cacheName, gauge.highWaterWeight); + metrics.setCacheEntries(gauge.cacheName, gauge.entries); + if (gauge.pinnedEntries >= 0) { + metrics.setCachePinnedEntries(gauge.cacheName, gauge.pinnedEntries); + } + if (gauge.derivedEntries >= 0) { + metrics.setCacheDerivedEntries(gauge.cacheName, gauge.derivedEntries); + } + } + } + } + + private static final class CacheGauge { + private final String cacheName; + private final long currentWeight; + private final long highWaterWeight; + private final int entries; + private final int pinnedEntries; + private final int derivedEntries; + + private CacheGauge(String cacheName, + long currentWeight, + long highWaterWeight, + int entries, + int pinnedEntries, + int derivedEntries) { + this.cacheName = cacheName; + this.currentWeight = currentWeight; + this.highWaterWeight = highWaterWeight; + this.entries = entries; + this.pinnedEntries = pinnedEntries; + this.derivedEntries = derivedEntries; + } + } + + private static final class CacheGenerationStamp { + private final Object ownerToken; + private final long generation; + + private CacheGenerationStamp(Object ownerToken, long generation) { + this.ownerToken = ownerToken; + this.generation = generation; + } + + private static CacheGenerationStamp invalid(Object ownerToken) { + return new CacheGenerationStamp(ownerToken, -1L); + } + } + + private static final class ProcessingOperation { + private final DocumentProcessor processor; + private final CacheGenerationStamp stamp; + private final NodeProvider preprocessingNodeProvider; + private final NodeProvider snapshotNodeProvider; + private final MergingProcessor snapshotMergingProcessor; + private final Map aliases; + private final Limits limits; + + private ProcessingOperation(DocumentProcessor processor, + CacheGenerationStamp stamp, + NodeProvider preprocessingNodeProvider, + NodeProvider snapshotNodeProvider, + MergingProcessor snapshotMergingProcessor, + Map aliases, + Limits limits) { + this.processor = processor; + this.stamp = stamp; + this.preprocessingNodeProvider = preprocessingNodeProvider; + this.snapshotNodeProvider = snapshotNodeProvider; + this.snapshotMergingProcessor = snapshotMergingProcessor; + this.aliases = aliases; + this.limits = limits; + } + } + + private static final class ConfigurationRefresh { + private final DocumentProcessor processorToClose; + private final ProcessingMetricsSink metrics; + private final CacheGaugeSnapshot gauges; + + private ConfigurationRefresh(DocumentProcessor processorToClose, + ProcessingMetricsSink metrics, + CacheGaugeSnapshot gauges) { + this.processorToClose = processorToClose; + this.metrics = metrics; + this.gauges = gauges; + } + } + + private BlueCacheStats.Region cacheRegion(WeightedLruCache cache, + boolean pinned) { + return new BlueCacheStats.Region( + cache.size(), + cache.currentWeight(), + cache.highWaterWeight(), + cache.hits(), + cache.misses(), + cache.evictions(), + cache.oversizedRejections(), + pinned); + } + + private static long approximateSnapshotWeightBytes(ResolvedSnapshot snapshot) { + long roots = FrozenNode.approximateRetainedWeightBytesOf( + snapshot.frozenCanonicalRoot(), snapshot.frozenResolvedRoot()); + return saturatedAdd(192L + 2L * snapshot.blueId().length(), roots); + } + + private static long saturatedAdd(long left, long right) { + return Long.MAX_VALUE - left < right ? Long.MAX_VALUE : left + right; + } + + private long clearReloadableRuntimeCaches() { + runtimeCacheGeneration++; + long released = derivedSnapshotsByCanonicalRepresentation.clear(); + released = saturatedAdd(released, derivedSnapshotsByBlueId.clear()); + released = saturatedAdd(released, recentProcessingDocumentSnapshots.clear()); + ResolvedReferenceCache.CacheStats reference = resolvedReferenceCache.cacheStats(); + long pinnedReferenceWeight = resolvedReferenceCache.pinnedVerifiedWeightBytes(); + released = saturatedAdd(released, Math.max(0L, + reference.verifiedCurrentWeightBytes() - pinnedReferenceWeight)); + released = saturatedAdd(released, reference.transientTrustedCurrentWeightBytes()); + released = saturatedAdd(released, reference.structuralCurrentWeightBytes()); + resolvedReferenceCache.clearReloadable(); + return released; + } + + private long clearAllRuntimeCaches() { + runtimeCacheGeneration++; + long released = pinnedSnapshotWeightBytes; + pinnedSnapshotsByBlueId.clear(); + pinnedSnapshotsByCanonicalRepresentation.clear(); + pinnedSnapshotWeightBytes = 0L; + released = saturatedAdd(released, + derivedSnapshotsByCanonicalRepresentation.clear()); + released = saturatedAdd(released, derivedSnapshotsByBlueId.clear()); + released = saturatedAdd(released, recentProcessingDocumentSnapshots.clear()); + ResolvedReferenceCache.CacheStats reference = resolvedReferenceCache.cacheStats(); + released = saturatedAdd(released, reference.verifiedCurrentWeightBytes()); + released = saturatedAdd(released, reference.transientTrustedCurrentWeightBytes()); + released = saturatedAdd(released, reference.structuralCurrentWeightBytes()); + resolvedReferenceCache.clear(); + return released; + } + + private static void closeProcessor(DocumentProcessor processor) { + if (processor != null) { + processor.close(); + } + } + + private ProcessingMetricsSink metricsSink() { + return documentProcessor != null + ? documentProcessor.processingMetricsSink() + : lifecycleMetricsSink; + } + + private void ensureOpen() { + if (closed || (closeInProgress + && activeProcessingCacheStamp.get() == null + && directCacheOperationDepth.get() == null + && cacheInvalidationThread != Thread.currentThread())) { + throw new IllegalStateException("Blue runtime is closed"); + } + } + + /** Returns whether this runtime has released its owned caches. */ + public boolean isClosed() { + return closed; + } + + /** + * Releases pinned authoritative content and all derived/transient cache + * state owned by this runtime. Closing is idempotent. An external close + * waits for provider-, processor-, and cache-backed operations admitted + * through this {@code Blue} instance, while preventing new runtime work from + * starting. Direct operations on a retained {@link #getDocumentProcessor() + * processor handle} must be completed by the caller before close. A close + * attempted reentrantly by active runtime work is rejected with + * {@link IllegalStateException} to avoid waiting for itself. Pure serialization + * helpers remain usable; runtime work rejects later calls. + */ + @Override + public void close() { + ProcessingMetricsSink metrics; + DocumentProcessor processorToClose; + CacheGaugeSnapshot gauges; + long released; + boolean firstClose; + Throwable previousFailure; + synchronized (lifecycleLock) { + if (closeInProgress && closingThread == Thread.currentThread()) { + // A close-time processor/metrics callback must not recursively + // re-emit close metrics or wait for its own initiating frame. + return; + } + if (activeProcessingCacheStamp.get() != null + || directCacheOperationDepth.get() != null) { + throw new IllegalStateException( + "Blue runtime cannot close from active runtime work"); + } + while (closeInProgress) { + try { + lifecycleLock.wait(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while waiting for Blue runtime close", exception); } } - if (existing.verifiedReferenceResolution() == null - && snapshot.verifiedReferenceResolution() != null) { - if (resolvedSnapshotsByCanonicalRepresentation.replace(key, existing, snapshot)) { - return snapshot; + if (cacheInvalidationInProgress + && cacheInvalidationThread == Thread.currentThread()) { + throw new IllegalStateException( + "Blue runtime cannot close while cache invalidation waits for current work"); + } + closingThread = Thread.currentThread(); + closeInProgress = true; + try { + awaitCacheInvalidation(); + while (activeProcessingOperations > 0 || activeDirectCacheOperations > 0) { + try { + lifecycleLock.wait(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while waiting for active Blue runtime work", + exception); + } } - continue; + } catch (RuntimeException | Error exception) { + closeInProgress = false; + closingThread = null; + lifecycleLock.notifyAll(); + throw exception; + } + metrics = metricsSink(); + if (closed) { + processorToClose = null; + gauges = null; + released = 0L; + firstClose = false; + previousFailure = lifecycleCloseFailure; + } else { + lifecycleMetricsSink = metrics; + closed = true; + processorOwnerToken = new Object(); + processorToClose = documentProcessorOwned ? documentProcessor : null; + long processorWeight = processorToClose != null + ? processorToClose.cacheWeightBytes() : 0L; + processorPlanCacheHighWaterBytes = Math.max( + processorPlanCacheHighWaterBytes, processorWeight); + documentProcessor = null; + documentProcessorOwned = false; + released = saturatedAdd(clearAllRuntimeCaches(), processorWeight); + externalContractTypeNodes.clear(); + synchronized (managedProcessorConformanceEngines) { + managedProcessorConformanceEngines.clear(); + } + gauges = captureCacheGauges(); + firstClose = true; + previousFailure = null; + } + } + + Throwable failure = previousFailure; + if (firstClose) { + try { + resolvedReferenceCache.close(); + } catch (Throwable throwable) { + failure = throwable; + } + try { + closeProcessor(processorToClose); + } catch (Throwable throwable) { + failure = combineFailure(failure, throwable); + } + } + try { + metrics.incrementRuntimeCloseCalls(); + if (firstClose) { + gauges.emit(metrics); + metrics.addRuntimeCloseReleasedWeightBytes(released); } - return existing; + } catch (Throwable throwable) { + failure = combineFailure(failure, throwable); + } finally { + synchronized (lifecycleLock) { + lifecycleCloseFailure = failure; + closeInProgress = false; + closingThread = null; + lifecycleLock.notifyAll(); + } + } + rethrowCloseFailure(failure); + } + + private static Throwable combineFailure(Throwable first, Throwable next) { + if (first == null) { + return next; + } + if (first != next) { + first.addSuppressed(next); + } + return first; + } + + private static void rethrowCloseFailure(Throwable failure) { + if (failure == null) { + return; + } + if (failure instanceof RuntimeException) { + throw (RuntimeException) failure; + } + if (failure instanceof Error) { + throw (Error) failure; } + throw new IllegalStateException("Failed to close Blue runtime", failure); } private ResolvedSnapshot cacheProcessingSnapshot(ResolvedSnapshot snapshot) { diff --git a/src/main/java/blue/language/BlueCachePolicy.java b/src/main/java/blue/language/BlueCachePolicy.java new file mode 100644 index 0000000..bcefe84 --- /dev/null +++ b/src/main/java/blue/language/BlueCachePolicy.java @@ -0,0 +1,170 @@ +package blue.language; + +/** + * Immutable bounds for reloadable acceleration data owned by one {@link Blue} + * runtime. Explicitly registered authoritative snapshots are not evicted by + * these limits; they remain pinned until clear or close. + */ +public final class BlueCachePolicy { + + private static final long MIB = 1024L * 1024L; + + private final int derivedSnapshotMaxEntries; + private final long derivedSnapshotMaxWeightBytes; + private final int canonicalAliasMaxEntries; + private final long canonicalAliasMaxWeightBytes; + private final int resolvedStructuralMaxEntries; + private final long resolvedStructuralMaxWeightBytes; + private final int transientReferenceMaxEntries; + private final long transientReferenceMaxWeightBytes; + private final int conformancePlanMaxEntries; + private final long conformancePlanMaxWeightBytes; + private final long maximumDerivedEntryWeightBytes; + + private BlueCachePolicy(Builder builder) { + this.derivedSnapshotMaxEntries = positive(builder.derivedSnapshotMaxEntries, + "derivedSnapshotMaxEntries"); + this.derivedSnapshotMaxWeightBytes = positive(builder.derivedSnapshotMaxWeightBytes, + "derivedSnapshotMaxWeightBytes"); + this.canonicalAliasMaxEntries = positive(builder.canonicalAliasMaxEntries, + "canonicalAliasMaxEntries"); + this.canonicalAliasMaxWeightBytes = positive(builder.canonicalAliasMaxWeightBytes, + "canonicalAliasMaxWeightBytes"); + this.resolvedStructuralMaxEntries = positive(builder.resolvedStructuralMaxEntries, + "resolvedStructuralMaxEntries"); + this.resolvedStructuralMaxWeightBytes = positive(builder.resolvedStructuralMaxWeightBytes, + "resolvedStructuralMaxWeightBytes"); + this.transientReferenceMaxEntries = positive(builder.transientReferenceMaxEntries, + "transientReferenceMaxEntries"); + this.transientReferenceMaxWeightBytes = positive(builder.transientReferenceMaxWeightBytes, + "transientReferenceMaxWeightBytes"); + this.conformancePlanMaxEntries = positive(builder.conformancePlanMaxEntries, + "conformancePlanMaxEntries"); + this.conformancePlanMaxWeightBytes = positive(builder.conformancePlanMaxWeightBytes, + "conformancePlanMaxWeightBytes"); + this.maximumDerivedEntryWeightBytes = positive(builder.maximumDerivedEntryWeightBytes, + "maximumDerivedEntryWeightBytes"); + } + + public static BlueCachePolicy boundedDefaults() { + return builder().build(); + } + + public static Builder builder() { + return new Builder(); + } + + public int derivedSnapshotMaxEntries() { + return derivedSnapshotMaxEntries; + } + + public long derivedSnapshotMaxWeightBytes() { + return derivedSnapshotMaxWeightBytes; + } + + public int canonicalAliasMaxEntries() { + return canonicalAliasMaxEntries; + } + + public long canonicalAliasMaxWeightBytes() { + return canonicalAliasMaxWeightBytes; + } + + public int resolvedStructuralMaxEntries() { + return resolvedStructuralMaxEntries; + } + + public long resolvedStructuralMaxWeightBytes() { + return resolvedStructuralMaxWeightBytes; + } + + public int transientReferenceMaxEntries() { + return transientReferenceMaxEntries; + } + + public long transientReferenceMaxWeightBytes() { + return transientReferenceMaxWeightBytes; + } + + public int conformancePlanMaxEntries() { + return conformancePlanMaxEntries; + } + + public long conformancePlanMaxWeightBytes() { + return conformancePlanMaxWeightBytes; + } + + public long maximumDerivedEntryWeightBytes() { + return maximumDerivedEntryWeightBytes; + } + + private static int positive(int value, String name) { + if (value <= 0) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + + private static long positive(long value, String name) { + if (value <= 0L) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + + public static final class Builder { + private int derivedSnapshotMaxEntries = 256; + private long derivedSnapshotMaxWeightBytes = 256L * MIB; + private int canonicalAliasMaxEntries = 512; + private long canonicalAliasMaxWeightBytes = 64L * MIB; + private int resolvedStructuralMaxEntries = 16_384; + private long resolvedStructuralMaxWeightBytes = 256L * MIB; + private int transientReferenceMaxEntries = 4_096; + private long transientReferenceMaxWeightBytes = 128L * MIB; + private int conformancePlanMaxEntries = 4_096; + private long conformancePlanMaxWeightBytes = 128L * MIB; + private long maximumDerivedEntryWeightBytes = 64L * MIB; + + private Builder() { + } + + public Builder derivedSnapshots(int maxEntries, long maxWeightBytes) { + this.derivedSnapshotMaxEntries = maxEntries; + this.derivedSnapshotMaxWeightBytes = maxWeightBytes; + return this; + } + + public Builder canonicalAliases(int maxEntries, long maxWeightBytes) { + this.canonicalAliasMaxEntries = maxEntries; + this.canonicalAliasMaxWeightBytes = maxWeightBytes; + return this; + } + + public Builder resolvedStructuralEntries(int maxEntries, long maxWeightBytes) { + this.resolvedStructuralMaxEntries = maxEntries; + this.resolvedStructuralMaxWeightBytes = maxWeightBytes; + return this; + } + + public Builder transientReferences(int maxEntries, long maxWeightBytes) { + this.transientReferenceMaxEntries = maxEntries; + this.transientReferenceMaxWeightBytes = maxWeightBytes; + return this; + } + + public Builder conformancePlans(int maxEntries, long maxWeightBytes) { + this.conformancePlanMaxEntries = maxEntries; + this.conformancePlanMaxWeightBytes = maxWeightBytes; + return this; + } + + public Builder maximumDerivedEntryWeightBytes(long maxWeightBytes) { + this.maximumDerivedEntryWeightBytes = maxWeightBytes; + return this; + } + + public BlueCachePolicy build() { + return new BlueCachePolicy(this); + } + } +} diff --git a/src/main/java/blue/language/BlueCacheStats.java b/src/main/java/blue/language/BlueCacheStats.java new file mode 100644 index 0000000..0ac983f --- /dev/null +++ b/src/main/java/blue/language/BlueCacheStats.java @@ -0,0 +1,130 @@ +package blue.language; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Immutable cache-ownership and weight snapshot for one {@link Blue} runtime. + * Weights are conservative estimates intended for bounding and operational + * observability rather than exact heap-size measurements. + */ +public final class BlueCacheStats { + + private final Map regions; + private final boolean closed; + + BlueCacheStats(Map regions, boolean closed) { + this.regions = Collections.unmodifiableMap(new LinkedHashMap<>( + Objects.requireNonNull(regions, "regions"))); + this.closed = closed; + } + + /** Cache regions keyed by the metric name reported by this runtime. */ + public Map regions() { + return regions; + } + + public Region region(String name) { + return regions.get(name); + } + + public long currentWeightBytes() { + long total = 0L; + for (Region region : regions.values()) { + total = saturatedAdd(total, region.currentWeightBytes()); + } + return total; + } + + public int entries() { + int total = 0; + for (Region region : regions.values()) { + if (Integer.MAX_VALUE - total < region.entries()) { + return Integer.MAX_VALUE; + } + total += region.entries(); + } + return total; + } + + public boolean isClosed() { + return closed; + } + + private static long saturatedAdd(long left, long right) { + return Long.MAX_VALUE - left < right ? Long.MAX_VALUE : left + right; + } + + /** Immutable counters for one ownership/cache region. */ + public static final class Region { + private final int entries; + private final long currentWeightBytes; + private final long highWaterWeightBytes; + private final long hits; + private final long misses; + private final long evictions; + private final long oversizedRejections; + private final boolean pinned; + + Region(int entries, + long currentWeightBytes, + long highWaterWeightBytes, + long hits, + long misses, + long evictions, + long oversizedRejections, + boolean pinned) { + if (entries < 0 + || currentWeightBytes < 0L + || highWaterWeightBytes < 0L + || hits < 0L + || misses < 0L + || evictions < 0L + || oversizedRejections < 0L) { + throw new IllegalArgumentException("Cache statistics must not be negative"); + } + this.entries = entries; + this.currentWeightBytes = currentWeightBytes; + this.highWaterWeightBytes = highWaterWeightBytes; + this.hits = hits; + this.misses = misses; + this.evictions = evictions; + this.oversizedRejections = oversizedRejections; + this.pinned = pinned; + } + + public int entries() { + return entries; + } + + public long currentWeightBytes() { + return currentWeightBytes; + } + + public long highWaterWeightBytes() { + return highWaterWeightBytes; + } + + public long hits() { + return hits; + } + + public long misses() { + return misses; + } + + public long evictions() { + return evictions; + } + + public long oversizedRejections() { + return oversizedRejections; + } + + public boolean isPinned() { + return pinned; + } + } +} diff --git a/src/main/java/blue/language/BlueViewPath.java b/src/main/java/blue/language/BlueViewPath.java index 76f4d72..837130a 100644 --- a/src/main/java/blue/language/BlueViewPath.java +++ b/src/main/java/blue/language/BlueViewPath.java @@ -82,7 +82,7 @@ private static Node child(Node node, List segments, int index) { } private static Node item(Node node, String indexSegment) { - if (node.getItems() == null || !indexSegment.matches("0|[1-9]\\d*")) { + if (node.getItems() == null || !isCanonicalArrayIndex(indexSegment)) { return null; } int index; @@ -94,6 +94,26 @@ private static Node item(Node node, String indexSegment) { return index < node.getItems().size() ? node.getItems().get(index) : null; } + private static boolean isCanonicalArrayIndex(String value) { + if (value == null || value.isEmpty()) { + return false; + } + char first = value.charAt(0); + if (first == '0') { + return value.length() == 1; + } + if (first < '1' || first > '9') { + return false; + } + for (int index = 1; index < value.length(); index++) { + char digit = value.charAt(index); + if (digit < '0' || digit > '9') { + return false; + } + } + return true; + } + private static String unescape(String segment) { StringBuilder builder = new StringBuilder(segment.length()); for (int i = 0; i < segment.length(); i++) { diff --git a/src/main/java/blue/language/WeightedLruCache.java b/src/main/java/blue/language/WeightedLruCache.java new file mode 100644 index 0000000..aead5f3 --- /dev/null +++ b/src/main/java/blue/language/WeightedLruCache.java @@ -0,0 +1,143 @@ +package blue.language; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Small synchronized weighted LRU for reloadable derived state. */ +final class WeightedLruCache { + + public interface Weigher { + long weightOf(V value); + } + + private final int maximumEntries; + private final long maximumWeight; + private final long maximumEntryWeight; + private final Weigher weigher; + private final LinkedHashMap> entries = + new LinkedHashMap>(16, 0.75f, true); + private long currentWeight; + private long highWaterWeight; + private long evictions; + private long oversizedRejections; + private long hits; + private long misses; + + public WeightedLruCache(int maximumEntries, + long maximumWeight, + long maximumEntryWeight, + Weigher weigher) { + if (maximumEntries <= 0 || maximumWeight <= 0L || maximumEntryWeight <= 0L) { + throw new IllegalArgumentException("Cache bounds must be positive"); + } + if (weigher == null) { + throw new IllegalArgumentException("weigher must not be null"); + } + this.maximumEntries = maximumEntries; + this.maximumWeight = maximumWeight; + this.maximumEntryWeight = maximumEntryWeight; + this.weigher = weigher; + } + + public synchronized V get(K key) { + Entry entry = entries.get(key); + if (entry == null) { + misses++; + } else { + hits++; + } + return entry != null ? entry.value : null; + } + + /** Returns a value without changing hit/miss counters. */ + public synchronized V peek(K key) { + Entry entry = entries.get(key); + return entry != null ? entry.value : null; + } + + public synchronized V put(K key, V value) { + if (key == null || value == null) { + throw new IllegalArgumentException("Cache keys and values must not be null"); + } + long weight = Math.max(1L, weigher.weightOf(value)); + if (weight > maximumEntryWeight || weight > maximumWeight) { + oversizedRejections++; + Entry previous = entries.get(key); + return previous != null ? previous.value : null; + } + Entry previous = entries.remove(key); + if (previous != null) { + currentWeight -= previous.weight; + } + entries.put(key, new Entry(value, weight)); + currentWeight += weight; + if (currentWeight > highWaterWeight) { + highWaterWeight = currentWeight; + } + evictToBounds(); + return previous != null ? previous.value : null; + } + + public synchronized V remove(K key) { + Entry removed = entries.remove(key); + if (removed != null) { + currentWeight -= removed.weight; + return removed.value; + } + return null; + } + + public synchronized long clear() { + long released = currentWeight; + entries.clear(); + currentWeight = 0L; + return released; + } + + public synchronized int size() { + return entries.size(); + } + + public synchronized long currentWeight() { + return currentWeight; + } + + public synchronized long highWaterWeight() { + return highWaterWeight; + } + + public synchronized long evictions() { + return evictions; + } + + public synchronized long oversizedRejections() { + return oversizedRejections; + } + + public synchronized long hits() { + return hits; + } + + public synchronized long misses() { + return misses; + } + + private void evictToBounds() { + while (entries.size() > maximumEntries || currentWeight > maximumWeight) { + Map.Entry> eldest = entries.entrySet().iterator().next(); + currentWeight -= eldest.getValue().weight; + entries.remove(eldest.getKey()); + evictions++; + } + } + + private static final class Entry { + private final V value; + private final long weight; + + private Entry(V value, long weight) { + this.value = value; + this.weight = weight; + } + } +} diff --git a/src/main/java/blue/language/conformance/ConformanceEngine.java b/src/main/java/blue/language/conformance/ConformanceEngine.java index 96cbb5a..1a82bcd 100644 --- a/src/main/java/blue/language/conformance/ConformanceEngine.java +++ b/src/main/java/blue/language/conformance/ConformanceEngine.java @@ -1,7 +1,9 @@ package blue.language.conformance; +import blue.language.BlueCachePolicy; import blue.language.NodeProvider; import blue.language.merge.Merger; +import blue.language.merge.IncrementalMergingProcessorCapability; import blue.language.merge.MergingProcessor; import blue.language.model.Node; import blue.language.snapshot.FrozenNode; @@ -15,11 +17,12 @@ import java.util.Objects; import java.util.Set; -public final class ConformanceEngine { +public final class ConformanceEngine implements AutoCloseable { private final NodeProvider nodeProvider; private final MergingProcessor mergingProcessor; private final ResolvedReferenceCache resolvedReferenceCache; + private final boolean ownsReferenceCache; public ConformanceEngine(NodeProvider nodeProvider, MergingProcessor mergingProcessor) { this(nodeProvider, mergingProcessor, null); @@ -28,9 +31,49 @@ public ConformanceEngine(NodeProvider nodeProvider, MergingProcessor mergingProc public ConformanceEngine(NodeProvider nodeProvider, MergingProcessor mergingProcessor, ResolvedReferenceCache resolvedReferenceCache) { + this(nodeProvider, mergingProcessor, resolvedReferenceCache, false); + } + + /** + * Creates an engine with an independent bounded reference cache that is + * released when the engine is closed. This is suitable for handles whose + * lifetime may outlast the runtime configuration that created them. + */ + public static ConformanceEngine withIsolatedCache( + NodeProvider nodeProvider, + MergingProcessor mergingProcessor, + BlueCachePolicy cachePolicy) { + return new ConformanceEngine(nodeProvider, + mergingProcessor, + new ResolvedReferenceCache(Objects.requireNonNull(cachePolicy, "cachePolicy")), + true); + } + + /** + * Creates an engine with an independent cache seeded from the verified + * entries that are caller-pinned in {@code seedSource} at creation time. + * Later source-cache invalidation cannot affect this engine, and entries + * discovered by this engine cannot be published back to the source. + */ + public static ConformanceEngine withIsolatedCache( + NodeProvider nodeProvider, + MergingProcessor mergingProcessor, + ResolvedReferenceCache seedSource) { + return new ConformanceEngine(nodeProvider, + mergingProcessor, + Objects.requireNonNull(seedSource, "seedSource") + .isolatedCopyOfPinnedVerifiedEntries(), + true); + } + + private ConformanceEngine(NodeProvider nodeProvider, + MergingProcessor mergingProcessor, + ResolvedReferenceCache resolvedReferenceCache, + boolean ownsReferenceCache) { this.nodeProvider = NodeProviderWrapper.wrap(nodeProvider); this.mergingProcessor = Objects.requireNonNull(mergingProcessor, "mergingProcessor"); this.resolvedReferenceCache = resolvedReferenceCache; + this.ownsReferenceCache = ownsReferenceCache; } /** @@ -41,14 +84,35 @@ public ConformanceEngine transientView() { if (resolvedReferenceCache == null) { return this; } - return transientView(resolvedReferenceCache.transientChild()); + return new ConformanceEngine(nodeProvider, + mergingProcessor, + resolvedReferenceCache.transientChild(), + true); } /** Creates a planning view backed by the supplied sequence-local cache. */ public ConformanceEngine transientView(ResolvedReferenceCache transientReferenceCache) { return new ConformanceEngine(nodeProvider, mergingProcessor, - Objects.requireNonNull(transientReferenceCache, "transientReferenceCache")); + Objects.requireNonNull(transientReferenceCache, "transientReferenceCache"), + false); + } + + @Override + public void close() { + if (ownsReferenceCache && resolvedReferenceCache != null) { + resolvedReferenceCache.close(); + } + } + + /** + * Returns whether this engine uses the exact built-in merge pipeline that + * participates in conservative value-only dependency analysis. + */ + public boolean supportsIncrementalValueResolution() { + return mergingProcessor instanceof IncrementalMergingProcessorCapability + && ((IncrementalMergingProcessorCapability) mergingProcessor) + .supportsIncrementalValueResolution(); } public ConformanceResult check(Node node) { diff --git a/src/main/java/blue/language/merge/IncrementalMergingProcessorCapability.java b/src/main/java/blue/language/merge/IncrementalMergingProcessorCapability.java new file mode 100644 index 0000000..5104760 --- /dev/null +++ b/src/main/java/blue/language/merge/IncrementalMergingProcessorCapability.java @@ -0,0 +1,17 @@ +package blue.language.merge; + +/** + * Optional capability advertised by merging processors whose behavior is + * understood by the conservative patch-impact analyzer. + * + *

Unknown and custom processors intentionally do not participate. They + * retain the historical authoritative whole-snapshot resolution path.

+ */ +public interface IncrementalMergingProcessorCapability { + + /** + * Whether value-only replacements may use dependency-proven incremental + * snapshot resolution. + */ + boolean supportsIncrementalValueResolution(); +} diff --git a/src/main/java/blue/language/merge/processor/SequentialMergingProcessor.java b/src/main/java/blue/language/merge/processor/SequentialMergingProcessor.java index ee3167e..14fe57c 100644 --- a/src/main/java/blue/language/merge/processor/SequentialMergingProcessor.java +++ b/src/main/java/blue/language/merge/processor/SequentialMergingProcessor.java @@ -3,11 +3,12 @@ import blue.language.NodeProvider; import blue.language.model.Node; import blue.language.merge.MergingProcessor; +import blue.language.merge.IncrementalMergingProcessorCapability; import blue.language.merge.NodeResolver; import java.util.List; -public class SequentialMergingProcessor implements MergingProcessor { +public class SequentialMergingProcessor implements MergingProcessor, IncrementalMergingProcessorCapability { private final List mergingProcessors; @@ -53,4 +54,22 @@ public void validateCompleted(Node node, boolean semanticallyPresent, String pat } } } + + /** + * The built-in processor sequence has path-local behavior that the + * patch-impact analyzer understands. A subclass or a sequence with any + * additional/reordered processor is treated as custom and falls back. + */ + @Override + public boolean supportsIncrementalValueResolution() { + return getClass() == SequentialMergingProcessor.class + && mergingProcessors.size() == 7 + && mergingProcessors.get(0).getClass() == ValuePropagator.class + && mergingProcessors.get(1).getClass() == TypeAssigner.class + && mergingProcessors.get(2).getClass() == ListProcessor.class + && mergingProcessors.get(3).getClass() == DictionaryProcessor.class + && mergingProcessors.get(4).getClass() == SchemaPropagator.class + && mergingProcessors.get(5).getClass() == SchemaVerifier.class + && mergingProcessors.get(6).getClass() == BasicTypesVerifier.class; + } } diff --git a/src/main/java/blue/language/model/Node.java b/src/main/java/blue/language/model/Node.java index cd58c6a..07d5ff4 100644 --- a/src/main/java/blue/language/model/Node.java +++ b/src/main/java/blue/language/model/Node.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import java.lang.reflect.Array; import java.math.BigDecimal; import java.math.BigInteger; import java.util.*; @@ -312,7 +313,7 @@ public Node replaceWith(Node source) { this.name = source.name; this.description = source.description; - this.value = source.value; + this.value = copyValue(source.value, new IdentityHashMap()); this.blueId = source.blueId; this.mergePolicy = source.mergePolicy; this.previousBlueId = source.previousBlueId; @@ -341,6 +342,134 @@ public Node replaceWith(Node source) { return this; } + /** Deep-copies JSON container values so a cloned Node owns its mutable payload graph. */ + private static Object copyValue(Object source, IdentityHashMap copies) { + if (source == null || source instanceof String || source instanceof Number + || source instanceof Boolean || source instanceof Character + || source instanceof Enum) { + return source; + } + Object existing = copies.get(source); + if (existing != null) { + return existing; + } + if (source instanceof List) { + List values = (List) source; + List copy = copyListLike(values); + copies.put(source, copy); + for (Object value : values) { + copy.add(copyValue(value, copies)); + } + return copy; + } + if (source instanceof Map) { + Map values = (Map) source; + Map copy = copyMapLike(values); + copies.put(source, copy); + for (Map.Entry entry : values.entrySet()) { + copy.put(entry.getKey(), copyValue(entry.getValue(), copies)); + } + return copy; + } + if (source.getClass().isArray()) { + int length = Array.getLength(source); + Class componentType = source.getClass().getComponentType(); + Class copyComponentType = canRetainArrayComponentType( + source, componentType, new IdentityHashMap()) + ? componentType + : Object.class; + Object copy = Array.newInstance(copyComponentType, length); + copies.put(source, copy); + for (int index = 0; index < length; index++) { + Array.set(copy, index, copyValue(Array.get(source, index), copies)); + } + return copy; + } + return source; + } + + /** + * A container is copied to an owned standard implementation. That copy is not + * always assignable to a concrete array component such as a Jackson or JDK + * implementation class. Predict the copied element types before allocating the + * array so cycles point at the final array rather than an abandoned typed copy. + */ + private static boolean canRetainArrayComponentType( + Object source, + Class componentType, + IdentityHashMap visitingArrays) { + if (componentType.isPrimitive()) { + return true; + } + if (visitingArrays.put(source, Boolean.TRUE) != null) { + return true; + } + try { + int length = Array.getLength(source); + for (int index = 0; index < length; index++) { + Class copiedType = copiedValueType( + Array.get(source, index), visitingArrays); + if (copiedType != null && !componentType.isAssignableFrom(copiedType)) { + return false; + } + } + return true; + } finally { + visitingArrays.remove(source); + } + } + + private static Class copiedValueType( + Object source, + IdentityHashMap visitingArrays) { + if (source == null) { + return null; + } + if (source instanceof List) { + return source instanceof LinkedList ? LinkedList.class : ArrayList.class; + } + if (source instanceof Map) { + if (source instanceof TreeMap) { + return TreeMap.class; + } + if (source instanceof LinkedHashMap) { + return LinkedHashMap.class; + } + if (source instanceof HashMap) { + return HashMap.class; + } + return LinkedHashMap.class; + } + if (source.getClass().isArray()) { + Class componentType = source.getClass().getComponentType(); + return canRetainArrayComponentType(source, componentType, visitingArrays) + ? source.getClass() + : Object[].class; + } + return source.getClass(); + } + + private static List copyListLike(List source) { + if (source instanceof LinkedList) { + return new LinkedList<>(); + } + return new ArrayList<>(source.size()); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Map copyMapLike(Map source) { + if (source instanceof TreeMap) { + return new TreeMap(((TreeMap) source).comparator()); + } + if (source instanceof LinkedHashMap) { + return new LinkedHashMap<>(); + } + if (source instanceof HashMap) { + return new HashMap<>(); + } + return new LinkedHashMap<>(); + } + public Object get(String path) { return NodePathAccessor.get(this, path); } diff --git a/src/main/java/blue/language/model/NodeDeserializer.java b/src/main/java/blue/language/model/NodeDeserializer.java index 241a6ac..00dd25e 100644 --- a/src/main/java/blue/language/model/NodeDeserializer.java +++ b/src/main/java/blue/language/model/NodeDeserializer.java @@ -419,12 +419,36 @@ private boolean isNumericType(Node type) { } private BigInteger parseExplicitInteger(String value) { - if (value == null || !value.matches("-?(0|[1-9]\\d*)")) { + if (!isCanonicalDecimalInteger(value)) { throw new IllegalArgumentException("Explicit Integer scalar values must be canonical decimal strings."); } return new BigInteger(value); } + private boolean isCanonicalDecimalInteger(String value) { + if (value == null || value.isEmpty()) { + return false; + } + int index = value.charAt(0) == '-' ? 1 : 0; + if (index == value.length()) { + return false; + } + char firstDigit = value.charAt(index); + if (firstDigit == '0') { + return index + 1 == value.length(); + } + if (firstDigit < '1' || firstDigit > '9') { + return false; + } + for (index++; index < value.length(); index++) { + char digit = value.charAt(index); + if (digit < '0' || digit > '9') { + return false; + } + } + return true; + } + private boolean isIntegerType(Node type) { return isCoreType(type, INTEGER_TYPE_BLUE_ID, "Integer"); } diff --git a/src/main/java/blue/language/processor/BatchPatchRecord.java b/src/main/java/blue/language/processor/BatchPatchRecord.java index 0910064..160f567 100644 --- a/src/main/java/blue/language/processor/BatchPatchRecord.java +++ b/src/main/java/blue/language/processor/BatchPatchRecord.java @@ -13,17 +13,20 @@ final class BatchPatchRecord { private final ImmutablePatchPlanner.PatchPlan resolvedPlan; private final FrozenNode beforeAtPatchTime; private final FrozenNode afterAtPatchTime; + private final PatchImpact impact; private final boolean processorManagedConformanceBypass; BatchPatchRecord(ImmutableJsonPatch patch, ImmutablePatchPlanner.PatchPlan canonicalPlan, ImmutablePatchPlanner.PatchPlan resolvedPlan, + PatchImpact impact, boolean processorManagedConformanceBypass) { this.parsedPath = patch.path(); this.canonicalPlan = canonicalPlan; this.resolvedPlan = resolvedPlan; this.beforeAtPatchTime = resolvedPlan.before(); this.afterAtPatchTime = resolvedPlan.after(); + this.impact = impact; this.processorManagedConformanceBypass = processorManagedConformanceBypass; } @@ -63,6 +66,10 @@ FrozenNode afterAtPatchTime() { return afterAtPatchTime; } + PatchImpact impact() { + return impact; + } + boolean processorManagedConformanceBypass() { return processorManagedConformanceBypass; } diff --git a/src/main/java/blue/language/processor/BatchPatchTransaction.java b/src/main/java/blue/language/processor/BatchPatchTransaction.java index ad381a1..da1f75b 100644 --- a/src/main/java/blue/language/processor/BatchPatchTransaction.java +++ b/src/main/java/blue/language/processor/BatchPatchTransaction.java @@ -16,7 +16,7 @@ */ final class BatchPatchTransaction { - private final List patches; + private final List patches; private final PatchPlanningEngine planningEngine; private final boolean buildUpdates; @@ -55,6 +55,24 @@ final class BatchPatchTransaction { DocumentProcessingRuntime.UpdateMaterializationMetrics materializationMetrics, boolean buildUpdates, ProcessingMetricsSink metrics) { + this.patches = PatchInput.mutableList(patches); + this.planningEngine = new PatchPlanningEngine(originScopePath, + planning, + conformanceEngine, + conformancePlannerOverride, + materializationMetrics, + metrics); + this.buildUpdates = buildUpdates; + } + + private BatchPatchTransaction(List patches, + String originScopePath, + DocumentProcessingRuntime.PlanningContext planning, + ConformanceEngine conformanceEngine, + ConformancePlannerOverride conformancePlannerOverride, + DocumentProcessingRuntime.UpdateMaterializationMetrics materializationMetrics, + boolean buildUpdates, + ProcessingMetricsSink metrics) { this.patches = Collections.unmodifiableList(new ArrayList<>(patches)); this.planningEngine = new PatchPlanningEngine(originScopePath, planning, @@ -65,7 +83,25 @@ final class BatchPatchTransaction { this.buildUpdates = buildUpdates; } + static BatchPatchTransaction fromInputs(String originScopePath, + List patches, + DocumentProcessingRuntime.PlanningContext planning, + ConformanceEngine conformanceEngine, + ConformancePlannerOverride conformancePlannerOverride, + DocumentProcessingRuntime.UpdateMaterializationMetrics materializationMetrics, + boolean buildUpdates, + ProcessingMetricsSink metrics) { + return new BatchPatchTransaction(patches, + originScopePath, + planning, + conformanceEngine, + conformancePlannerOverride, + materializationMetrics, + buildUpdates, + metrics); + } + BatchPatchResult apply() { - return planningEngine.planAtomic(patches, buildUpdates); + return planningEngine.planAtomicInputs(patches, buildUpdates); } } diff --git a/src/main/java/blue/language/processor/ChannelRunner.java b/src/main/java/blue/language/processor/ChannelRunner.java index 9ce6125..154d160 100644 --- a/src/main/java/blue/language/processor/ChannelRunner.java +++ b/src/main/java/blue/language/processor/ChannelRunner.java @@ -335,9 +335,9 @@ boolean runHandlers(String scopePath, false); metrics.incrementHandlersExecuted(); long executionStart = System.nanoTime(); - try { - ProcessorEngine.executeHandler(owner, handler.contract(), context); - context.applyBufferedEffects(); + try (ProcessorExecutionContext ownedContext = context) { + ProcessorEngine.executeHandler(owner, handler.contract(), ownedContext); + ownedContext.applyBufferedEffects(); } catch (RunTerminationException ex) { throw ex; } catch (ProcessorFatalException ex) { diff --git a/src/main/java/blue/language/processor/ContractEffectBuffer.java b/src/main/java/blue/language/processor/ContractEffectBuffer.java index 89929f0..6e18945 100644 --- a/src/main/java/blue/language/processor/ContractEffectBuffer.java +++ b/src/main/java/blue/language/processor/ContractEffectBuffer.java @@ -1,22 +1,25 @@ package blue.language.processor; import blue.language.model.Node; +import blue.language.processor.model.FrozenJsonPatch; import blue.language.processor.model.JsonPatch; import java.util.ArrayList; import java.util.Collections; import java.util.List; -final class ContractEffectBuffer { +final class ContractEffectBuffer implements AutoCloseable { private long gas; private String invalidGasReason; - private final List patches = new ArrayList<>(); + private final List patches = new ArrayList<>(); private final List patchBatches = new ArrayList<>(); private final List emittedEvents = new ArrayList<>(); private TerminationRequest terminationRequest; + private boolean closed; void addGas(long units) { + ensureOpen(); if (units < 0) { invalidGasReason = "Gas amount must be non-negative"; return; @@ -39,27 +42,32 @@ void addPatch(JsonPatch patch) { } void addPatches(List input) { - addPatches(input, null); + addPatchInputs(PatchInput.mutableList(input), null); } void addPreviewedPatches(List input, WorkingDocument.Preview preview) { - addPatches(input, preview); + addPatchInputs(PatchInput.mutableList(input), preview); } - private void addPatches(List input, WorkingDocument.Preview preview) { + void addFrozenPatches(List input) { + addPatchInputs(PatchInput.frozenList(input), null); + } + + void addPreviewedFrozenPatches(List input, WorkingDocument.Preview preview) { + addPatchInputs(PatchInput.frozenList(input), preview); + } + + private void addPatchInputs(List input, WorkingDocument.Preview preview) { + ensureOpen(); if (input == null || input.isEmpty()) { return; } - List batch = new ArrayList<>(input.size()); - for (JsonPatch patch : input) { - JsonPatch copied = copyPatch(patch); - patches.add(copied); - batch.add(copied); - } + List batch = new ArrayList<>(input); + patches.addAll(batch); patchBatches.add(new PatchBatch(batch, preview)); } - List patches() { + List patches() { return Collections.unmodifiableList(patches); } @@ -68,6 +76,7 @@ List patchBatches() { } void emit(Node event) { + ensureOpen(); emittedEvents.add(event != null ? event.clone() : null); } @@ -76,6 +85,7 @@ List emittedEvents() { } void terminate(ScopeRuntimeContext.TerminationKind kind, String reason) { + ensureOpen(); if (terminationRequest == null) { terminationRequest = new TerminationRequest(kind, reason); } @@ -85,16 +95,42 @@ TerminationRequest terminationRequest() { return terminationRequest; } - private JsonPatch copyPatch(JsonPatch patch) { - switch (patch.getOp()) { - case ADD: - return JsonPatch.add(patch.getPath(), patch.getVal().clone()); - case REPLACE: - return JsonPatch.replace(patch.getPath(), patch.getVal().clone()); - case REMOVE: - return JsonPatch.remove(patch.getPath()); - default: - throw new IllegalStateException("Unsupported patch op: " + patch.getOp()); + /** Releases every preview whose ownership was transferred into this buffer. */ + @Override + public void close() { + if (closed) { + return; + } + closed = true; + Throwable failure = null; + for (PatchBatch patchBatch : patchBatches) { + try { + patchBatch.closePreview(); + } catch (RuntimeException | Error ex) { + if (failure == null) { + failure = ex; + } else if (failure != ex) { + failure.addSuppressed(ex); + } + } + } + patches.clear(); + patchBatches.clear(); + emittedEvents.clear(); + terminationRequest = null; + gas = 0L; + invalidGasReason = null; + if (failure instanceof RuntimeException) { + throw (RuntimeException) failure; + } + if (failure instanceof Error) { + throw (Error) failure; + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Contract effect buffer is closed"); } } @@ -117,20 +153,28 @@ String reason() { } static final class PatchBatch { - private final List patches; - private final WorkingDocument.Preview preview; + private final List patches; + private WorkingDocument.Preview preview; - private PatchBatch(List patches, WorkingDocument.Preview preview) { + private PatchBatch(List patches, WorkingDocument.Preview preview) { this.patches = Collections.unmodifiableList(new ArrayList<>(patches)); this.preview = preview; } - List patches() { + List patches() { return patches; } WorkingDocument.Preview preview() { return preview; } + + private void closePreview() { + WorkingDocument.Preview retained = preview; + preview = null; + if (retained != null) { + retained.close(); + } + } } } diff --git a/src/main/java/blue/language/processor/ContractLoader.java b/src/main/java/blue/language/processor/ContractLoader.java index ac12905..fd013f8 100644 --- a/src/main/java/blue/language/processor/ContractLoader.java +++ b/src/main/java/blue/language/processor/ContractLoader.java @@ -1,5 +1,6 @@ package blue.language.processor; +import blue.language.BlueCachePolicy; import blue.language.mapping.NodeToObjectConverter; import blue.language.model.Node; import blue.language.processor.model.ChannelContract; @@ -21,8 +22,6 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; /** * Parses contracts under a scope and produces a {@link ContractBundle}. @@ -44,14 +43,22 @@ final class ContractLoader { private final ContractProcessorRegistry registry; private final NodeToObjectConverter converter; private final TypeClassResolver typeResolver; - private final ConcurrentMap bundleCache = new ConcurrentHashMap<>(); + private final BundleCache bundleCache; ContractLoader(ContractProcessorRegistry registry, NodeToObjectConverter converter, TypeClassResolver typeResolver) { + this(registry, converter, typeResolver, BlueCachePolicy.boundedDefaults()); + } + + ContractLoader(ContractProcessorRegistry registry, + NodeToObjectConverter converter, + TypeClassResolver typeResolver, + BlueCachePolicy cachePolicy) { this.registry = Objects.requireNonNull(registry, "registry"); this.converter = Objects.requireNonNull(converter, "converter"); this.typeResolver = Objects.requireNonNull(typeResolver, "typeResolver"); + this.bundleCache = new BundleCache(Objects.requireNonNull(cachePolicy, "cachePolicy")); } ContractBundle load(ResolvedSnapshot snapshot, String scopePath) { @@ -133,6 +140,18 @@ ContractBundle load(Node selectedScopeNode, runtimeMarkers.checkpointDeclared); } + void clearCaches() { + bundleCache.clear(); + } + + int cacheSize() { + return bundleCache.size(); + } + + long cacheWeightBytes() { + return bundleCache.currentWeightBytes(); + } + private ContractBundle build(Node selectedScopeNode, FrozenNode effectiveScopeNode, String scopePath) { @@ -463,6 +482,109 @@ private String typeBlueId(FrozenNode node) { return type.getReferenceBlueId() != null ? type.getReferenceBlueId() : type.blueId(); } + private static final class BundleCache { + private final int maximumEntries; + private final long maximumWeightBytes; + private final long maximumEntryWeightBytes; + private final LinkedHashMap entries = + new LinkedHashMap(16, 0.75f, true); + private long currentWeightBytes; + + private BundleCache(BlueCachePolicy policy) { + this.maximumEntries = policy.conformancePlanMaxEntries(); + this.maximumWeightBytes = policy.conformancePlanMaxWeightBytes(); + this.maximumEntryWeightBytes = Math.min( + policy.maximumDerivedEntryWeightBytes(), maximumWeightBytes); + } + + private synchronized ContractBundle get(BundleCacheKey key) { + BundleCacheEntry entry = entries.get(key); + return entry != null ? entry.bundle : null; + } + + private synchronized void putIfAbsent(BundleCacheKey key, ContractBundle bundle) { + if (entries.containsKey(key)) { + entries.get(key); + return; + } + long weight = estimateWeight(key, bundle); + if (weight > maximumEntryWeightBytes || weight > maximumWeightBytes) { + return; + } + entries.put(key, new BundleCacheEntry(bundle, weight)); + currentWeightBytes = saturatedAdd(currentWeightBytes, weight); + evictToBounds(); + } + + private synchronized void clear() { + entries.clear(); + currentWeightBytes = 0L; + } + + private synchronized int size() { + return entries.size(); + } + + private synchronized long currentWeightBytes() { + return currentWeightBytes; + } + + private void evictToBounds() { + Iterator> iterator = + entries.entrySet().iterator(); + while ((entries.size() > maximumEntries + || currentWeightBytes > maximumWeightBytes) && iterator.hasNext()) { + BundleCacheEntry eldest = iterator.next().getValue(); + currentWeightBytes -= eldest.weightBytes; + iterator.remove(); + } + } + + private long estimateWeight(BundleCacheKey key, ContractBundle bundle) { + long weight = 256L; + weight = saturatedAdd(weight, retainedString(key.scopePath)); + weight = saturatedAdd(weight, retainedString(key.selectedContractKeysSignature)); + weight = saturatedAdd(weight, retainedString(key.contractsSignature)); + weight = saturatedAdd(weight, retainedString(key.channelBindingsSignature)); + weight = saturatedAdd(weight, 192L * bundle.channels().size()); + weight = saturatedAdd(weight, 160L * bundle.markers().size()); + weight = saturatedAdd(weight, 64L * bundle.embeddedPaths().size()); + for (String path : bundle.embeddedPaths()) { + weight = saturatedAdd(weight, retainedString(path)); + } + for (Map.Entry entry : bundle.contractNodes().entrySet()) { + weight = saturatedAdd(weight, 96L + retainedString(entry.getKey())); + weight = saturatedAdd(weight, entry.getValue().approximateRetainedWeightBytes()); + } + for (String channelKey : bundle.channels().keySet()) { + weight = saturatedAdd(weight, retainedString(channelKey)); + weight = saturatedAdd(weight, 160L * bundle.handlersFor(channelKey).size()); + } + for (String markerKey : bundle.markers().keySet()) { + weight = saturatedAdd(weight, retainedString(markerKey)); + } + return weight; + } + + private long retainedString(String value) { + return value != null ? 48L + 2L * value.length() : 0L; + } + + private long saturatedAdd(long left, long right) { + return Long.MAX_VALUE - left < right ? Long.MAX_VALUE : left + right; + } + } + + private static final class BundleCacheEntry { + private final ContractBundle bundle; + private final long weightBytes; + + private BundleCacheEntry(ContractBundle bundle, long weightBytes) { + this.bundle = Objects.requireNonNull(bundle, "bundle"); + this.weightBytes = weightBytes; + } + } + private static final class RuntimeMarkers { final Map markers; final Map nodes; diff --git a/src/main/java/blue/language/processor/ContractMatchingService.java b/src/main/java/blue/language/processor/ContractMatchingService.java index 0e4a15f..161697c 100644 --- a/src/main/java/blue/language/processor/ContractMatchingService.java +++ b/src/main/java/blue/language/processor/ContractMatchingService.java @@ -1,6 +1,7 @@ package blue.language.processor; import blue.language.Blue; +import blue.language.BlueCachePolicy; import blue.language.model.Node; import blue.language.snapshot.FrozenNode; import blue.language.utils.FrozenTypeMatcher; @@ -11,6 +12,7 @@ public final class ContractMatchingService { private final Blue blue; + private final BlueCachePolicy cachePolicy; private final FrozenTypeMatcher matcher; private final DeclaredTypeLineageMatcher declaredTypeLineageMatcher; @@ -20,9 +22,13 @@ public ContractMatchingService() { public ContractMatchingService(Blue blue) { this.blue = blue; + this.cachePolicy = blue != null + ? blue.cachePolicy() + : BlueCachePolicy.boundedDefaults(); this.matcher = new FrozenTypeMatcher(blue); this.declaredTypeLineageMatcher = new DeclaredTypeLineageMatcher( - blue != null ? blue.getNodeProvider() : null); + blue != null ? blue.getNodeProvider() : null, + cachePolicy); } Blue blue() { @@ -37,6 +43,32 @@ int declaredTypeLineageCacheSize() { return declaredTypeLineageMatcher.cacheSize(); } + BlueCachePolicy cachePolicy() { + return cachePolicy; + } + + int matcherCacheSize() { + return matcher.cacheEntryCount(); + } + + int cacheEntryCount() { + return matcher.cacheEntryCount() + declaredTypeLineageMatcher.cacheSize(); + } + + long cacheWeightBytes() { + long matcherWeight = matcher.cacheWeightBytes(); + long lineageWeight = declaredTypeLineageMatcher.cacheWeightBytes(); + return Long.MAX_VALUE - matcherWeight < lineageWeight + ? Long.MAX_VALUE + : matcherWeight + lineageWeight; + } + + /** Releases matching, reference-resolution, and declared-lineage caches. */ + public void clearCaches() { + matcher.clearCaches(); + declaredTypeLineageMatcher.clearCaches(); + } + public boolean matches(FrozenNode event, FrozenNode pattern) { if (pattern == null) { return true; diff --git a/src/main/java/blue/language/processor/ContractProcessorRegistry.java b/src/main/java/blue/language/processor/ContractProcessorRegistry.java index e44bb5d..bbe5fdc 100644 --- a/src/main/java/blue/language/processor/ContractProcessorRegistry.java +++ b/src/main/java/blue/language/processor/ContractProcessorRegistry.java @@ -6,11 +6,17 @@ import blue.language.processor.model.HandlerContract; import blue.language.processor.model.MarkerContract; +import java.util.AbstractMap; +import java.util.AbstractSet; import java.util.Collections; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; /** * Maintains the mapping between contract BlueIds and their processors. @@ -24,63 +30,167 @@ public class ContractProcessorRegistry { private final Map> handlerProcessorsByBlueId = new LinkedHashMap<>(); private final Map> channelProcessorsByBlueId = new LinkedHashMap<>(); private final Map> markerProcessorsByBlueId = new LinkedHashMap<>(); + private final Map> processorsView = + Collections.unmodifiableMap( + new AbstractMap>() { + private final Set>> entries = + new AbstractSet>>() { + @Override + public Iterator>> iterator() { + synchronized (ContractProcessorRegistry.this) { + return Collections.unmodifiableMap( + new LinkedHashMap<>(processorsByBlueId)) + .entrySet() + .iterator(); + } + } + + @Override + public int size() { + synchronized (ContractProcessorRegistry.this) { + return processorsByBlueId.size(); + } + } + + @Override + public boolean contains(Object entry) { + synchronized (ContractProcessorRegistry.this) { + return processorsByBlueId.entrySet().contains(entry); + } + } + }; + + @Override + public ContractProcessor get(Object key) { + synchronized (ContractProcessorRegistry.this) { + return processorsByBlueId.get(key); + } + } + + @Override + public boolean containsKey(Object key) { + synchronized (ContractProcessorRegistry.this) { + return processorsByBlueId.containsKey(key); + } + } + + @Override + public int size() { + synchronized (ContractProcessorRegistry.this) { + return processorsByBlueId.size(); + } + } + + @Override + public Set>> entrySet() { + return entries; + } + }); + private final ReentrantReadWriteLock configurationLock = new ReentrantReadWriteLock(); private long version; + Lock configurationReadLock() { + return configurationLock.readLock(); + } + + Lock configurationWriteLock() { + return configurationLock.writeLock(); + } + + boolean isConfigurationReadHeldByCurrentThread() { + return configurationLock.getReadHoldCount() > 0; + } + public void registerHandler(HandlerProcessor processor) { - Objects.requireNonNull(processor, "processor"); - registerBlueIds(processor.contractType(), processor); - handlerProcessors.put(processor.contractType(), processor); + mutateConfiguration(() -> registerHandlerInternal(processor)); } public void registerChannel(ChannelProcessor processor) { - Objects.requireNonNull(processor, "processor"); - registerBlueIds(processor.contractType(), processor); - channelProcessors.put(processor.contractType(), processor); + mutateConfiguration(() -> registerChannelInternal(processor)); } public void registerMarker(ContractProcessor processor) { - Objects.requireNonNull(processor, "processor"); - registerBlueIds(processor.contractType(), processor); - markerProcessors.put(processor.contractType(), processor); + mutateConfiguration(() -> registerMarkerInternal(processor)); } public void register(ContractProcessor processor) { + mutateConfiguration(() -> registerInternal(processor)); + } + + public void register(String blueId, ContractProcessor processor) { + mutateConfiguration(() -> { + Objects.requireNonNull(processor, "processor"); + if (blueId == null || blueId.isEmpty()) { + throw new IllegalArgumentException("blueId must not be empty"); + } + registerBlueId(blueId, processor); + registerClassLookup(processor); + }); + } + + private void registerInternal(ContractProcessor processor) { Objects.requireNonNull(processor, "processor"); if (processor instanceof HandlerProcessor) { @SuppressWarnings("unchecked") HandlerProcessor handler = (HandlerProcessor) processor; - registerHandler(handler); + registerHandlerInternal(handler); } else if (processor instanceof ChannelProcessor) { @SuppressWarnings("unchecked") ChannelProcessor channel = (ChannelProcessor) processor; - registerChannel(channel); + registerChannelInternal(channel); } else if (processor.contractType() != null && MarkerContract.class.isAssignableFrom(processor.contractType())) { @SuppressWarnings("unchecked") ContractProcessor marker = (ContractProcessor) processor; - registerMarker(marker); + registerMarkerInternal(marker); } else { throw new IllegalArgumentException("Unsupported processor type: " + processor.getClass().getName()); } } - public void register(String blueId, ContractProcessor processor) { + private void registerHandlerInternal(HandlerProcessor processor) { + Objects.requireNonNull(processor, "processor"); + registerBlueIds(processor.contractType(), processor); + handlerProcessors.put(processor.contractType(), processor); + } + + private void registerChannelInternal(ChannelProcessor processor) { Objects.requireNonNull(processor, "processor"); - if (blueId == null || blueId.isEmpty()) { - throw new IllegalArgumentException("blueId must not be empty"); + registerBlueIds(processor.contractType(), processor); + channelProcessors.put(processor.contractType(), processor); + } + + private void registerMarkerInternal(ContractProcessor processor) { + Objects.requireNonNull(processor, "processor"); + registerBlueIds(processor.contractType(), processor); + markerProcessors.put(processor.contractType(), processor); + } + + private void mutateConfiguration(Runnable mutation) { + if (configurationLock.getReadHoldCount() > 0 + && !configurationLock.isWriteLockedByCurrentThread()) { + throw new IllegalStateException( + "Contract processor configuration cannot change during active processing"); + } + Lock write = configurationWriteLock(); + write.lock(); + try { + synchronized (this) { + mutation.run(); + } + } finally { + write.unlock(); } - registerBlueId(blueId, processor); - registerClassLookup(processor); } - public Optional> lookupHandler(Class type) { + public synchronized Optional> lookupHandler(Class type) { return Optional.ofNullable(handlerProcessors.get(type)); } - public Optional> lookupHandler(String blueId) { + public synchronized Optional> lookupHandler(String blueId) { return Optional.ofNullable(handlerProcessorsByBlueId.get(blueId)); } - public Optional> lookupHandler(HandlerContract contract) { + public synchronized Optional> lookupHandler(HandlerContract contract) { if (contract == null) { return Optional.empty(); } @@ -90,15 +200,15 @@ public Optional> lookupHandler(Handl : lookupHandler(contract.getClass().asSubclass(HandlerContract.class)); } - public Optional> lookupChannel(Class type) { + public synchronized Optional> lookupChannel(Class type) { return Optional.ofNullable(channelProcessors.get(type)); } - public Optional> lookupChannel(String blueId) { + public synchronized Optional> lookupChannel(String blueId) { return Optional.ofNullable(channelProcessorsByBlueId.get(blueId)); } - public Optional> lookupChannel(ChannelContract contract) { + public synchronized Optional> lookupChannel(ChannelContract contract) { if (contract == null) { return Optional.empty(); } @@ -108,15 +218,15 @@ public Optional> lookupChannel(Chann : lookupChannel(contract.getClass().asSubclass(ChannelContract.class)); } - public Optional> lookupMarker(Class type) { + public synchronized Optional> lookupMarker(Class type) { return Optional.ofNullable(markerProcessors.get(type)); } - public Optional> lookupMarker(String blueId) { + public synchronized Optional> lookupMarker(String blueId) { return Optional.ofNullable(markerProcessorsByBlueId.get(blueId)); } - public Optional> lookupMarker(MarkerContract contract) { + public synchronized Optional> lookupMarker(MarkerContract contract) { if (contract == null) { return Optional.empty(); } @@ -126,11 +236,11 @@ public Optional> lookupMarker(Marker : lookupMarker(contract.getClass().asSubclass(MarkerContract.class)); } - public Map> processors() { - return Collections.unmodifiableMap(processorsByBlueId); + public synchronized Map> processors() { + return processorsView; } - long version() { + synchronized long version() { return version; } diff --git a/src/main/java/blue/language/processor/DeclaredTypeLineageMatcher.java b/src/main/java/blue/language/processor/DeclaredTypeLineageMatcher.java index c9db579..59e35d3 100644 --- a/src/main/java/blue/language/processor/DeclaredTypeLineageMatcher.java +++ b/src/main/java/blue/language/processor/DeclaredTypeLineageMatcher.java @@ -1,13 +1,16 @@ package blue.language.processor; +import blue.language.BlueCachePolicy; import blue.language.NodeProvider; import blue.language.model.Node; import blue.language.utils.BlueIds; import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import static blue.language.utils.Properties.CORE_TYPE_BLUE_IDS; @@ -20,16 +23,24 @@ final class DeclaredTypeLineageMatcher { static final int CACHE_ENTRY_LIMIT = 4_096; private final NodeProvider provider; + private final int maximumEntries; + private final long maximumWeightBytes; + private final long maximumEntryWeightBytes; private final Map directParentByType = - new LinkedHashMap(CACHE_INITIAL_CAPACITY, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > CACHE_ENTRY_LIMIT; - } - }; + new LinkedHashMap(CACHE_INITIAL_CAPACITY, 0.75f, true); + private long currentWeightBytes; DeclaredTypeLineageMatcher(NodeProvider provider) { + this(provider, BlueCachePolicy.boundedDefaults()); + } + + DeclaredTypeLineageMatcher(NodeProvider provider, BlueCachePolicy cachePolicy) { this.provider = provider; + BlueCachePolicy policy = Objects.requireNonNull(cachePolicy, "cachePolicy"); + this.maximumEntries = policy.conformancePlanMaxEntries(); + this.maximumWeightBytes = policy.conformancePlanMaxWeightBytes(); + this.maximumEntryWeightBytes = Math.min( + policy.maximumDerivedEntryWeightBytes(), maximumWeightBytes); } boolean isSameOrDescendant(Node candidateType, Node expectedType) { @@ -57,6 +68,19 @@ int cacheSize() { } } + long cacheWeightBytes() { + synchronized (directParentByType) { + return currentWeightBytes; + } + } + + void clearCaches() { + synchronized (directParentByType) { + directParentByType.clear(); + currentWeightBytes = 0L; + } + } + private boolean hasExpectedInCompleteAncestry(String candidateId, String expectedId) { if (provider == null) { return false; @@ -126,11 +150,37 @@ private DirectParentFact cacheFact(String declaredTypeId, DirectParentFact fact) if (existing != null) { return existing; } + long weight = factWeight(declaredTypeId, fact); + if (weight > maximumEntryWeightBytes || weight > maximumWeightBytes) { + return fact; + } directParentByType.put(declaredTypeId, fact); + currentWeightBytes += weight; + evictToBounds(); return fact; } } + private void evictToBounds() { + Iterator> iterator = + directParentByType.entrySet().iterator(); + while ((directParentByType.size() > maximumEntries + || currentWeightBytes > maximumWeightBytes) && iterator.hasNext()) { + Map.Entry eldest = iterator.next(); + currentWeightBytes -= factWeight(eldest.getKey(), eldest.getValue()); + iterator.remove(); + } + } + + private long factWeight(String declaredTypeId, DirectParentFact fact) { + long weight = 112L + retainedString(declaredTypeId); + return weight + retainedString(fact.directParentId); + } + + private long retainedString(String value) { + return value != null ? 48L + 2L * value.length() : 0L; + } + private static IllegalStateException typeCycle(String repeatedId, int uniqueTypeCount) { return new IllegalStateException("Type cycle in declared type ancestry: " + repeatedId + " was revisited after " + uniqueTypeCount diff --git a/src/main/java/blue/language/processor/DocumentProcessingRuntime.java b/src/main/java/blue/language/processor/DocumentProcessingRuntime.java index 69c9d07..7adc9a8 100644 --- a/src/main/java/blue/language/processor/DocumentProcessingRuntime.java +++ b/src/main/java/blue/language/processor/DocumentProcessingRuntime.java @@ -2,6 +2,7 @@ import blue.language.conformance.ConformanceEngine; import blue.language.model.Node; +import blue.language.processor.model.FrozenJsonPatch; import blue.language.processor.model.JsonPatch; import blue.language.processor.util.PointerUtils; import blue.language.processor.util.ProcessorPointerConstants; @@ -213,6 +214,14 @@ public void chargePatchAddOrReplace(Node value) { gasMeter.chargePatchAddOrReplace(value); } + public void chargeFrozenPatchAddOrReplace(FrozenNode value) { + gasMeter.chargeFrozenPatchAddOrReplace(value); + } + + public void chargeFrozenPatchAddOrReplace(long authoredCanonicalSizeBytes) { + gasMeter.chargeFrozenPatchAddOrReplace(authoredCanonicalSizeBytes); + } + public void chargePatchRemove() { gasMeter.chargePatchRemove(); } @@ -537,6 +546,29 @@ public List applyPatches(String originScopePath, List updates = applyFrozenPatches( + originScopePath, Collections.singletonList(patch)); + return updates.isEmpty() ? null : updates.get(0); + } + + /** Applies frozen patches as one rollback-all atomic transaction. */ + public List applyFrozenPatches(String originScopePath, + List patches) { + if (patches == null || patches.isEmpty()) { + return Collections.emptyList(); + } + return applyPatchInputs(originScopePath, PatchInput.frozenList(patches)); + } + + private List applyPatchInputs(String originScopePath, + List patches) { Node selectedRollback = selectedDocumentBacked ? materializedView.copyRoot() : null; ResolvedSnapshot snapshotRollback = snapshot; batchPatchCalls++; @@ -547,7 +579,7 @@ public List applyPatches(String originScopePath, List applyPrecomputedPatch(String originScopePath, PreparedPatchSequence preparePatchSequence(String originScopePath, List patches, WorkingDocument.Preview preview) { + return new PreparedPatchSequence(originScopePath, PatchInput.mutableList(patches), preview); + } + + PreparedPatchSequence prepareFrozenPatchSequence(String originScopePath, + List patches, + WorkingDocument.Preview preview) { + return new PreparedPatchSequence(originScopePath, PatchInput.frozenList(patches), preview); + } + + PreparedPatchSequence preparePatchInputSequence(String originScopePath, + List patches, + WorkingDocument.Preview preview) { return new PreparedPatchSequence(originScopePath, patches, preview); } @@ -966,7 +1010,7 @@ final class PreparedPatchSequence implements AutoCloseable { private final String originScope; private final int patchCount; private final WorkingDocument.Preview preview; - private final List patches; + private final List patches; private ProcessingSnapshotManager sequenceSnapshotManager; private ProcessingSnapshotManager previousActiveSequenceSnapshotManager; private boolean sequenceSnapshotManagerActivated; @@ -979,15 +1023,12 @@ final class PreparedPatchSequence implements AutoCloseable { private boolean counted; private PreparedPatchSequence(String originScope, - List requestedPatches, + List requestedPatches, WorkingDocument.Preview preview) { this.originScope = PointerUtils.normalizeScope(originScope); this.preview = preview; - List checkedPatches = Objects.requireNonNull(requestedPatches, "patches"); - this.patches = new ArrayList<>(checkedPatches.size()); - for (JsonPatch patch : checkedPatches) { - this.patches.add(ImmutableJsonPatch.copy(patch)); - } + List checkedPatches = Objects.requireNonNull(requestedPatches, "patches"); + this.patches = new ArrayList<>(checkedPatches); this.patchCount = this.patches.size(); } @@ -996,6 +1037,10 @@ int size() { } JsonPatch patchForValidation(int patchIndex) { + return patchAt(patchIndex).legacyPatch(); + } + + PatchInput patchInputForValidation(int patchIndex) { return patchAt(patchIndex); } @@ -1003,7 +1048,7 @@ List applyNext(int patchIndex) { if (closed) { throw new IllegalStateException("Patch sequence is already closed"); } - JsonPatch authoredPatch = patchAt(patchIndex); + PatchInput authoredPatch = patchAt(patchIndex); if (!counted) { patchSequencesPrepared++; batchPatchCalls++; @@ -1109,11 +1154,11 @@ List applyNext(int patchIndex) { } } - private JsonPatch patchAt(int patchIndex) { + private PatchInput patchAt(int patchIndex) { if (patchIndex < 0 || patchIndex >= patchCount) { throw new IndexOutOfBoundsException("Patch index outside prepared sequence: " + patchIndex); } - JsonPatch patch = patches.get(patchIndex); + PatchInput patch = patches.get(patchIndex); if (patch == null) { throw new IllegalStateException("Patch was already consumed: " + patchIndex); } @@ -1184,7 +1229,11 @@ private void refreshInvalidSequenceSnapshotManager() { || sequenceSnapshotManager.isTransientStateCurrent()) { return; } + ProcessingSnapshotManager invalid = sequenceSnapshotManager; deactivateSequenceSnapshotManager(); + closePlanningSession(); + sequenceSnapshotManager = null; + invalid.releaseTransientState(); sequenceSnapshotManager = snapshotManager != null ? snapshotManager.transientSequence() : null; @@ -1249,16 +1298,45 @@ public void close() { promoteCurrentSequenceSnapshot(manager); } } - } catch (RuntimeException ex) { + } catch (RuntimeException | Error ex) { + ProcessingSnapshotManager failedManager = sequenceSnapshotManager; deactivateSequenceSnapshotManager(); + sequenceSnapshotManager = null; + try { + closePlanningSession(); + } catch (RuntimeException | Error cleanupFailure) { + if (ex != cleanupFailure) { + ex.addSuppressed(cleanupFailure); + } + } + if (failedManager != null) { + try { + failedManager.releaseTransientState(); + } catch (RuntimeException | Error cleanupFailure) { + if (ex != cleanupFailure) { + ex.addSuppressed(cleanupFailure); + } + } + } throw ex; } + ProcessingSnapshotManager managerToRelease = sequenceSnapshotManager; deactivateSequenceSnapshotManager(); - planningSession = null; + closePlanningSession(); sequenceSnapshotManager = null; observedCanonical = null; observedResolved = null; closed = true; + if (managerToRelease != null) { + managerToRelease.releaseTransientState(); + } + } + + private void closePlanningSession() { + if (planningSession != null) { + planningSession.close(); + planningSession = null; + } } } diff --git a/src/main/java/blue/language/processor/DocumentProcessor.java b/src/main/java/blue/language/processor/DocumentProcessor.java index c6e3c5b..ffc9b50 100644 --- a/src/main/java/blue/language/processor/DocumentProcessor.java +++ b/src/main/java/blue/language/processor/DocumentProcessor.java @@ -11,21 +11,29 @@ import blue.language.utils.TypeClassResolver; import java.util.Map; import java.util.Objects; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantReadWriteLock; /** * Facade over the processor engine; retains public API for Document processing. */ -public class DocumentProcessor { +public class DocumentProcessor implements AutoCloseable { private final ContractProcessorRegistry contractRegistry; private final TypeClassResolver contractTypeResolver; private final NodeToObjectConverter contractConverter; private final ContractLoader contractLoader; - private final ConformanceEngine conformanceEngine; - private final ConformancePlannerOverride conformancePlannerOverride; - private final ProcessingSnapshotManager snapshotManager; - private final ContractMatchingService matchingService; - private ProcessingMetricsSink metricsSink; + private ConformanceEngine conformanceEngine; + private ConformancePlannerOverride conformancePlannerOverride; + private ProcessingSnapshotManager snapshotManager; + private ContractMatchingService matchingService; + private volatile ProcessingMetricsSink metricsSink; + private final ReentrantReadWriteLock lifecycleLock = new ReentrantReadWriteLock(); + private final Lock lifecycleRead = lifecycleLock.readLock(); + private final Lock lifecycleWrite = lifecycleLock.writeLock(); + private volatile boolean closed; + private volatile boolean cachesCleared; + private volatile boolean clearRequested; public DocumentProcessor() { this(ContractProcessorRegistryBuilder.create().registerDefaults().build()); @@ -93,11 +101,15 @@ public DocumentProcessor(ContractProcessorRegistry registry, this.contractRegistry = Objects.requireNonNull(registry, "registry"); this.contractTypeResolver = Objects.requireNonNull(contractTypeResolver, "contractTypeResolver"); this.contractConverter = new NodeToObjectConverter(this.contractTypeResolver); - this.contractLoader = new ContractLoader(contractRegistry, contractConverter, this.contractTypeResolver); + this.matchingService = Objects.requireNonNull(matchingService, "matchingService"); + this.contractLoader = new ContractLoader( + contractRegistry, + contractConverter, + this.contractTypeResolver, + this.matchingService.cachePolicy()); this.conformanceEngine = conformanceEngine; this.conformancePlannerOverride = conformancePlannerOverride; this.snapshotManager = snapshotManager; - this.matchingService = Objects.requireNonNull(matchingService, "matchingService"); this.metricsSink = metricsSink != null ? metricsSink : ProcessingMetricsSink.NOOP; } @@ -112,7 +124,15 @@ private DocumentProcessor(Builder builder) { } public DocumentProcessingResult initializeDocument(Node document) { - return ProcessorEngine.initializeDocument(this, document); + Lock configurationRead = contractRegistry.configurationReadLock(); + configurationRead.lock(); + lifecycleRead.lock(); + try { + ensureOpen(); + return ProcessorEngine.initializeDocument(this, document); + } finally { + releaseLifecycleReadAndConfiguration(configurationRead); + } } /** @@ -123,12 +143,28 @@ public DocumentProcessingResult initializeDocument(Node document) { * @return the initialization result and its authoritative snapshot */ public DocumentProcessingResult initializeDocument(ResolvedSnapshot snapshot) { - requireSnapshotManager(); - return ProcessorEngine.initializeDocument(this, snapshot); + Lock configurationRead = contractRegistry.configurationReadLock(); + configurationRead.lock(); + lifecycleRead.lock(); + try { + ensureOpen(); + requireSnapshotManager(); + return ProcessorEngine.initializeDocument(this, snapshot); + } finally { + releaseLifecycleReadAndConfiguration(configurationRead); + } } public DocumentProcessingResult processDocument(Node document, Node event) { - return ProcessorEngine.processDocument(this, document, event); + Lock configurationRead = contractRegistry.configurationReadLock(); + configurationRead.lock(); + lifecycleRead.lock(); + try { + ensureOpen(); + return ProcessorEngine.processDocument(this, document, event); + } finally { + releaseLifecycleReadAndConfiguration(configurationRead); + } } /** @@ -140,30 +176,76 @@ public DocumentProcessingResult processDocument(Node document, Node event) { * @return the processing result and its authoritative snapshot */ public DocumentProcessingResult processDocument(ResolvedSnapshot snapshot, Node event) { - requireSnapshotManager(); - return ProcessorEngine.processDocument(this, snapshot, event); + Lock configurationRead = contractRegistry.configurationReadLock(); + configurationRead.lock(); + lifecycleRead.lock(); + try { + ensureOpen(); + requireSnapshotManager(); + return ProcessorEngine.processDocument(this, snapshot, event); + } finally { + releaseLifecycleReadAndConfiguration(configurationRead); + } } public boolean isInitialized(Node document) { - return ProcessorEngine.isInitialized(this, document); + Lock configurationRead = contractRegistry.configurationReadLock(); + configurationRead.lock(); + lifecycleRead.lock(); + try { + ensureOpen(); + return ProcessorEngine.isInitialized(this, document); + } finally { + releaseLifecycleReadAndConfiguration(configurationRead); + } } public boolean isInitialized(ResolvedSnapshot snapshot) { - return ProcessorEngine.isInitialized(this, snapshot); + Lock configurationRead = contractRegistry.configurationReadLock(); + configurationRead.lock(); + lifecycleRead.lock(); + try { + ensureOpen(); + return ProcessorEngine.isInitialized(this, snapshot); + } finally { + releaseLifecycleReadAndConfiguration(configurationRead); + } } public DocumentProcessor registerContractProcessor(ContractProcessor processor) { - Objects.requireNonNull(processor, "processor"); - contractRegistry.register(processor); - registerAnnotatedContractType(processor.contractType()); - return this; + rejectWriteUpgrade(); + Lock configurationWrite = contractRegistry.configurationWriteLock(); + configurationWrite.lock(); + lifecycleWrite.lock(); + try { + ensureOpen(); + Objects.requireNonNull(processor, "processor"); + contractRegistry.register(processor); + registerAnnotatedContractType(processor.contractType()); + clearCachesInternal(); + return this; + } finally { + lifecycleWrite.unlock(); + configurationWrite.unlock(); + } } public DocumentProcessor registerContractProcessor(String blueId, ContractProcessor processor) { - Objects.requireNonNull(processor, "processor"); - contractRegistry.register(blueId, processor); - contractTypeResolver.register(blueId, processor.contractType()); - return this; + rejectWriteUpgrade(); + Lock configurationWrite = contractRegistry.configurationWriteLock(); + configurationWrite.lock(); + lifecycleWrite.lock(); + try { + ensureOpen(); + Objects.requireNonNull(processor, "processor"); + contractRegistry.register(blueId, processor); + contractTypeResolver.register(blueId, processor.contractType()); + clearCachesInternal(); + return this; + } finally { + lifecycleWrite.unlock(); + configurationWrite.unlock(); + } } public ContractProcessorRegistry getContractRegistry() { @@ -215,13 +297,151 @@ public boolean supportsSnapshotProcessing() { } public DocumentProcessor processingMetricsSink(ProcessingMetricsSink metricsSink) { - this.metricsSink = metricsSink != null ? metricsSink : ProcessingMetricsSink.NOOP; - return this; + rejectWriteUpgrade(); + lifecycleWrite.lock(); + try { + ensureOpen(); + this.metricsSink = metricsSink != null ? metricsSink : ProcessingMetricsSink.NOOP; + return this; + } finally { + lifecycleWrite.unlock(); + } + } + + /** Releases every reloadable contract-plan and matching cache owned by this processor. */ + public void clearCaches() { + if (lifecycleLock.getReadHoldCount() > 0) { + clearRequested = true; + return; + } + lifecycleWrite.lock(); + try { + clearCachesInternal(); + clearRequested = false; + } finally { + lifecycleWrite.unlock(); + } + } + + /** Returns the number of reloadable processor-plan cache entries. */ + public int cacheEntryCount() { + int loaderEntries = contractLoader.cacheSize(); + ContractMatchingService currentMatchingService = matchingService; + int matchingEntries = currentMatchingService != null + ? currentMatchingService.cacheEntryCount() : 0; + return Integer.MAX_VALUE - loaderEntries < matchingEntries + ? Integer.MAX_VALUE + : loaderEntries + matchingEntries; + } + + /** Returns the approximate retained weight of reloadable processor-plan caches. */ + public long cacheWeightBytes() { + long loaderWeight = contractLoader.cacheWeightBytes(); + ContractMatchingService currentMatchingService = matchingService; + long matchingWeight = currentMatchingService != null + ? currentMatchingService.cacheWeightBytes() : 0L; + return Long.MAX_VALUE - loaderWeight < matchingWeight + ? Long.MAX_VALUE + : loaderWeight + matchingWeight; } public Map markersFor(Node scopeNode, String scopePath) { - ContractBundle bundle = contractLoader.load(FrozenNode.fromResolvedNode(scopeNode), scopePath); - return bundle.markers(); + Lock configurationRead = contractRegistry.configurationReadLock(); + configurationRead.lock(); + lifecycleRead.lock(); + try { + ensureOpen(); + ContractBundle bundle = contractLoader.load( + FrozenNode.fromResolvedNode(scopeNode), scopePath); + return bundle.markers(); + } finally { + releaseLifecycleReadAndConfiguration(configurationRead); + } + } + + /** Returns whether this processor has released its reloadable caches. */ + public boolean isClosed() { + return closed; + } + + /** Invalidates processor work and releases every reloadable plan/matching cache. */ + @Override + public void close() { + closed = true; + if (lifecycleLock.getReadHoldCount() > 0) { + clearRequested = true; + return; + } + lifecycleWrite.lock(); + try { + clearCachesIfNeeded(); + } finally { + lifecycleWrite.unlock(); + } + } + + private void clearCachesInternal() { + contractLoader.clearCaches(); + ContractMatchingService currentMatchingService = matchingService; + if (currentMatchingService != null) { + currentMatchingService.clearCaches(); + } + } + + private void releaseLifecycleRead() { + lifecycleRead.unlock(); + if ((closed || clearRequested) && lifecycleLock.getReadHoldCount() == 0) { + lifecycleWrite.lock(); + try { + clearCachesIfNeeded(); + } finally { + lifecycleWrite.unlock(); + } + } + } + + private void releaseLifecycleReadAndConfiguration(Lock configurationRead) { + try { + releaseLifecycleRead(); + } finally { + configurationRead.unlock(); + } + } + + private void clearCachesIfNeeded() { + if (closed) { + if (!cachesCleared) { + clearCachesInternal(); + cachesCleared = true; + } + detachRuntimeCollaborators(); + clearRequested = false; + } else if (clearRequested) { + clearCachesInternal(); + clearRequested = false; + } + } + + private void rejectWriteUpgrade() { + if (lifecycleLock.getReadHoldCount() > 0 + || contractRegistry.isConfigurationReadHeldByCurrentThread()) { + throw new IllegalStateException( + "Document processor configuration cannot change during active processing"); + } + } + + private void detachRuntimeCollaborators() { + conformanceEngine = null; + conformancePlannerOverride = null; + snapshotManager = null; + matchingService = null; + metricsSink = ProcessingMetricsSink.NOOP; + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Document processor is closed"); + } } private void requireSnapshotManager() { diff --git a/src/main/java/blue/language/processor/GasMeter.java b/src/main/java/blue/language/processor/GasMeter.java index a58bb9e..af9f655 100644 --- a/src/main/java/blue/language/processor/GasMeter.java +++ b/src/main/java/blue/language/processor/GasMeter.java @@ -2,6 +2,7 @@ import blue.language.model.Node; import blue.language.processor.util.NodeCanonicalizer; +import blue.language.snapshot.FrozenNode; /** * Tracks and charges gas usage for a processing run. @@ -48,6 +49,17 @@ void chargePatchAddOrReplace(Node value) { add(GasCharges.patchAddOrReplace(payloadSizeCharge(value))); } + void chargeFrozenPatchAddOrReplace(FrozenNode value) { + add(GasCharges.patchAddOrReplace(frozenPayloadSizeCharge(value))); + } + + void chargeFrozenPatchAddOrReplace(long authoredCanonicalSizeBytes) { + if (authoredCanonicalSizeBytes < 0L) { + throw new IllegalArgumentException("Authored canonical size must be non-negative"); + } + add(GasCharges.patchAddOrReplace(payloadSizeCharge(authoredCanonicalSizeBytes))); + } + void chargePatchRemove() { add(GasCharges.PATCH_REMOVE); } @@ -87,7 +99,14 @@ void chargeFatalTerminationOverhead() { } private long payloadSizeCharge(Node node) { - long bytes = NodeCanonicalizer.canonicalSize(node); + return payloadSizeCharge(NodeCanonicalizer.canonicalSize(node)); + } + + private long frozenPayloadSizeCharge(FrozenNode node) { + return payloadSizeCharge(NodeCanonicalizer.canonicalFrozenSize(node)); + } + + private long payloadSizeCharge(long bytes) { return (bytes + 99L) / 100L; } diff --git a/src/main/java/blue/language/processor/ImmutableJsonPatch.java b/src/main/java/blue/language/processor/ImmutableJsonPatch.java index c4f4ed9..7e13163 100644 --- a/src/main/java/blue/language/processor/ImmutableJsonPatch.java +++ b/src/main/java/blue/language/processor/ImmutableJsonPatch.java @@ -1,6 +1,7 @@ package blue.language.processor; import blue.language.model.Node; +import blue.language.processor.model.FrozenJsonPatch; import blue.language.processor.model.JsonPatch; import blue.language.snapshot.FrozenNode; import blue.language.utils.ParsedJsonPointer; @@ -19,13 +20,15 @@ final class ImmutableJsonPatch { private final FrozenNode canonicalValue; private final FrozenNode resolvedValue; private final String valueBlueId; + private final ProcessingMetricsSink metrics; private ImmutableJsonPatch(JsonPatch.Op op, String authoredPath, ParsedJsonPointer path, Node authoredValue, FrozenNode canonicalValue, - FrozenNode resolvedValue) { + FrozenNode resolvedValue, + ProcessingMetricsSink metrics) { this.op = Objects.requireNonNull(op, "op"); this.authoredPath = Objects.requireNonNull(authoredPath, "authoredPath"); this.path = Objects.requireNonNull(path, "path"); @@ -33,6 +36,7 @@ private ImmutableJsonPatch(JsonPatch.Op op, this.canonicalValue = canonicalValue; this.resolvedValue = resolvedValue; this.valueBlueId = op == JsonPatch.Op.REMOVE ? null : resolvedValue.blueId(); + this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP; } static PreparationContext preparationContext(ProcessingMetricsSink metrics) { @@ -63,6 +67,13 @@ static ImmutableJsonPatch from(JsonPatch patch, .prepare(patch, canonicalRoot, resolvedRoot); } + static ImmutableJsonPatch from(FrozenJsonPatch patch, + FrozenNode canonicalRoot, + FrozenNode resolvedRoot) { + return new PreparationContext(ProcessingMetricsSink.NOOP) + .prepare(patch, canonicalRoot, resolvedRoot); + } + JsonPatch.Op op() { return op; } @@ -102,15 +113,18 @@ boolean matches(ImmutableJsonPatch candidate) { if (candidate == null || op != candidate.op || !path.equals(candidate.path)) { return false; } - return op == JsonPatch.Op.REMOVE || valueBlueId.equals(candidate.valueBlueId); + return op == JsonPatch.Op.REMOVE + || valueBlueId.equals(candidate.valueBlueId) + && canonicalValue.resolvedStructuralKey().equals( + candidate.canonicalValue.resolvedStructuralKey()); } JsonPatch materialize() { switch (op) { case ADD: - return JsonPatch.add(authoredPath, authoredValue.clone()); + return JsonPatch.add(authoredPath, materializedAuthoredValue()); case REPLACE: - return JsonPatch.replace(authoredPath, authoredValue.clone()); + return JsonPatch.replace(authoredPath, materializedAuthoredValue()); case REMOVE: return JsonPatch.remove(authoredPath); default: @@ -118,6 +132,14 @@ JsonPatch materialize() { } } + private Node materializedAuthoredValue() { + if (authoredValue != null) { + return authoredValue.clone(); + } + metrics.incrementFrozenPatchValuesMaterialized(); + return canonicalValue.toNode(); + } + private static FrozenNode freeze(Node value, FrozenNode modeRoot) { if (modeRoot.isStrictCanonical()) { return modeRoot.isStrictBlueIdValidation() @@ -165,10 +187,11 @@ ImmutableJsonPatch prepare(JsonPatch patch, } if (op == JsonPatch.Op.REMOVE) { - return new ImmutableJsonPatch(op, authoredPath, parsed, null, null, null); + return new ImmutableJsonPatch(op, authoredPath, parsed, null, null, null, metrics); } Node value = Objects.requireNonNull(patch.getVal(), "patch value"); + metrics.incrementMutablePatchValuesFrozen(); FrozenNode canonical = freeze(value, canonicalRoot); FrozenNode resolved; if (sameFreezeMode(canonicalRoot, resolvedRoot)) { @@ -177,7 +200,33 @@ ImmutableJsonPatch prepare(JsonPatch patch, } else { resolved = freeze(value, resolvedRoot); } - return new ImmutableJsonPatch(op, authoredPath, parsed, value, canonical, resolved); + return new ImmutableJsonPatch(op, authoredPath, parsed, value, canonical, resolved, metrics); + } + + ImmutableJsonPatch prepare(FrozenJsonPatch patch, + FrozenNode canonicalRoot, + FrozenNode resolvedRoot) { + Objects.requireNonNull(patch, "patch"); + Objects.requireNonNull(canonicalRoot, "canonicalRoot"); + Objects.requireNonNull(resolvedRoot, "resolvedRoot"); + JsonPatch.Op op = Objects.requireNonNull(patch.getOp(), "patch op"); + String authoredPath = Objects.requireNonNull(patch.getPath(), "patch path"); + ParsedJsonPointer parsed = patch.parsedPath(); + if (op == JsonPatch.Op.REMOVE) { + return new ImmutableJsonPatch(op, authoredPath, parsed, null, null, null, metrics); + } + + FrozenNode authored = Objects.requireNonNull(patch.getValue(), "patch value"); + metrics.incrementFrozenPatchValuesAccepted(); + FrozenNode canonical = FrozenNode.authoredValueInModeOf(authored, canonicalRoot); + FrozenNode resolved; + if (sameFreezeMode(canonicalRoot, resolvedRoot)) { + resolved = canonical; + metrics.incrementFrozenPatchValueHits(); + } else { + resolved = FrozenNode.authoredValueInModeOf(authored, resolvedRoot); + } + return new ImmutableJsonPatch(op, authoredPath, parsed, null, canonical, resolved, metrics); } int cachedPointerCount() { diff --git a/src/main/java/blue/language/processor/ImmutablePatchPlanner.java b/src/main/java/blue/language/processor/ImmutablePatchPlanner.java index 9659ff8..8161e3a 100644 --- a/src/main/java/blue/language/processor/ImmutablePatchPlanner.java +++ b/src/main/java/blue/language/processor/ImmutablePatchPlanner.java @@ -64,10 +64,55 @@ PatchPlan planWithExactReplacement(String originScopePath, ImmutableJsonPatch pa return plan(originScopePath, patch, true); } + /** + * Replaces a proven scalar value while retaining its already-resolved basic + * type metadata. Only the scalar leaf is materialized; the surrounding + * frozen tree is spliced with structural sharing. + */ + PatchPlan planWithPreservedResolvedScalarMetadata(String originScopePath, + ImmutableJsonPatch patch) { + Objects.requireNonNull(originScopePath, "originScopePath"); + Objects.requireNonNull(patch, "patch"); + if (patch.op() != JsonPatch.Op.REPLACE || patch.path().isRoot()) { + throw new IllegalArgumentException( + "Resolved scalar metadata preservation requires a non-root replace patch"); + } + FrozenNode existing = read(patch.path()); + FrozenNode replacement = patch.valueFor(root); + if (!PatchImpact.isValueOnlyScalar(existing) + || !PatchImpact.isValueOnlyScalar(replacement)) { + throw new IllegalArgumentException( + "Resolved scalar metadata preservation requires basic scalar leaves"); + } + + Node preservedNode = existing.toNode().value(replacement.getValue()); + FrozenNode preserved = root.isStrictCanonical() + ? root.isStrictBlueIdValidation() + ? FrozenNode.fromNode(preservedNode) + : FrozenNode.fromUncheckedCanonicalNode(preservedNode) + : FrozenNode.fromResolvedNode(preservedNode); + String normalizedScope = PointerUtils.normalizeScope(originScopePath); + CanonicalPatchResult replaced = new CanonicalOverlayPatchEngine(root) + .apply(JsonPatch.Op.REPLACE, patch.path(), preserved); + return new PatchPlan(replaced.root(), + replaced.before(), + replaced.after(), + patch.op(), + patch.normalizedPath(), + normalizedScope, + computeCascadeScopes(normalizedScope)); + } + private PatchPlan plan(String originScopePath, JsonPatch patch, boolean exactReplacement) { Objects.requireNonNull(originScopePath, "originScopePath"); Objects.requireNonNull(patch, "patch"); String normalizedScope = PointerUtils.normalizeScope(originScopePath); + String path = PointerUtils.canonicalizePointer(patch.getPath()); + if ((patch.getOp() == JsonPatch.Op.ADD || patch.getOp() == JsonPatch.Op.REPLACE) + && JsonPointer.split(path).isEmpty()) { + return rootReplacement(normalizedScope, + patch.getOp(), path, freezeValueForRoot(patch.getVal())); + } if (exactReplacement && (patch.getOp() == JsonPatch.Op.ADD || patch.getOp() == JsonPatch.Op.REPLACE)) { return planExactValueWrite(normalizedScope, patch); @@ -88,6 +133,11 @@ private PatchPlan plan(String originScopePath, Objects.requireNonNull(originScopePath, "originScopePath"); Objects.requireNonNull(patch, "patch"); String normalizedScope = PointerUtils.normalizeScope(originScopePath); + if ((patch.op() == JsonPatch.Op.ADD || patch.op() == JsonPatch.Op.REPLACE) + && patch.path().isRoot()) { + return rootReplacement(normalizedScope, + patch.op(), patch.normalizedPath(), patch.valueFor(root)); + } if (exactReplacement && (patch.op() == JsonPatch.Op.ADD || patch.op() == JsonPatch.Op.REPLACE)) { return planExactValueWrite(normalizedScope, patch); @@ -177,6 +227,28 @@ private PatchPlan planExactValueWrite(String normalizedScope, ImmutableJsonPatch computeCascadeScopes(normalizedScope)); } + private FrozenNode freezeValueForRoot(Node value) { + if (!root.isStrictCanonical()) { + return FrozenNode.fromResolvedNode(value); + } + return root.isStrictBlueIdValidation() + ? FrozenNode.fromNode(value) + : FrozenNode.fromUncheckedCanonicalNode(value); + } + + private PatchPlan rootReplacement(String normalizedScope, + JsonPatch.Op op, + String path, + FrozenNode replacement) { + return new PatchPlan(Objects.requireNonNull(replacement, "replacement"), + root, + replacement, + op, + path, + normalizedScope, + computeCascadeScopes(normalizedScope)); + } + private boolean targetsListMember(String path) { List segments = JsonPointer.split(path); if (segments.isEmpty()) { diff --git a/src/main/java/blue/language/processor/PatchImpact.java b/src/main/java/blue/language/processor/PatchImpact.java new file mode 100644 index 0000000..e8852dd --- /dev/null +++ b/src/main/java/blue/language/processor/PatchImpact.java @@ -0,0 +1,285 @@ +package blue.language.processor; + +import blue.language.processor.model.JsonPatch; +import blue.language.snapshot.FrozenNode; +import blue.language.utils.ParsedJsonPointer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import static blue.language.utils.Properties.BOOLEAN_TYPE_BLUE_ID; +import static blue.language.utils.Properties.DOUBLE_TYPE_BLUE_ID; +import static blue.language.utils.Properties.INTEGER_TYPE_BLUE_ID; +import static blue.language.utils.Properties.TEXT_TYPE_BLUE_ID; + +/** Immutable evidence describing which semantic region one patch can affect. */ +final class PatchImpact { + + enum Kind { + VALUE_ONLY, + OBJECT_MEMBER_VALUE, + COLLECTION_SHAPE, + TYPE_METADATA, + SCHEMA_METADATA, + REFERENCE_OR_BLUE_ID, + MERGE_POLICY, + CONTRACT_OR_PROCESSING_STRUCTURE, + ROOT_REPLACEMENT, + UNKNOWN + } + + enum Shape { + MISSING, + SCALAR, + OBJECT, + LIST, + REFERENCE, + METADATA_OR_MIXED + } + + enum FallbackReason { + CUSTOM_MERGING_PROCESSOR, + UNKNOWN_PROCESSOR_CAPABILITY, + ROOT_REPLACEMENT, + CONTRACTS_CHANGED, + TYPE_GRAPH_CHANGED, + SCHEMA_GRAPH_CHANGED, + MERGE_POLICY_CHANGED, + REFERENCE_CYCLE_CHANGED, + UNBOUNDED_SIBLING_DEPENDENCY, + LIST_CONTROL_STRUCTURE_CHANGED, + STRICT_VERIFICATION_EVIDENCE_UNAVAILABLE, + DEPENDENCY_INDEX_MISSING_OR_STALE, + CUSTOM_CONFORMANCE_PLANNER, + NON_REPLACE_OPERATION, + NON_LEAF_VALUE, + RESOLVED_VALUE_DIFFERS, + UNKNOWN + } + + private final Kind kind; + private final ParsedJsonPointer path; + private final JsonPatch.Op operation; + private final Shape beforeShape; + private final Shape afterShape; + private final String changedSubtreeRoot; + private final List ancestorChain; + private final List affectedTypedBoundaries; + private final String nearestCollectionBoundary; + private final boolean typeDependency; + private final boolean schemaDependency; + private final boolean referenceDependency; + private final boolean siblingValuesInfluenceValidation; + private final boolean listPositionOrIdentityCanChange; + private final boolean contractsOrProcessorRoutingCanChange; + private final boolean localResolutionProvenSafe; + private final boolean resolvedScalarMetadataPreservationRequired; + private final FallbackReason fallbackReason; + + PatchImpact(Kind kind, + ParsedJsonPointer path, + JsonPatch.Op operation, + FrozenNode before, + FrozenNode after, + String changedSubtreeRoot, + List ancestorChain, + List affectedTypedBoundaries, + String nearestCollectionBoundary, + boolean typeDependency, + boolean schemaDependency, + boolean referenceDependency, + boolean siblingValuesInfluenceValidation, + boolean listPositionOrIdentityCanChange, + boolean contractsOrProcessorRoutingCanChange, + boolean localResolutionProvenSafe, + boolean resolvedScalarMetadataPreservationRequired, + FallbackReason fallbackReason) { + this.kind = Objects.requireNonNull(kind, "kind"); + this.path = Objects.requireNonNull(path, "path"); + this.operation = Objects.requireNonNull(operation, "operation"); + this.beforeShape = shapeOf(before); + this.afterShape = shapeOf(after); + this.changedSubtreeRoot = Objects.requireNonNull(changedSubtreeRoot, "changedSubtreeRoot"); + this.ancestorChain = immutableCopy(ancestorChain); + this.affectedTypedBoundaries = immutableCopy(affectedTypedBoundaries); + this.nearestCollectionBoundary = nearestCollectionBoundary; + this.typeDependency = typeDependency; + this.schemaDependency = schemaDependency; + this.referenceDependency = referenceDependency; + this.siblingValuesInfluenceValidation = siblingValuesInfluenceValidation; + this.listPositionOrIdentityCanChange = listPositionOrIdentityCanChange; + this.contractsOrProcessorRoutingCanChange = contractsOrProcessorRoutingCanChange; + this.localResolutionProvenSafe = localResolutionProvenSafe; + this.resolvedScalarMetadataPreservationRequired = + resolvedScalarMetadataPreservationRequired; + this.fallbackReason = localResolutionProvenSafe + ? null + : Objects.requireNonNull(fallbackReason, "fallbackReason"); + } + + private static List immutableCopy(List source) { + return Collections.unmodifiableList(new ArrayList<>(Objects.requireNonNull(source, "source"))); + } + + private static Shape shapeOf(FrozenNode node) { + if (node == null) { + return Shape.MISSING; + } + if (node.isReferenceOnly()) { + return Shape.REFERENCE; + } + if (node.hasItems()) { + return Shape.LIST; + } + if (isScalarPayload(node)) { + return Shape.SCALAR; + } + if (node.hasProperties() && !hasMetadata(node)) { + return Shape.OBJECT; + } + return Shape.METADATA_OR_MIXED; + } + + private static boolean hasMetadata(FrozenNode node) { + return node.getType() != null + || node.getItemType() != null + || node.getKeyType() != null + || node.getValueType() != null + || node.getContracts() != null + || node.getReferenceBlueId() != null + || node.getSchema() != null + || node.getMergePolicy() != null + || node.getPreviousBlueId() != null + || node.getPosition() != null + || node.getBlue() != null + || node.isInlineValue(); + } + + /** Accepts either a raw scalar or its exact canonical inferred-basic-type wrapper. */ + static boolean isValueOnlyScalar(FrozenNode node) { + if (node == null || node.getValue() == null + || node.getItemType() != null + || node.getKeyType() != null + || node.getValueType() != null + || node.getContracts() != null + || node.getReferenceBlueId() != null + || node.getSchema() != null + || node.getMergePolicy() != null + || node.getPreviousBlueId() != null + || node.getPosition() != null + || node.getBlue() != null + || node.isInlineValue() + || node.hasItems() + || node.hasProperties() + || node.getName() != null + || node.getDescription() != null) { + return false; + } + FrozenNode type = node.getType(); + if (type == null) { + return true; + } + String expected = inferredTypeBlueId(node.getValue()); + return expected != null + && type.isReferenceOnly() + && expected.equals(type.getReferenceBlueId()); + } + + static boolean isScalarPayload(FrozenNode node) { + return node != null + && node.getValue() != null + && !node.hasItems() + && !node.hasProperties() + && !node.isReferenceOnly(); + } + + static String inferredTypeBlueId(Object value) { + if (value instanceof String) { + return TEXT_TYPE_BLUE_ID; + } + if (value instanceof java.math.BigInteger) { + return INTEGER_TYPE_BLUE_ID; + } + if (value instanceof java.math.BigDecimal) { + return DOUBLE_TYPE_BLUE_ID; + } + if (value instanceof Boolean) { + return BOOLEAN_TYPE_BLUE_ID; + } + return null; + } + + Kind kind() { + return kind; + } + + ParsedJsonPointer path() { + return path; + } + + JsonPatch.Op operation() { + return operation; + } + + Shape beforeShape() { + return beforeShape; + } + + Shape afterShape() { + return afterShape; + } + + String changedSubtreeRoot() { + return changedSubtreeRoot; + } + + List ancestorChain() { + return ancestorChain; + } + + List affectedTypedBoundaries() { + return affectedTypedBoundaries; + } + + String nearestCollectionBoundary() { + return nearestCollectionBoundary; + } + + boolean typeDependency() { + return typeDependency; + } + + boolean schemaDependency() { + return schemaDependency; + } + + boolean referenceDependency() { + return referenceDependency; + } + + boolean siblingValuesInfluenceValidation() { + return siblingValuesInfluenceValidation; + } + + boolean listPositionOrIdentityCanChange() { + return listPositionOrIdentityCanChange; + } + + boolean contractsOrProcessorRoutingCanChange() { + return contractsOrProcessorRoutingCanChange; + } + + boolean localResolutionProvenSafe() { + return localResolutionProvenSafe; + } + + boolean resolvedScalarMetadataPreservationRequired() { + return resolvedScalarMetadataPreservationRequired; + } + + FallbackReason fallbackReason() { + return fallbackReason; + } +} diff --git a/src/main/java/blue/language/processor/PatchImpactAnalyzer.java b/src/main/java/blue/language/processor/PatchImpactAnalyzer.java new file mode 100644 index 0000000..8408464 --- /dev/null +++ b/src/main/java/blue/language/processor/PatchImpactAnalyzer.java @@ -0,0 +1,670 @@ +package blue.language.processor; + +import blue.language.conformance.ConformanceEngine; +import blue.language.processor.model.JsonPatch; +import blue.language.snapshot.FrozenNode; +import blue.language.utils.JsonPointer; +import blue.language.utils.ParsedJsonPointer; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static blue.language.utils.Properties.BOOLEAN_TYPE_BLUE_ID; +import static blue.language.utils.Properties.DOUBLE_TYPE_BLUE_ID; +import static blue.language.utils.Properties.INTEGER_TYPE_BLUE_ID; +import static blue.language.utils.Properties.TEXT_TYPE_BLUE_ID; + +/** + * Conservative dependency analysis for exact-replacement processor patches. + * + *

The analyzer proves locality for plain scalar members and for the narrow + * case where every observing type contributes only matching, value-free basic + * type metadata. Every typed boundary is checked along the relative changed + * path. Unknown processor behavior and every context-sensitive construct use + * an explicit authoritative fallback.

+ */ +final class PatchImpactAnalyzer { + + private final ConformanceEngine conformanceEngine; + private final ConformancePlannerOverride conformancePlannerOverride; + private final ProcessingSnapshotManager snapshotManager; + private final ProcessingMetricsSink metrics; + + PatchImpactAnalyzer(ConformanceEngine conformanceEngine, + ConformancePlannerOverride conformancePlannerOverride, + ProcessingSnapshotManager snapshotManager, + ProcessingMetricsSink metrics) { + this.conformanceEngine = conformanceEngine; + this.conformancePlannerOverride = conformancePlannerOverride; + this.snapshotManager = snapshotManager; + this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP; + } + + PatchImpact analyze(boolean exactReplacement, + FrozenNode canonicalRoot, + FrozenNode resolvedRoot, + ImmutablePatchPlanner.PatchPlan canonicalPlan, + ImmutablePatchPlanner.PatchPlan resolvedPlan, + ImmutableJsonPatch patch) { + Objects.requireNonNull(canonicalRoot, "canonicalRoot"); + Objects.requireNonNull(resolvedRoot, "resolvedRoot"); + Objects.requireNonNull(canonicalPlan, "canonicalPlan"); + Objects.requireNonNull(resolvedPlan, "resolvedPlan"); + Objects.requireNonNull(patch, "patch"); + + metrics.incrementPatchImpactAnalyses(); + ParsedJsonPointer path = patch.path(); + List ancestors = ancestorPaths(path); + List typedBoundaries = new ArrayList<>(); + String collectionBoundary = null; + boolean schemaDependency = false; + boolean referenceDependency = false; + boolean siblingDependency = false; + boolean typeDependency = false; + boolean allObservedTypeDependenciesAreBasicMetadata = true; + String observedBasicTypeBlueId = null; + boolean emptyContractsNormalizationDependency = false; + boolean hasResolutionContext = false; + + for (int depth = 0; depth < path.depth(); depth++) { + String ancestorPath = pointer(path.segments(), depth); + FrozenNode canonicalAncestor = read(canonicalRoot, ancestorPath); + FrozenNode resolvedAncestor = read(resolvedRoot, ancestorPath); + FrozenNode effective = resolvedAncestor != null ? resolvedAncestor : canonicalAncestor; + if (effective == null) { + continue; + } + if (hasResolutionMetadata(canonicalAncestor) || hasResolutionMetadata(resolvedAncestor)) { + hasResolutionContext = true; + } + if (hasTypeMetadata(effective)) { + typedBoundaries.add(ancestorPath); + List relative = path.segments().subList(depth, path.depth()); + TypeObservation observation = observeType(effective, relative); + if (observation.observes) { + typeDependency = true; + schemaDependency |= observation.schemaDependency; + siblingDependency |= observation.siblingDependency; + if (!observation.valueFreeBasicMetadata + || observedBasicTypeBlueId != null + && !observedBasicTypeBlueId.equals(observation.basicTypeBlueId)) { + allObservedTypeDependenciesAreBasicMetadata = false; + } else if (observedBasicTypeBlueId == null) { + observedBasicTypeBlueId = observation.basicTypeBlueId; + } + } + } + if (hasSchemaDependency(canonicalAncestor) || hasSchemaDependency(resolvedAncestor)) { + schemaDependency = true; + siblingDependency = true; + } + if (hasReferenceDependency(canonicalAncestor) || hasReferenceDependency(resolvedAncestor)) { + referenceDependency = true; + } + if (hasEmptyContractsNormalizationDependency(canonicalAncestor) + || hasEmptyContractsNormalizationDependency(resolvedAncestor)) { + emptyContractsNormalizationDependency = true; + siblingDependency = true; + } + if (isCollectionBoundary(effective)) { + if (collectionBoundary == null) { + collectionBoundary = ancestorPath; + } + siblingDependency = true; + } + } + + boolean collectionChange = collectionBoundary != null + || parentIsList(canonicalRoot, path) + || parentIsList(resolvedRoot, path); + boolean contractsChange = containsSegment(path, "contracts"); + boolean typeChange = containsAnySegment(path, "type", "itemType", "keyType", "valueType"); + boolean schemaChange = containsSegment(path, "schema"); + boolean referenceChange = containsAnySegment(path, "blueId", "blue", "$previous", "$pos"); + boolean mergePolicyChange = containsSegment(path, "mergePolicy"); + boolean listIdentityChange = collectionChange + || patch.op() != JsonPatch.Op.REPLACE && path.hasArrayIndexLeaf(); + boolean safeBasicTypeDependency = typeDependency + && allObservedTypeDependenciesAreBasicMetadata + && observedBasicTypeBlueId != null + && observedBasicTypeBlueId.equals( + PatchImpact.inferredTypeBlueId(canonicalPlan.after() != null + ? canonicalPlan.after().getValue() + : null)); + + PatchImpact.Kind kind = classify(path, + patch, + canonicalPlan, + resolvedPlan, + collectionChange, + contractsChange, + typeChange, + schemaChange, + referenceChange, + mergePolicyChange); + recordKind(kind); + metrics.addConformanceTypedBoundariesConsidered(typedBoundaries.size()); + + boolean legacyRequiresAuthoritative = hasResolutionContext + || !sameStructure(canonicalPlan.before(), resolvedPlan.before()) + || patch.op() != JsonPatch.Op.REMOVE + && !isPlainValue(patch.resolvedValue(), new IdentityHashMap()); + + Decision decision; + if (!exactReplacement) { + decision = Decision.fallback(PatchImpact.FallbackReason.UNKNOWN); + } else if (!legacyRequiresAuthoritative) { + // Preserve rc.11's already-safe direct path for untyped/plain data. + decision = Decision.local(); + } else { + decision = decideTypedLocality(path, + patch, + canonicalPlan, + resolvedPlan, + kind, + typeDependency, + safeBasicTypeDependency, + observedBasicTypeBlueId, + schemaDependency, + referenceDependency, + siblingDependency, + collectionChange, + contractsChange, + typeChange, + schemaChange, + referenceChange, + mergePolicyChange); + } + + return new PatchImpact(kind, + path, + patch.op(), + resolvedPlan.before(), + resolvedPlan.after(), + path.pointer(), + ancestors, + typedBoundaries, + collectionBoundary, + typeDependency, + schemaDependency, + referenceDependency, + siblingDependency, + listIdentityChange, + contractsChange || emptyContractsNormalizationDependency, + decision.local, + decision.local && safeBasicTypeDependency, + decision.reason); + } + + private Decision decideTypedLocality(ParsedJsonPointer path, + ImmutableJsonPatch patch, + ImmutablePatchPlanner.PatchPlan canonicalPlan, + ImmutablePatchPlanner.PatchPlan resolvedPlan, + PatchImpact.Kind kind, + boolean typeDependency, + boolean safeBasicTypeDependency, + String observedBasicTypeBlueId, + boolean schemaDependency, + boolean referenceDependency, + boolean siblingDependency, + boolean collectionChange, + boolean contractsChange, + boolean typeChange, + boolean schemaChange, + boolean referenceChange, + boolean mergePolicyChange) { + if (path.isRoot()) { + return Decision.fallback(PatchImpact.FallbackReason.ROOT_REPLACEMENT); + } + if (contractsChange) { + return Decision.fallback(PatchImpact.FallbackReason.CONTRACTS_CHANGED); + } + if (schemaChange || schemaDependency) { + return Decision.fallback(PatchImpact.FallbackReason.SCHEMA_GRAPH_CHANGED); + } + if (typeChange || typeDependency && !safeBasicTypeDependency) { + return Decision.fallback(PatchImpact.FallbackReason.TYPE_GRAPH_CHANGED); + } + if (mergePolicyChange) { + return Decision.fallback(PatchImpact.FallbackReason.MERGE_POLICY_CHANGED); + } + if (referenceChange || referenceDependency) { + return Decision.fallback(PatchImpact.FallbackReason.REFERENCE_CYCLE_CHANGED); + } + if (collectionChange) { + return Decision.fallback(PatchImpact.FallbackReason.LIST_CONTROL_STRUCTURE_CHANGED); + } + if (siblingDependency) { + return Decision.fallback(PatchImpact.FallbackReason.UNBOUNDED_SIBLING_DEPENDENCY); + } + if (patch.op() != JsonPatch.Op.REPLACE) { + return Decision.fallback(PatchImpact.FallbackReason.NON_REPLACE_OPERATION); + } + if (kind != PatchImpact.Kind.VALUE_ONLY + || !PatchImpact.isValueOnlyScalar(canonicalPlan.before()) + || !PatchImpact.isValueOnlyScalar(canonicalPlan.after()) + || !PatchImpact.isValueOnlyScalar(resolvedPlan.before()) + || !PatchImpact.isValueOnlyScalar(resolvedPlan.after())) { + return Decision.fallback(PatchImpact.FallbackReason.NON_LEAF_VALUE); + } + if (safeBasicTypeDependency + ? !sameBasicScalar(canonicalPlan.before(), resolvedPlan.before(), observedBasicTypeBlueId) + || !sameBasicScalar(canonicalPlan.after(), resolvedPlan.after(), observedBasicTypeBlueId) + : !sameStructure(canonicalPlan.before(), resolvedPlan.before()) + || !sameStructure(canonicalPlan.after(), resolvedPlan.after())) { + return Decision.fallback(PatchImpact.FallbackReason.RESOLVED_VALUE_DIFFERS); + } + if (conformancePlannerOverride != null && conformancePlannerOverride.applies()) { + return Decision.fallback(PatchImpact.FallbackReason.CUSTOM_CONFORMANCE_PLANNER); + } + if (conformanceEngine == null || !conformanceEngine.supportsIncrementalValueResolution()) { + return Decision.fallback(PatchImpact.FallbackReason.CUSTOM_MERGING_PROCESSOR); + } + if (snapshotManager == null || !snapshotManager.supportsIncrementalValueResolution()) { + return Decision.fallback(PatchImpact.FallbackReason.UNKNOWN_PROCESSOR_CAPABILITY); + } + return Decision.local(); + } + + private TypeObservation observeType(FrozenNode boundary, List relativePath) { + TypeObservation aggregate = TypeObservation.none(); + FrozenNode[] declaredTypes = { + boundary.getType(), boundary.getItemType(), boundary.getKeyType(), boundary.getValueType() + }; + for (FrozenNode declaredType : declaredTypes) { + if (declaredType != null) { + aggregate = aggregate.merge(observeTypePath(declaredType, relativePath)); + } + } + return aggregate; + } + + private TypeObservation observeTypePath(FrozenNode type, List relativePath) { + FrozenNode current = type; + for (String segment : relativePath) { + if (current == null) { + return TypeObservation.none(); + } + if (hasIntermediatePayloadDependency(current)) { + return TypeObservation.unsafe(hasSchemaDependency(current), true); + } + current = current.property(segment); + } + if (current == null) { + return TypeObservation.none(); + } + String basicTypeBlueId = valueFreeBasicTypeContribution(current); + if (basicTypeBlueId != null) { + return TypeObservation.safeBasic(basicTypeBlueId); + } + return TypeObservation.unsafe(hasSchemaDependency(current), hasSiblingPayloadDependency(current)); + } + + private boolean hasIntermediatePayloadDependency(FrozenNode node) { + return node != null + && (node.getSchema() != null + || node.getType() != null + || node.getItemType() != null + || node.getKeyType() != null + || node.getValueType() != null + || node.getMergePolicy() != null + || node.getContracts() != null + || node.isReferenceOnly() + || node.getPreviousBlueId() != null + || node.getPosition() != null + || node.getBlue() != null + || node.isInlineValue() + || node.getValue() != null + || node.hasItems()); + } + + private String valueFreeBasicTypeContribution(FrozenNode node) { + if (node == null + || node.getName() != null + || node.getDescription() != null + || node.getValue() != null + || node.hasItems() + || node.hasProperties() + || node.getContracts() != null + || node.getReferenceBlueId() != null + || node.getSchema() != null + || node.getMergePolicy() != null + || node.getPreviousBlueId() != null + || node.getPosition() != null + || node.getBlue() != null + || node.isInlineValue() + || node.getItemType() != null + || node.getKeyType() != null + || node.getValueType() != null) { + return null; + } + FrozenNode type = node.getType(); + if (type == null || !type.isReferenceOnly()) { + return null; + } + String blueId = type.getReferenceBlueId(); + return isBasicTypeBlueId(blueId) ? blueId : null; + } + + private boolean isBasicTypeBlueId(String blueId) { + return TEXT_TYPE_BLUE_ID.equals(blueId) + || INTEGER_TYPE_BLUE_ID.equals(blueId) + || DOUBLE_TYPE_BLUE_ID.equals(blueId) + || BOOLEAN_TYPE_BLUE_ID.equals(blueId); + } + + private boolean hasSiblingPayloadDependency(FrozenNode node) { + return node != null + && (node.hasItems() + || node.hasProperties() + || node.getContracts() != null + || node.getItemType() != null + || node.getKeyType() != null + || node.getValueType() != null + || node.getMergePolicy() != null); + } + + private PatchImpact.Kind classify(ParsedJsonPointer path, + ImmutableJsonPatch patch, + ImmutablePatchPlanner.PatchPlan canonicalPlan, + ImmutablePatchPlanner.PatchPlan resolvedPlan, + boolean collectionChange, + boolean contractsChange, + boolean typeChange, + boolean schemaChange, + boolean referenceChange, + boolean mergePolicyChange) { + if (path.isRoot()) { + return PatchImpact.Kind.ROOT_REPLACEMENT; + } + if (contractsChange) { + return PatchImpact.Kind.CONTRACT_OR_PROCESSING_STRUCTURE; + } + if (typeChange) { + return PatchImpact.Kind.TYPE_METADATA; + } + if (schemaChange) { + return PatchImpact.Kind.SCHEMA_METADATA; + } + if (referenceChange) { + return PatchImpact.Kind.REFERENCE_OR_BLUE_ID; + } + if (mergePolicyChange) { + return PatchImpact.Kind.MERGE_POLICY; + } + if (collectionChange) { + return PatchImpact.Kind.COLLECTION_SHAPE; + } + if (patch.op() == JsonPatch.Op.REPLACE + && PatchImpact.isScalarPayload(canonicalPlan.after()) + && PatchImpact.isScalarPayload(resolvedPlan.before()) + && PatchImpact.isScalarPayload(resolvedPlan.after()) + && (canonicalPlan.before() == null + || PatchImpact.isScalarPayload(canonicalPlan.before()))) { + return PatchImpact.Kind.VALUE_ONLY; + } + if (patch.op() != JsonPatch.Op.REMOVE + && canonicalPlan.after() != null + && !path.isRoot()) { + return PatchImpact.Kind.OBJECT_MEMBER_VALUE; + } + return PatchImpact.Kind.UNKNOWN; + } + + private void recordKind(PatchImpact.Kind kind) { + switch (kind) { + case VALUE_ONLY: + metrics.incrementPatchImpactValueOnly(); + break; + case OBJECT_MEMBER_VALUE: + metrics.incrementPatchImpactObjectMemberValue(); + break; + case COLLECTION_SHAPE: + metrics.incrementPatchImpactCollectionShape(); + break; + case TYPE_METADATA: + metrics.incrementPatchImpactTypeMetadata(); + break; + case SCHEMA_METADATA: + metrics.incrementPatchImpactSchemaMetadata(); + break; + case REFERENCE_OR_BLUE_ID: + metrics.incrementPatchImpactReference(); + break; + case MERGE_POLICY: + metrics.incrementPatchImpactMergePolicy(); + break; + case CONTRACT_OR_PROCESSING_STRUCTURE: + metrics.incrementPatchImpactContractsOrProcessing(); + break; + case ROOT_REPLACEMENT: + metrics.incrementPatchImpactRootReplacement(); + break; + case UNKNOWN: + default: + metrics.incrementPatchImpactUnknown(); + break; + } + } + + private List ancestorPaths(ParsedJsonPointer path) { + List paths = new ArrayList<>(path.depth()); + for (int depth = 0; depth < path.depth(); depth++) { + paths.add(pointer(path.segments(), depth)); + } + return paths; + } + + private String pointer(List segments, int depth) { + return JsonPointer.toPointer(segments.subList(0, depth)); + } + + private FrozenNode read(FrozenNode root, String path) { + return root == null ? null : ImmutablePatchPlanner.forFrozen(root).read(path); + } + + private boolean parentIsList(FrozenNode root, ParsedJsonPointer path) { + if (root == null || path.isRoot()) { + return false; + } + FrozenNode parent = read(root, path.parent().pointer()); + return parent != null && parent.hasItems(); + } + + private boolean hasTypeMetadata(FrozenNode node) { + return node != null + && (node.getType() != null + || node.getItemType() != null + || node.getKeyType() != null + || node.getValueType() != null); + } + + private boolean hasResolutionMetadata(FrozenNode node) { + return node != null + && (hasTypeMetadata(node) + || node.getSchema() != null + || node.getMergePolicy() != null + || node.getReferenceBlueId() != null + || node.getPreviousBlueId() != null + || node.getPosition() != null + || node.getBlue() != null + || node.isInlineValue()); + } + + private boolean hasSchemaDependency(FrozenNode node) { + if (node == null) { + return false; + } + if (node.getSchema() != null) { + return true; + } + FrozenNode type = node.getType(); + return type != null && type.getSchema() != null; + } + + private boolean hasReferenceDependency(FrozenNode node) { + return node != null + && (node.isReferenceOnly() + || node.getPreviousBlueId() != null + || node.getBlue() != null); + } + + private boolean hasEmptyContractsNormalizationDependency(FrozenNode node) { + return node != null + && node.getContracts() != null + && node.getContracts().isEmptyNode(); + } + + private boolean isCollectionBoundary(FrozenNode node) { + return node != null + && (node.hasItems() + || node.getItemType() != null + || node.getKeyType() != null + || node.getValueType() != null + || node.getMergePolicy() != null); + } + + private boolean sameStructure(FrozenNode left, FrozenNode right) { + return left == right || left != null && left.sameResolvedStructure(right); + } + + private boolean sameBasicScalar(FrozenNode left, + FrozenNode right, + String expectedBasicTypeBlueId) { + return PatchImpact.isValueOnlyScalar(left) + && PatchImpact.isValueOnlyScalar(right) + && Objects.equals(left.getValue(), right.getValue()) + && expectedBasicTypeBlueId.equals(effectiveBasicTypeBlueId(left)) + && expectedBasicTypeBlueId.equals(effectiveBasicTypeBlueId(right)); + } + + private String effectiveBasicTypeBlueId(FrozenNode node) { + FrozenNode type = node.getType(); + return type != null ? type.getReferenceBlueId() : PatchImpact.inferredTypeBlueId(node.getValue()); + } + + private boolean isPlainValue(FrozenNode node, Map visited) { + if (node == null || visited.put(node, Boolean.TRUE) != null) { + return false; + } + if (node.getBlue() != null + || node.getReferenceBlueId() != null + || node.getType() != null + || node.getItemType() != null + || node.getKeyType() != null + || node.getValueType() != null + || node.getPreviousBlueId() != null + || node.getPosition() != null + || node.getName() != null + || node.getDescription() != null + || node.getSchema() != null + || node.getContracts() != null + || node.getMergePolicy() != null + || node.isInlineValue()) { + return false; + } + if (node.getItems() != null) { + for (FrozenNode item : node.getItems()) { + if (!isPlainValue(item, visited)) { + return false; + } + } + } + if (node.getProperties() != null) { + for (FrozenNode property : node.getProperties().values()) { + if (!isPlainValue(property, visited)) { + return false; + } + } + } + return true; + } + + private boolean containsSegment(ParsedJsonPointer path, String segment) { + return path.segments().contains(segment); + } + + private boolean containsAnySegment(ParsedJsonPointer path, String... segments) { + for (String segment : segments) { + if (containsSegment(path, segment)) { + return true; + } + } + return false; + } + + private static final class Decision { + private final boolean local; + private final PatchImpact.FallbackReason reason; + + private Decision(boolean local, PatchImpact.FallbackReason reason) { + this.local = local; + this.reason = reason; + } + + private static Decision local() { + return new Decision(true, null); + } + + private static Decision fallback(PatchImpact.FallbackReason reason) { + return new Decision(false, Objects.requireNonNull(reason, "reason")); + } + } + + private static final class TypeObservation { + private final boolean observes; + private final boolean valueFreeBasicMetadata; + private final String basicTypeBlueId; + private final boolean schemaDependency; + private final boolean siblingDependency; + + private TypeObservation(boolean observes, + boolean valueFreeBasicMetadata, + String basicTypeBlueId, + boolean schemaDependency, + boolean siblingDependency) { + this.observes = observes; + this.valueFreeBasicMetadata = valueFreeBasicMetadata; + this.basicTypeBlueId = basicTypeBlueId; + this.schemaDependency = schemaDependency; + this.siblingDependency = siblingDependency; + } + + private static TypeObservation none() { + return new TypeObservation(false, false, null, false, false); + } + + private static TypeObservation safeBasic(String basicTypeBlueId) { + return new TypeObservation(true, + true, + Objects.requireNonNull(basicTypeBlueId, "basicTypeBlueId"), + false, + false); + } + + private static TypeObservation unsafe(boolean schemaDependency, + boolean siblingDependency) { + return new TypeObservation(true, false, null, schemaDependency, siblingDependency); + } + + private TypeObservation merge(TypeObservation other) { + if (!observes) { + return other; + } + if (!other.observes) { + return this; + } + boolean sameBasicType = valueFreeBasicMetadata + && other.valueFreeBasicMetadata + && basicTypeBlueId.equals(other.basicTypeBlueId); + return new TypeObservation(true, + sameBasicType, + sameBasicType ? basicTypeBlueId : null, + schemaDependency || other.schemaDependency, + siblingDependency || other.siblingDependency); + } + } +} diff --git a/src/main/java/blue/language/processor/PatchInput.java b/src/main/java/blue/language/processor/PatchInput.java new file mode 100644 index 0000000..1baad44 --- /dev/null +++ b/src/main/java/blue/language/processor/PatchInput.java @@ -0,0 +1,94 @@ +package blue.language.processor; + +import blue.language.model.Node; +import blue.language.processor.model.FrozenJsonPatch; +import blue.language.processor.model.JsonPatch; +import blue.language.snapshot.FrozenNode; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** One defensively captured mutable or already-frozen authored patch. */ +final class PatchInput { + + private final JsonPatch mutablePatch; + private final FrozenJsonPatch frozenPatch; + + private PatchInput(JsonPatch mutablePatch, FrozenJsonPatch frozenPatch) { + this.mutablePatch = mutablePatch; + this.frozenPatch = frozenPatch; + } + + static PatchInput mutable(JsonPatch patch) { + return patch == null ? null : new PatchInput(ImmutableJsonPatch.copy(patch), null); + } + + static PatchInput frozen(FrozenJsonPatch patch) { + return patch == null ? null : new PatchInput(null, patch); + } + + static List mutableList(List patches) { + if (patches == null || patches.isEmpty()) { + return Collections.emptyList(); + } + List captured = new ArrayList<>(patches.size()); + for (JsonPatch patch : patches) { + captured.add(mutable(patch)); + } + return Collections.unmodifiableList(captured); + } + + static List frozenList(List patches) { + if (patches == null || patches.isEmpty()) { + return Collections.emptyList(); + } + List captured = new ArrayList<>(patches.size()); + for (FrozenJsonPatch patch : patches) { + captured.add(frozen(patch)); + } + return Collections.unmodifiableList(captured); + } + + JsonPatch.Op op() { + return mutablePatch != null ? mutablePatch.getOp() : frozenPatch.getOp(); + } + + String authoredPath() { + return mutablePatch != null ? mutablePatch.getPath() : frozenPatch.getPath(); + } + + boolean isFrozen() { + return frozenPatch != null; + } + + Node mutableValue() { + return mutablePatch != null ? mutablePatch.getVal() : null; + } + + FrozenNode frozenValue() { + return frozenPatch != null ? frozenPatch.getValue() : null; + } + + long frozenAuthoredCanonicalSizeBytes() { + if (frozenPatch == null) { + throw new IllegalStateException("Mutable patch inputs do not carry frozen authored size"); + } + return frozenPatch.getAuthoredCanonicalSizeBytes(); + } + + JsonPatch legacyPatch() { + if (mutablePatch == null) { + throw new IllegalStateException("Frozen patch inputs do not expose a mutable validation patch"); + } + return mutablePatch; + } + + ImmutableJsonPatch prepare(ImmutableJsonPatch.PreparationContext context, + FrozenNode canonicalRoot, + FrozenNode resolvedRoot) { + return mutablePatch != null + ? context.prepare(mutablePatch, canonicalRoot, resolvedRoot) + : context.prepare(frozenPatch, canonicalRoot, resolvedRoot); + } +} diff --git a/src/main/java/blue/language/processor/PatchPlanningEngine.java b/src/main/java/blue/language/processor/PatchPlanningEngine.java index 261e07d..96666a8 100644 --- a/src/main/java/blue/language/processor/PatchPlanningEngine.java +++ b/src/main/java/blue/language/processor/PatchPlanningEngine.java @@ -12,7 +12,6 @@ import java.util.ArrayList; import java.util.Collections; -import java.util.IdentityHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; @@ -37,6 +36,8 @@ final class PatchPlanningEngine { private final ConformancePlannerOverride conformancePlannerOverride; private final DocumentProcessingRuntime.UpdateMaterializationMetrics materializationMetrics; private final ImmutableJsonPatch.PreparationContext patchPreparation; + private final ProcessingMetricsSink metrics; + private final PatchImpactAnalyzer impactAnalyzer; PatchPlanningEngine(String originScopePath, DocumentProcessingRuntime.PlanningContext planning, @@ -89,7 +90,12 @@ final class PatchPlanningEngine { this.conformanceEngine = conformanceEngine; this.conformancePlannerOverride = conformancePlannerOverride; this.materializationMetrics = materializationMetrics; - this.patchPreparation = ImmutableJsonPatch.preparationContext(metrics); + this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP; + this.patchPreparation = ImmutableJsonPatch.preparationContext(this.metrics); + this.impactAnalyzer = new PatchImpactAnalyzer(conformanceEngine, + conformancePlannerOverride, + authoritativeSnapshotManager, + this.metrics); } BatchPatchResult planAtomic(List patches, boolean buildUpdates) { @@ -102,6 +108,16 @@ BatchPatchResult planAtomic(List patches, boolean buildUpdates) { return plan(prepared, initialCanonicalRoot, initialResolvedRoot, buildUpdates); } + BatchPatchResult planAtomicInputs(List patches, boolean buildUpdates) { + if (initialCanonicalRoot == null || initialResolvedRoot == null) { + throw new IllegalStateException("Atomic planning roots were not retained"); + } + List prepared = preparePatchInputs(patches, + initialCanonicalRoot, + initialResolvedRoot); + return plan(prepared, initialCanonicalRoot, initialResolvedRoot, buildUpdates); + } + BatchPatchResult planSequentialStep(FrozenNode canonicalRoot, FrozenNode resolvedRoot, JsonPatch patch) { @@ -119,6 +135,14 @@ ImmutableJsonPatch preparePatch(JsonPatch patch, Objects.requireNonNull(resolvedRoot, "resolvedRoot")); } + ImmutableJsonPatch preparePatch(PatchInput patch, + FrozenNode canonicalRoot, + FrozenNode resolvedRoot) { + return Objects.requireNonNull(patch, "patch").prepare(patchPreparation, + Objects.requireNonNull(canonicalRoot, "canonicalRoot"), + Objects.requireNonNull(resolvedRoot, "resolvedRoot")); + } + BatchPatchResult planSequentialStep(FrozenNode canonicalRoot, FrozenNode resolvedRoot, ImmutableJsonPatch patch) { @@ -139,6 +163,17 @@ List preparePatches(List patches, return Collections.unmodifiableList(prepared); } + List preparePatchInputs(List patches, + FrozenNode canonicalRoot, + FrozenNode resolvedRoot) { + Objects.requireNonNull(patches, "patches"); + List prepared = new ArrayList<>(patches.size()); + for (PatchInput patch : patches) { + prepared.add(preparePatch(patch, canonicalRoot, resolvedRoot)); + } + return Collections.unmodifiableList(prepared); + } + private BatchPatchResult plan(List patches, FrozenNode initialCanonical, FrozenNode initialResolved, @@ -147,7 +182,7 @@ private BatchPatchResult plan(List patches, long planningStart = System.nanoTime(); FrozenNode workingCanonical = initialCanonical; FrozenNode workingResolved = initialResolved; - boolean authoritativeResolutionRequired = false; + PatchImpact.FallbackReason authoritativeFallbackReason = null; List records = new ArrayList<>(); List preparedPatches = new ArrayList<>(patches.size()); for (ImmutableJsonPatch prepared : patches) { @@ -161,14 +196,25 @@ private BatchPatchResult plan(List patches, ImmutablePatchPlanner.PatchPlan resolvedPlan = exactReplacement ? resolvedPlanner.planWithExactReplacement(originScopePath, prepared) : resolvedPlanner.plan(originScopePath, prepared); + PatchImpact impact = impactAnalyzer.analyze(exactReplacement, + workingCanonical, + workingResolved, + canonicalPlan, + resolvedPlan, + prepared); + if (impact.resolvedScalarMetadataPreservationRequired()) { + resolvedPlan = resolvedPlanner.planWithPreservedResolvedScalarMetadata( + originScopePath, prepared); + } if (exactReplacement - && requiresAuthoritativeResolution( - workingCanonical, workingResolved, canonicalPlan, resolvedPlan, prepared)) { - authoritativeResolutionRequired = true; + && !impact.localResolutionProvenSafe() + && authoritativeFallbackReason == null) { + authoritativeFallbackReason = impact.fallbackReason(); } BatchPatchRecord record = new BatchPatchRecord(prepared, canonicalPlan, resolvedPlan, + impact, isProcessorManagedConformanceBypass(canonicalPlan)); records.add(record); workingCanonical = canonicalPlan.root(); @@ -184,15 +230,31 @@ && requiresAuthoritativeResolution( ? conformancePlan.canonicalRoot() : workingCanonical; FrozenNode finalResolved = conformancePlan.root(); - if (exactReplacement - && (authoritativeResolutionRequired || !conformancePlan.fullSnapshotRebuildAvoidable())) { + boolean fullSnapshotResolution = exactReplacement + && (authoritativeFallbackReason != null || !conformancePlan.fullSnapshotRebuildAvoidable()); + if (fullSnapshotResolution) { if (authoritativeSnapshotManager == null) { throw new IllegalStateException("Authoritative snapshot resolution is unavailable"); } + PatchImpact.FallbackReason reason = authoritativeFallbackReason != null + ? authoritativeFallbackReason + : PatchImpact.FallbackReason.DEPENDENCY_INDEX_MISSING_OR_STALE; + metrics.incrementFullSnapshotFallback(reason.name()); + metrics.incrementFullCanonicalRootMaterializations(); + metrics.incrementFullFrozenRootToNodeMaterializations(); ResolvedSnapshot authoritative = authoritativeSnapshotManager.fromDocumentTransient(finalCanonical.toNode()); + metrics.incrementFullResolvedRootMaterializations(); finalCanonical = authoritative.frozenCanonicalRoot(); finalResolved = authoritative.frozenResolvedRoot(); + } else if (exactReplacement) { + for (BatchPatchRecord record : records) { + if (record.impact().localResolutionProvenSafe()) { + metrics.incrementIncrementalSnapshotResolutions(); + metrics.addIncrementalBoundaryPathDepth(record.impact().path().depth()); + metrics.addIncrementalBoundaryNodeCount(1L); + } + } } boolean includeGeneratedUpdates = conformancePlannerOverride != null && conformancePlannerOverride.applies(); @@ -221,96 +283,6 @@ && requiresAuthoritativeResolution( buildUpdatesNanos); } - private boolean requiresAuthoritativeResolution(FrozenNode canonicalRoot, - FrozenNode resolvedRoot, - ImmutablePatchPlanner.PatchPlan canonicalPlan, - ImmutablePatchPlanner.PatchPlan resolvedPlan, - ImmutableJsonPatch patch) { - if (hasResolutionContext(canonicalRoot, canonicalPlan.path()) - || hasResolutionContext(resolvedRoot, resolvedPlan.path())) { - return true; - } - if (!sameResolvedStructure(canonicalPlan.before(), resolvedPlan.before())) { - return true; - } - return patch.op() != JsonPatch.Op.REMOVE - && !isPlainValue(patch.resolvedValue(), new IdentityHashMap()); - } - - private boolean hasResolutionContext(FrozenNode root, String path) { - List segments = JsonPointer.split(path); - ImmutablePatchPlanner planner = ImmutablePatchPlanner.forFrozen(root); - int ancestorCount = Math.max(1, segments.size()); - for (int depth = 0; depth < ancestorCount; depth++) { - FrozenNode ancestor = planner.read(JsonPointer.toPointer(segments.subList(0, depth))); - if (ancestor != null && hasResolutionMetadata(ancestor)) { - return true; - } - } - return false; - } - - private boolean hasResolutionMetadata(FrozenNode node) { - return node.getType() != null - || node.getItemType() != null - || node.getKeyType() != null - || node.getValueType() != null - || node.getSchema() != null - || node.getMergePolicy() != null - || node.getReferenceBlueId() != null - || node.getPreviousBlueId() != null - || node.getPosition() != null - || node.getBlue() != null - || node.isInlineValue(); - } - - private boolean sameResolvedStructure(FrozenNode left, FrozenNode right) { - if (left == right) { - return true; - } - if (left == null || right == null) { - return false; - } - return left.sameResolvedStructure(right); - } - - private boolean isPlainValue(FrozenNode node, IdentityHashMap visited) { - if (node == null || visited.put(node, Boolean.TRUE) != null) { - return false; - } - if (node.getBlue() != null - || node.getReferenceBlueId() != null - || node.getType() != null - || node.getItemType() != null - || node.getKeyType() != null - || node.getValueType() != null - || node.getPreviousBlueId() != null - || node.getPosition() != null - || node.getName() != null - || node.getDescription() != null - || node.getSchema() != null - || node.getContracts() != null - || node.getMergePolicy() != null - || node.isInlineValue()) { - return false; - } - if (node.getItems() != null) { - for (FrozenNode item : node.getItems()) { - if (!isPlainValue(item, visited)) { - return false; - } - } - } - if (node.getProperties() != null) { - for (FrozenNode property : node.getProperties().values()) { - if (!isPlainValue(property, visited)) { - return false; - } - } - } - return true; - } - private List generalizationMetadataWrites( FrozenNode finalCanonical, FrozenNode finalResolved, @@ -386,6 +358,9 @@ private ConformancePlan planBatchConformance(FrozenNode canonicalRoot, if (record.processorManagedConformanceBypass()) { continue; } + if (record.impact().localResolutionProvenSafe()) { + continue; + } if (hasTypedNodeBetweenOriginAndPath(resolvedRoot, record.originScope(), record.path())) { changedPaths.add(record.path()); changedPathRecords.add(new ConformanceChangedPath(record.path(), record.originScope())); @@ -394,6 +369,7 @@ private ConformancePlan planBatchConformance(FrozenNode canonicalRoot, if (changedPaths.isEmpty()) { return ConformancePlan.unchanged(canonicalRoot, resolvedRoot); } + metrics.incrementConformancePlans(); if (hasOverride) { ConformancePlan plan = conformancePlannerOverride.plan(canonicalRoot, resolvedRoot, changedPathRecords); String originScope = originScopeForGeneratedUpdate(records); diff --git a/src/main/java/blue/language/processor/ProcessingMetricsSink.java b/src/main/java/blue/language/processor/ProcessingMetricsSink.java index 8c6ed62..08bd8ae 100644 --- a/src/main/java/blue/language/processor/ProcessingMetricsSink.java +++ b/src/main/java/blue/language/processor/ProcessingMetricsSink.java @@ -11,305 +11,665 @@ public interface ProcessingMetricsSink { }; default void addProcessDocumentNanos(long nanos) { + addMetric("processDocumentNanos", nanos); } default void addBlueProcessDocumentNanos(long nanos) { + addMetric("blueProcessDocumentNanos", nanos); } default void addEventPreprocessNanos(long nanos) { + addMetric("eventPreprocessNanos", nanos); } default void addResultSnapshotAttachNanos(long nanos) { + addMetric("resultSnapshotAttachNanos", nanos); } default void addBlueIdCalculationNanos(long nanos) { + addMetric("blueIdCalculationNanos", nanos); } default void addProcessingSnapshotCacheLookupNanos(long nanos) { + addMetric("processingSnapshotCacheLookupNanos", nanos); } default void incrementProcessingSnapshotCacheHits() { + addMetric("processingSnapshotCacheHits", 1L); } default void incrementProcessingSnapshotCacheMisses() { + addMetric("processingSnapshotCacheMisses", 1L); } default void addProcessingSnapshotFromDocumentNanos(long nanos) { + addMetric("processingSnapshotFromDocumentNanos", nanos); } default void incrementProcessingSnapshotFromDocumentBuilds() { + addMetric("processingSnapshotFromDocumentBuilds", 1L); } /** * Records one attempt to create the immutable Processing Event snapshot. */ default void incrementProcessEventSnapshotAttempts() { + addMetric("processEventSnapshotAttempts", 1L); } /** * Records one successfully created immutable Processing Event snapshot. */ default void incrementProcessEventSnapshotBuilds() { + addMetric("processEventSnapshotBuilds", 1L); } /** * Records one failed immutable Processing Event snapshot construction. */ default void incrementProcessEventSnapshotFailures() { + addMetric("processEventSnapshotFailures", 1L); } /** * Records the duration of one immutable Processing Event snapshot attempt. */ default void addProcessEventSnapshotConstructionNanos(long nanos) { + addMetric("processEventSnapshotConstructionNanos", nanos); } default void addBundleLoadNanos(long nanos) { + addMetric("bundleLoadNanos", nanos); } default void addBundleLoadCacheKeyBuildNanos(long nanos) { + addMetric("bundleLoadCacheKeyBuildNanos", nanos); } default void addBundleLoadActualBuildNanos(long nanos) { + addMetric("bundleLoadActualBuildNanos", nanos); } default void addBundleLoadReuseNanos(long nanos) { + addMetric("bundleLoadReuseNanos", nanos); } default void incrementBundleLoadCacheHits() { + addMetric("bundleLoadCacheHits", 1L); } default void incrementBundleLoadCacheMisses() { + addMetric("bundleLoadCacheMisses", 1L); } default void incrementBundlesBuilt() { + addMetric("bundlesBuilt", 1L); } default void incrementBundlesReused() { + addMetric("bundlesReused", 1L); } default void incrementBundleScopeLoadAttempts() { + addMetric("bundleScopeLoadAttempts", 1L); } default void incrementBundleScopeExecutionCacheHits() { + addMetric("bundleScopeExecutionCacheHits", 1L); } default void incrementBundleScopeRefreshes() { + addMetric("bundleScopeRefreshes", 1L); } default void addBundleScopeTerminationCheckNanos(long nanos) { + addMetric("bundleScopeTerminationCheckNanos", nanos); } default void addBundleScopeResolvedLookupNanos(long nanos) { + addMetric("bundleScopeResolvedLookupNanos", nanos); } default void addBundleScopeContractLoadNanos(long nanos) { + addMetric("bundleScopeContractLoadNanos", nanos); } default void addChannelDiscoveryNanos(long nanos) { + addMetric("channelDiscoveryNanos", nanos); } default void addChannelMatchNanos(long nanos) { + addMetric("channelMatchNanos", nanos); } default void incrementChannelEvaluations() { + addMetric("channelEvaluations", 1L); } /** * Records one handler dispatch through an explicitly routed channel delivery. */ default void incrementRoutedChannelDeliveries() { + addMetric("routedChannelDeliveries", 1L); } /** * Records one eligible source delivery whose successful logical route was already dispatched. */ default void incrementDeduplicatedChannelDeliveries() { + addMetric("deduplicatedChannelDeliveries", 1L); } default void addHandlerDiscoveryNanos(long nanos) { + addMetric("handlerDiscoveryNanos", nanos); } default void addHandlerMatchNanos(long nanos) { + addMetric("handlerMatchNanos", nanos); } default void incrementHandlerMatchAttempts() { + addMetric("handlerMatchAttempts", 1L); } default void addHandlerExecutionNanos(long nanos) { + addMetric("handlerExecutionNanos", nanos); } default void incrementHandlersExecuted() { + addMetric("handlersExecuted", 1L); } default void addTriggeredEventRoutingNanos(long nanos) { + addMetric("triggeredEventRoutingNanos", nanos); } default void incrementTriggeredEventsRouted() { + addMetric("triggeredEventsRouted", 1L); } default void addCheckpointUpdateNanos(long nanos) { + addMetric("checkpointUpdateNanos", nanos); } default void addCheckpointEnsureNanos(long nanos) { + addMetric("checkpointEnsureNanos", nanos); } default void addCheckpointFindNanos(long nanos) { + addMetric("checkpointFindNanos", nanos); } default void addCheckpointCurrentIdentityNanos(long nanos) { + addMetric("checkpointCurrentIdentityNanos", nanos); } default void addCheckpointIsNewerNanos(long nanos) { + addMetric("checkpointIsNewerNanos", nanos); } default void addCheckpointDuplicateNanos(long nanos) { + addMetric("checkpointDuplicateNanos", nanos); } default void addCheckpointPersistNanos(long nanos) { + addMetric("checkpointPersistNanos", nanos); } default void incrementCheckpointIdentityCacheHits() { + addMetric("checkpointIdentityCacheHits", 1L); } default void incrementCheckpointIdentityCacheMisses() { + addMetric("checkpointIdentityCacheMisses", 1L); } default void incrementCheckpointStoredIdentityCacheHits() { + addMetric("checkpointStoredIdentityCacheHits", 1L); } default void incrementCheckpointStoredIdentityCacheMisses() { + addMetric("checkpointStoredIdentityCacheMisses", 1L); } default void addCheckpointDirectBlueIdNanos(long nanos) { + addMetric("checkpointDirectBlueIdNanos", nanos); } default void addCheckpointContentBlueIdNanos(long nanos) { + addMetric("checkpointContentBlueIdNanos", nanos); } default void addCheckpointFallbackNanos(long nanos) { + addMetric("checkpointFallbackNanos", nanos); } default void addSnapshotCommitNanos(long nanos) { + addMetric("snapshotCommitNanos", nanos); } default void addPostProcessingNanos(long nanos) { + addMetric("postProcessingNanos", nanos); } default void addPatchBoundaryNanos(long nanos) { + addMetric("patchBoundaryNanos", nanos); } default void addPatchGasNanos(long nanos) { + addMetric("patchGasNanos", nanos); } default void addDocumentUpdateRoutingNanos(long nanos) { + addMetric("documentUpdateRoutingNanos", nanos); } default void incrementDocumentUpdateEventsBuilt() { + addMetric("documentUpdateEventsBuilt", 1L); } default void incrementDocumentUpdateEventsSkippedNoChannel() { + addMetric("documentUpdateEventsSkippedNoChannel", 1L); } default void addBatchPatchPlanningNanos(long nanos) { + addMetric("batchPatchPlanningNanos", nanos); } default void addBatchPatchConformanceNanos(long nanos) { + addMetric("batchPatchConformanceNanos", nanos); } default void addBatchPatchBuildUpdatesNanos(long nanos) { + addMetric("batchPatchBuildUpdatesNanos", nanos); } default void addBatchPatchCommitNanos(long nanos) { + addMetric("batchPatchCommitNanos", nanos); } default void incrementDocumentUpdateBeforeMaterializations() { + addMetric("documentUpdateBeforeMaterializations", 1L); } default void incrementDocumentUpdateAfterMaterializations() { + addMetric("documentUpdateAfterMaterializations", 1L); } /** Records one reusable observable-sequential patch planning session. */ default void incrementPatchSequencesPrepared() { + addMetric("patchSequencesPrepared", 1L); } /** Records patches accepted by reusable observable-sequential sessions. */ default void addPatchesPrepared(long count) { + addMetric("patchesPrepared", count); } /** Records use of the legacy standalone one-patch transaction path. */ default void incrementSingletonPatchTransactions() { + addMetric("singletonPatchTransactions", 1L); } default void addSequencePlanningNanos(long nanos) { + addMetric("sequencePlanningNanos", nanos); } default void addSequenceConformanceNanos(long nanos) { + addMetric("sequenceConformanceNanos", nanos); } default void addSequenceCommitNanos(long nanos) { + addMetric("sequenceCommitNanos", nanos); } default void addSequenceFinalCacheCommitNanos(long nanos) { + addMetric("sequenceFinalCacheCommitNanos", nanos); } default void incrementSequenceIntermediateSnapshotAdvances() { + addMetric("sequenceIntermediateSnapshotAdvances", 1L); } default void incrementSequenceSharedSnapshotCacheInserts() { + addMetric("sequenceSharedSnapshotCacheInserts", 1L); } default void incrementSequenceFinalSnapshotCacheInserts() { + addMetric("sequenceFinalSnapshotCacheInserts", 1L); } default void incrementSequenceSuffixRebases() { + addMetric("sequenceSuffixRebases", 1L); } default void incrementSequenceStalePreviewFallbacks() { + addMetric("sequenceStalePreviewFallbacks", 1L); } default void incrementSequenceFallbackPatches() { + addMetric("sequenceFallbackPatches", 1L); } default void incrementParsedPointerCacheHits() { + addMetric("parsedPointerCacheHits", 1L); } default void incrementParsedPointerCacheMisses() { + addMetric("parsedPointerCacheMisses", 1L); } default void incrementFrozenPatchValueHits() { + addMetric("frozenPatchValueHits", 1L); } default void incrementPatchValueMaterializations() { + addMetric("patchValueMaterializations", 1L); } default void incrementFrozenNodesCreated() { + addMetric("frozenNodesCreated", 1L); } default void incrementFrozenNodesReused() { + addMetric("frozenNodesReused", 1L); } default void incrementCanonicalIdentityCalculations() { + addMetric("canonicalIdentityCalculations", 1L); } default void incrementResolvedIdentityCalculations() { + addMetric("resolvedIdentityCalculations", 1L); } default void addCanonicalBytesWritten(long count) { + addMetric("canonicalBytesWritten", count); } default void incrementJcsFallbacks() { + addMetric("jcsFallbacks", 1L); } default void addBase58EncodeNanos(long nanos) { + addMetric("base58EncodeNanos", nanos); } default void addBase58DecodeNanos(long nanos) { + addMetric("base58DecodeNanos", nanos); } default void addBlueIdDigestNanos(long nanos) { + addMetric("blueIdDigestNanos", nanos); } default void incrementResolvedStructuralKeyBuilds() { + addMetric("resolvedStructuralKeyBuilds", 1L); + } + + /** + * Generic additive counter hook used by the default phase-specific methods + * below. Implementations may override individual methods instead. Metric + * names are fixed library constants and must not contain document paths or + * BlueIds. + */ + default void addMetric(String metricName, long delta) { + } + + /** Records a current-value gauge rather than an additive counter. */ + default void setMetric(String metricName, long value) { + } + + /** Records the maximum value observed for a gauge. */ + default void recordMetricHighWater(String metricName, long value) { + } + + default void incrementPatchImpactAnalyses() { + addMetric("patchImpactAnalyses", 1L); + } + + default void incrementPatchImpactValueOnly() { + addMetric("patchImpactValueOnly", 1L); + } + + default void incrementPatchImpactObjectMemberValue() { + addMetric("patchImpactObjectMemberValue", 1L); + } + + default void incrementPatchImpactCollectionShape() { + addMetric("patchImpactCollectionShape", 1L); + } + + default void incrementPatchImpactTypeMetadata() { + addMetric("patchImpactTypeMetadata", 1L); + } + + default void incrementPatchImpactSchemaMetadata() { + addMetric("patchImpactSchemaMetadata", 1L); + } + + default void incrementPatchImpactReference() { + addMetric("patchImpactReference", 1L); + } + + default void incrementPatchImpactMergePolicy() { + addMetric("patchImpactMergePolicy", 1L); + } + + default void incrementPatchImpactContractsOrProcessing() { + addMetric("patchImpactContractsOrProcessing", 1L); + } + + default void incrementPatchImpactRootReplacement() { + addMetric("patchImpactRootReplacement", 1L); + } + + default void incrementPatchImpactUnknown() { + addMetric("patchImpactUnknown", 1L); + } + + default void incrementIncrementalSnapshotResolutions() { + addMetric("incrementalSnapshotResolutions", 1L); + } + + default void incrementFullSnapshotFallback(String reason) { + addMetric("fullSnapshotFallbacks", 1L); + addMetric("fullSnapshotFallbackReason." + reason, 1L); + } + + default void incrementFullCanonicalRootMaterializations() { + addMetric("fullCanonicalRootMaterializations", 1L); + } + + default void incrementFullResolvedRootMaterializations() { + addMetric("fullResolvedRootMaterializations", 1L); + } + + default void addIncrementalBoundaryPathDepth(long depth) { + addMetric("incrementalBoundaryPathDepth", depth); + } + + default void addIncrementalBoundaryNodeCount(long count) { + addMetric("incrementalBoundaryNodeCount", count); + } + + default void addIncrementalAncestorsRevalidated(long count) { + addMetric("incrementalAncestorsRevalidated", count); + } + + default void addReferencesReResolved(long count) { + addMetric("referencesReResolved", count); + } + + default void addReferencesReused(long count) { + addMetric("referencesReused", count); + } + + default void incrementConformancePlans() { + addMetric("conformancePlans", 1L); + } + + default void addConformanceNodesVisited(long count) { + addMetric("conformanceNodesVisited", count); + } + + default void addConformanceTypedBoundariesConsidered(long count) { + addMetric("conformanceTypedBoundariesConsidered", count); + } + + default void addConformanceTypedBoundariesValidated(long count) { + addMetric("conformanceTypedBoundariesValidated", count); + } + + default void addConformanceTypedBoundariesGeneralized(long count) { + addMetric("conformanceTypedBoundariesGeneralized", count); + } + + default void incrementConformanceFullRootScans() { + addMetric("conformanceFullRootScans", 1L); + } + + default void addConformanceMutableNodeMaterializations(long count) { + addMetric("conformanceMutableNodeMaterializations", count); + } + + default void addConformanceMergerInvocations(long count) { + addMetric("conformanceMergerInvocations", count); + } + + default void incrementConformanceTypePlanHits() { + addMetric("conformanceTypePlanHits", 1L); + } + + default void incrementConformanceTypePlanMisses() { + addMetric("conformanceTypePlanMisses", 1L); + } + + default void incrementConformanceSchemaPlanHits() { + addMetric("conformanceSchemaPlanHits", 1L); + } + + default void incrementConformanceSchemaPlanMisses() { + addMetric("conformanceSchemaPlanMisses", 1L); + } + + default void incrementCompiledPatternHits() { + addMetric("compiledPatternHits", 1L); + } + + default void incrementCompiledPatternMisses() { + addMetric("compiledPatternMisses", 1L); + } + + default void incrementCanonicalDigestWrites() { + addMetric("canonicalDigestWrites", 1L); + } + + default void addCanonicalDigestBytes(long count) { + addMetric("canonicalDigestBytes", count); + } + + default void incrementCanonicalGenericGraphFallbacks() { + addMetric("canonicalGenericGraphFallbacks", 1L); + } + + default void incrementCanonicalWholeStringsCreated() { + addMetric("canonicalWholeStringsCreated", 1L); + } + + default void incrementCanonicalWholeByteArraysCreated() { + addMetric("canonicalWholeByteArraysCreated", 1L); + } + + default void incrementBlueIdCalculations() { + addMetric("blueIdCalculations", 1L); + } + + default void incrementBlueIdMemoHits() { + addMetric("blueIdMemoHits", 1L); + } + + default void incrementBase58Encodes() { + addMetric("base58Encodes", 1L); + } + + default void incrementFrozenPatchValuesAccepted() { + addMetric("frozenPatchValuesAccepted", 1L); + } + + default void incrementMutablePatchValuesFrozen() { + addMetric("mutablePatchValuesFrozen", 1L); + } + + default void incrementFrozenPatchValuesMaterialized() { + addMetric("frozenPatchValuesMaterialized", 1L); + } + + default void incrementFullFrozenRootToNodeMaterializations() { + addMetric("fullFrozenRootToNodeMaterializations", 1L); + } + + default void incrementSubtreeToNodeMaterializations() { + addMetric("subtreeToNodeMaterializations", 1L); + } + + default void incrementNodeCloneCalls(String purpose) { + addMetric("nodeCloneCallsByPurpose." + purpose, 1L); + } + + default void setCacheCurrentWeightBytes(String cacheName, long value) { + setMetric("cache." + cacheName + ".currentWeightBytes", value); + } + + default void recordCacheHighWaterBytes(String cacheName, long value) { + recordMetricHighWater("cache." + cacheName + ".highWaterBytes", value); + } + + default void setCacheEntries(String cacheName, long value) { + setMetric("cache." + cacheName + ".entries", value); + } + + default void incrementCacheHits(String cacheName) { + addMetric("cache." + cacheName + ".hits", 1L); + } + + default void incrementCacheMisses(String cacheName) { + addMetric("cache." + cacheName + ".misses", 1L); + } + + default void incrementCacheEvictions(String cacheName) { + addMetric("cache." + cacheName + ".evictions", 1L); + } + + default void incrementCacheOversizedRejections(String cacheName) { + addMetric("cache." + cacheName + ".oversizedRejections", 1L); + } + + default void setCachePinnedEntries(String cacheName, long value) { + setMetric("cache." + cacheName + ".pinnedEntries", value); + } + + default void setCacheDerivedEntries(String cacheName, long value) { + setMetric("cache." + cacheName + ".derivedEntries", value); + } + + default void incrementRuntimeCloseCalls() { + addMetric("runtimeCloseCalls", 1L); + } + + default void addRuntimeCloseReleasedWeightBytes(long count) { + addMetric("runtimeCloseReleasedWeightBytes", count); + } + + default void addSequenceCacheEntriesReleased(long count) { + addMetric("sequenceCacheEntriesReleased", count); + } + + default void incrementReferenceReachabilityDeltaUpdates() { + addMetric("referenceReachabilityDeltaUpdates", 1L); + } + + default void incrementReferenceReachabilityFullScans() { + addMetric("referenceReachabilityFullScans", 1L); } } diff --git a/src/main/java/blue/language/processor/ProcessingMetricsSnapshot.java b/src/main/java/blue/language/processor/ProcessingMetricsSnapshot.java new file mode 100644 index 0000000..7816bce --- /dev/null +++ b/src/main/java/blue/language/processor/ProcessingMetricsSnapshot.java @@ -0,0 +1,45 @@ +package blue.language.processor; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Immutable point-in-time view of production processing counters and gauges. + */ +public final class ProcessingMetricsSnapshot { + + private final Map counters; + private final Map gauges; + + ProcessingMetricsSnapshot(Map counters, Map gauges) { + this.counters = Collections.unmodifiableMap(new LinkedHashMap<>(counters)); + this.gauges = Collections.unmodifiableMap(new LinkedHashMap<>(gauges)); + } + + public Map counters() { + return counters; + } + + public Map gauges() { + return gauges; + } + + public long counter(String name) { + Long value = counters.get(name); + return value != null ? value : 0L; + } + + public long gauge(String name) { + Long value = gauges.get(name); + return value != null ? value : 0L; + } + + @Override + public String toString() { + return "ProcessingMetricsSnapshot{" + + "counters=" + counters + + ", gauges=" + gauges + + '}'; + } +} diff --git a/src/main/java/blue/language/processor/ProcessingSnapshotManager.java b/src/main/java/blue/language/processor/ProcessingSnapshotManager.java index 92263a0..bb40429 100644 --- a/src/main/java/blue/language/processor/ProcessingSnapshotManager.java +++ b/src/main/java/blue/language/processor/ProcessingSnapshotManager.java @@ -43,11 +43,26 @@ default void retainTransientState(FrozenNode canonicalRoot, FrozenNode resolvedR // Historical managers have no explicit transient cache to prune. } + /** Releases a transient manager after its preview/sequence ownership ends. */ + default void releaseTransientState() { + // Historical managers have no explicitly owned transient state. + } + /** Whether this transient scope still belongs to the manager's current cache generation. */ default boolean isTransientStateCurrent() { return true; } + /** + * Whether this manager accepts dependency-proven value-only snapshot + * updates without invoking {@link #fromDocumentTransient(Node)}. + * + *

The default is deliberately conservative for custom managers.

+ */ + default boolean supportsIncrementalValueResolution() { + return false; + } + /** * Returns the conformance view that shares this sequence's transient * resolution scope. Cache-aware decorators should delegate this method diff --git a/src/main/java/blue/language/processor/ProcessorEngine.java b/src/main/java/blue/language/processor/ProcessorEngine.java index bdbfee5..00a73a6 100644 --- a/src/main/java/blue/language/processor/ProcessorEngine.java +++ b/src/main/java/blue/language/processor/ProcessorEngine.java @@ -613,6 +613,14 @@ void handlePatches(String scopePath, scopeExecutor.handlePatches(scopePath, bundle, patches, allowReservedMutation, preview); } + void handlePatchInputs(String scopePath, + ContractBundle bundle, + List patches, + boolean allowReservedMutation, + WorkingDocument.Preview preview) { + scopeExecutor.handlePatchInputs(scopePath, bundle, patches, allowReservedMutation, preview); + } + ProcessorExecutionContext createContext(String scopePath, ContractBundle bundle, Node event) { diff --git a/src/main/java/blue/language/processor/ProcessorExecutionContext.java b/src/main/java/blue/language/processor/ProcessorExecutionContext.java index eb1bd2d..912e25b 100644 --- a/src/main/java/blue/language/processor/ProcessorExecutionContext.java +++ b/src/main/java/blue/language/processor/ProcessorExecutionContext.java @@ -2,6 +2,7 @@ import blue.language.model.Node; import blue.language.processor.conformance.ScriptedContractsRuntime; +import blue.language.processor.model.FrozenJsonPatch; import blue.language.processor.model.JsonPatch; import blue.language.snapshot.FrozenNode; @@ -11,8 +12,12 @@ /** * Lightweight wrapper passed to contract processors while executing. + * + *

The context is valid only for its handler invocation. The processor + * runtime closes it after applying or abandoning buffered effects; later + * effect mutation and working-document creation are rejected.

*/ -public final class ProcessorExecutionContext { +public final class ProcessorExecutionContext implements AutoCloseable { private final ProcessorEngine.Execution execution; private final ContractBundle bundle; @@ -23,6 +28,7 @@ public final class ProcessorExecutionContext { private final boolean allowReservedMutation; private final ContractEffectBuffer effects = new ContractEffectBuffer(); private boolean effectsApplied; + private boolean closed; ProcessorExecutionContext(ProcessorEngine.Execution execution, ContractBundle bundle, @@ -90,6 +96,7 @@ public FrozenNode frozenProcessEvent() { } public void applyPatch(JsonPatch patch) { + ensureOpen(); if (patch == null) { return; } @@ -97,6 +104,7 @@ public void applyPatch(JsonPatch patch) { } public void applyPatches(List patches) { + ensureOpen(); if (execution.shouldStopScopeWork(scopePath)) { return; } @@ -106,7 +114,16 @@ public void applyPatches(List patches) { effects.addPatches(patches); } + /** + * Buffers patches with a precomputed preview. + * + *

When this context accepts a non-empty patch list, it owns the preview + * and releases it after the buffered effects are consumed or abandoned. + * If execution has already stopped or the list is empty, ownership remains + * with the caller.

+ */ public void applyPreviewedPatches(List patches, WorkingDocument.Preview preview) { + ensureOpen(); if (execution.shouldStopScopeWork(scopePath)) { return; } @@ -116,7 +133,43 @@ public void applyPreviewedPatches(List patches, WorkingDocument.Previ effects.addPreviewedPatches(patches, preview); } + public void applyFrozenPatch(FrozenJsonPatch patch) { + ensureOpen(); + if (patch == null) { + return; + } + applyFrozenPatches(Collections.singletonList(patch)); + } + + public void applyFrozenPatches(List patches) { + ensureOpen(); + if (execution.shouldStopScopeWork(scopePath)) { + return; + } + if (patches == null || patches.isEmpty()) { + return; + } + effects.addFrozenPatches(patches); + } + + /** + * Frozen-patch counterpart of {@link #applyPreviewedPatches(List, WorkingDocument.Preview)}. + * Accepting a non-empty patch list transfers preview ownership to this context. + */ + public void applyPreviewedFrozenPatches(List patches, + WorkingDocument.Preview preview) { + ensureOpen(); + if (execution.shouldStopScopeWork(scopePath)) { + return; + } + if (patches == null || patches.isEmpty()) { + return; + } + effects.addPreviewedFrozenPatches(patches, preview); + } + public void emitEvent(Node emission) { + ensureOpen(); if (execution.shouldStopScopeWork(scopePath)) { return; } @@ -129,6 +182,18 @@ void applyBufferedEffects() { return; } effectsApplied = true; + Throwable failure = null; + try { + applyBufferedEffectsNow(); + } catch (RuntimeException | Error ex) { + failure = ex; + throw ex; + } finally { + closeEffects(failure); + } + } + + private void applyBufferedEffectsNow() { if (execution.shouldStopScopeWork(scopePath)) { return; } @@ -143,7 +208,7 @@ void applyBufferedEffects() { runtime().addGas(effects.gas()); } for (ContractEffectBuffer.PatchBatch patchBatch : effects.patchBatches()) { - execution.handlePatches(scopePath, + execution.handlePatchInputs(scopePath, bundle, patchBatch.patches(), allowReservedMutation, @@ -174,7 +239,33 @@ void applyBufferedEffects() { } } + /** Discards buffered work and releases every transferred preview. */ + @Override + public void close() { + if (closed) { + return; + } + closed = true; + effectsApplied = true; + effects.close(); + } + + private void closeEffects(Throwable primaryFailure) { + try { + close(); + } catch (RuntimeException | Error cleanupFailure) { + if (primaryFailure != null) { + if (primaryFailure != cleanupFailure) { + primaryFailure.addSuppressed(cleanupFailure); + } + } else { + throw cleanupFailure; + } + } + } + public void consumeGas(long units) { + ensureOpen(); if (execution.shouldStopScopeWork(scopePath)) { return; } @@ -182,6 +273,7 @@ public void consumeGas(long units) { } public void throwFatal(String reason) { + ensureOpen(); applyBufferedEffects(); throw new ProcessorFatalException(reason, execution.partialResult(), @@ -214,10 +306,12 @@ public FrozenNode resolvedFrozenAt(String absolutePointer) { } public WorkingDocument newWorkingDocument() { + ensureOpen(); return runtime().workingDocument(scopePath); } public WorkingDocument newWorkingDocument(String originScope) { + ensureOpen(); return runtime().workingDocument(originScope); } @@ -229,13 +323,21 @@ public boolean documentContains(String absolutePointer) { } public void terminateGracefully(String reason) { + ensureOpen(); effects.terminate(ScopeRuntimeContext.TerminationKind.GRACEFUL, reason); } public void terminateFatally(String reason) { + ensureOpen(); effects.terminate(ScopeRuntimeContext.TerminationKind.FATAL, reason); } + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Processor execution context is closed"); + } + } + private boolean emitEventNow(Node emission) { try { CheckpointIdentityCalculator.identity(emission, execution.blue()); diff --git a/src/main/java/blue/language/processor/RecordingProcessingMetricsSink.java b/src/main/java/blue/language/processor/RecordingProcessingMetricsSink.java new file mode 100644 index 0000000..d72767a --- /dev/null +++ b/src/main/java/blue/language/processor/RecordingProcessingMetricsSink.java @@ -0,0 +1,71 @@ +package blue.language.processor; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Thread-safe metrics sink intended for diagnostics, tests, and integration + * reports. Normal production deployments may continue to use the no-op sink or + * their existing metrics adapter. + */ +public final class RecordingProcessingMetricsSink implements ProcessingMetricsSink { + + private final ConcurrentMap counters = new ConcurrentHashMap<>(); + private final ConcurrentMap gauges = new ConcurrentHashMap<>(); + + @Override + public void addMetric(String metricName, long delta) { + requireMetricName(metricName); + counters.computeIfAbsent(metricName, ignored -> new AtomicLong()).addAndGet(delta); + } + + @Override + public void setMetric(String metricName, long value) { + requireMetricName(metricName); + gauges.computeIfAbsent(metricName, ignored -> new AtomicLong()).set(value); + } + + @Override + public void recordMetricHighWater(String metricName, long value) { + requireMetricName(metricName); + AtomicLong highWater = gauges.computeIfAbsent(metricName, ignored -> new AtomicLong()); + long current = highWater.get(); + while (value > current && !highWater.compareAndSet(current, value)) { + current = highWater.get(); + } + } + + public ProcessingMetricsSnapshot snapshot() { + return new ProcessingMetricsSnapshot(sortedValues(counters), sortedValues(gauges)); + } + + public void clear() { + counters.clear(); + gauges.clear(); + } + + private Map sortedValues(ConcurrentMap source) { + List names = new ArrayList<>(source.keySet()); + Collections.sort(names); + Map values = new LinkedHashMap<>(); + for (String name : names) { + AtomicLong value = source.get(name); + if (value != null) { + values.put(name, value.get()); + } + } + return values; + } + + private void requireMetricName(String metricName) { + if (metricName == null || metricName.isEmpty()) { + throw new IllegalArgumentException("metricName must not be empty"); + } + } +} diff --git a/src/main/java/blue/language/processor/ScopeExecutor.java b/src/main/java/blue/language/processor/ScopeExecutor.java index 86c953a..844e571 100644 --- a/src/main/java/blue/language/processor/ScopeExecutor.java +++ b/src/main/java/blue/language/processor/ScopeExecutor.java @@ -289,6 +289,18 @@ void handlePatches(String scopePath, List patches, boolean allowReservedMutation, WorkingDocument.Preview preview) { + handlePatchInputs(scopePath, + bundle, + PatchInput.mutableList(patches), + allowReservedMutation, + preview); + } + + void handlePatchInputs(String scopePath, + ContractBundle bundle, + List patches, + boolean allowReservedMutation, + WorkingDocument.Preview preview) { if (execution.shouldStopScopeWork(scopePath)) { return; } @@ -296,9 +308,9 @@ void handlePatches(String scopePath, return; } try (DocumentProcessingRuntime.PreparedPatchSequence sequence = - runtime.preparePatchSequence(scopePath, patches, preview)) { + runtime.preparePatchInputSequence(scopePath, patches, preview)) { for (int patchIndex = 0; patchIndex < sequence.size(); patchIndex++) { - JsonPatch patch = sequence.patchForValidation(patchIndex); + PatchInput patch = sequence.patchInputForValidation(patchIndex); if (execution.shouldStopScopeWork(scopePath)) { return; } @@ -381,11 +393,16 @@ void handlePatches(String scopePath, } } - private void chargePatchGas(JsonPatch patch) { - switch (patch.getOp()) { + private void chargePatchGas(PatchInput patch) { + switch (patch.op()) { case ADD: case REPLACE: - runtime.chargePatchAddOrReplace(patch.getVal()); + if (patch.isFrozen()) { + runtime.chargeFrozenPatchAddOrReplace( + patch.frozenAuthoredCanonicalSizeBytes()); + } else { + runtime.chargePatchAddOrReplace(patch.mutableValue()); + } break; case REMOVE: runtime.chargePatchRemove(); @@ -734,12 +751,12 @@ private void drainTriggeredQueue(String scopePath, ContractBundle bundle) { } } - private void validatePatchBoundary(String scopePath, ContractBundle bundle, JsonPatch patch) { + private void validatePatchBoundary(String scopePath, ContractBundle bundle, PatchInput patch) { if (bundle == null) { return; } String normalizedScope = ProcessorEngine.normalizeScope(scopePath); - String targetPath = PointerUtils.assertValidRuntimePointer(patch.getPath()); + String targetPath = PointerUtils.assertValidRuntimePointer(patch.authoredPath()); if ("/".equals(targetPath)) { throw new ProcessorEngine.BoundaryViolationException("Patch path '/' is forbidden"); @@ -766,13 +783,13 @@ private void validatePatchBoundary(String scopePath, ContractBundle bundle, Json } private void enforceReservedKeyWriteProtection(String scopePath, - JsonPatch patch, + PatchInput patch, boolean allowReservedMutation) { if (allowReservedMutation) { return; } String normalizedScope = ProcessorEngine.normalizeScope(scopePath); - String targetPath = PointerUtils.assertValidRuntimePointer(patch.getPath()); + String targetPath = PointerUtils.assertValidRuntimePointer(patch.authoredPath()); String contractsPointer = ProcessorEngine.resolvePointer(normalizedScope, ProcessorPointerConstants.RELATIVE_CONTRACTS); if (targetPath.equals(contractsPointer)) { enforceContractsMapReservedSubtreePreservation(normalizedScope, patch); @@ -794,8 +811,8 @@ private void enforceReservedKeyWriteProtection(String scopePath, } } - private void enforceContractsMapReservedSubtreePreservation(String scopePath, JsonPatch patch) { - if (patch.getOp() == JsonPatch.Op.REMOVE) { + private void enforceContractsMapReservedSubtreePreservation(String scopePath, PatchInput patch) { + if (patch.op() == JsonPatch.Op.REMOVE) { for (String key : ProcessorContractConstants.RESERVED_CONTRACT_KEYS) { String reservedPointer = ProcessorEngine.resolvePointer(scopePath, ProcessorPointerConstants.relativeContractsEntry(key)); if (runtime.canonicalNodeAt(reservedPointer) != null) { @@ -805,23 +822,49 @@ private void enforceContractsMapReservedSubtreePreservation(String scopePath, Js } return; } - Node replacement = patch.getVal(); + Node replacement = patch.mutableValue(); + FrozenNode frozenReplacement = patch.frozenValue(); for (String key : ProcessorContractConstants.RESERVED_CONTRACT_KEYS) { String reservedPointer = ProcessorEngine.resolvePointer(scopePath, ProcessorPointerConstants.relativeContractsEntry(key)); - Node existing = runtime.canonicalNodeAt(reservedPointer); - if (existing == null) { - continue; + boolean equal; + if (patch.isFrozen()) { + FrozenNode existing = runtime.canonicalFrozenAt(reservedPointer); + if (existing == null) { + continue; + } + FrozenNode proposed = frozenReplacement != null + ? frozenReplacement.property(key) + : null; + equal = semanticallyEqual(existing, proposed); + } else { + Node existing = runtime.canonicalNodeAt(reservedPointer); + if (existing == null) { + continue; + } + Node proposed = replacement != null && replacement.getProperties() != null + ? replacement.getProperties().get(key) + : null; + equal = semanticallyEqual(existing, proposed); } - Node proposed = replacement != null && replacement.getProperties() != null - ? replacement.getProperties().get(key) - : null; - if (!semanticallyEqual(existing, proposed)) { + if (!equal) { throw new ProcessorFailureException(ProcessorErrorCategory.ReservedKeyWrite, "Replacing /contracts must preserve reserved key '" + key + "'"); } } } + private boolean semanticallyEqual(FrozenNode left, FrozenNode right) { + if (left == null || right == null) { + return left == right; + } + // Reserved runtime subtrees are stored as unchecked canonical state, + // while a public FrozenJsonPatch carries a strict authored value. The + // boundary's historical oracle is the unchecked identity used by the + // mutable lane, so normalize both representations to that identity. + return BlueIdCalculator.calculateUncheckedBlueId(left.toNode()) + .equals(BlueIdCalculator.calculateUncheckedBlueId(right.toNode())); + } + private boolean semanticallyEqual(Node left, Node right) { if (left == null || right == null) { return left == right; diff --git a/src/main/java/blue/language/processor/SequentialPatchPlanningSession.java b/src/main/java/blue/language/processor/SequentialPatchPlanningSession.java index d2b70fe..271b5bf 100644 --- a/src/main/java/blue/language/processor/SequentialPatchPlanningSession.java +++ b/src/main/java/blue/language/processor/SequentialPatchPlanningSession.java @@ -16,11 +16,12 @@ * step fails, which lets callers safely retain an already-planned prefix or * rebase an unconsumed suffix onto an observed runtime state.

*/ -final class SequentialPatchPlanningSession { +final class SequentialPatchPlanningSession implements AutoCloseable { private final String originScope; private final PatchPlanningEngine planningEngine; private final ProcessingMetricsSink metrics; + private final ConformanceEngine conformanceEngine; private FrozenNode canonicalRoot; private FrozenNode resolvedRoot; private boolean metricsStarted; @@ -47,6 +48,7 @@ final class SequentialPatchPlanningSession { this.originScope = PointerUtils.normalizeScope(Objects.requireNonNull(originScope, "originScope")); Objects.requireNonNull(planning, "planning"); this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP; + this.conformanceEngine = conformanceEngine; this.canonicalRoot = planning.baseSnapshot() != null ? planning.baseSnapshot().frozenCanonicalRoot() : planning.canonicalPlanner().root(); @@ -62,6 +64,13 @@ final class SequentialPatchPlanningSession { false); } + @Override + public void close() { + if (conformanceEngine != null) { + conformanceEngine.close(); + } + } + List preparePatches(List patches) { return planningEngine.preparePatches(patches, canonicalRoot, resolvedRoot); } @@ -76,6 +85,17 @@ ImmutableJsonPatch preparePatch(JsonPatch patch, return planningEngine.preparePatch(patch, actualCanonicalRoot, actualResolvedRoot); } + ImmutableJsonPatch preparePatch(PatchInput patch, + FrozenNode actualCanonicalRoot, + FrozenNode actualResolvedRoot) { + return planningEngine.preparePatch(patch, actualCanonicalRoot, actualResolvedRoot); + } + + PlannedStep planNext(PatchInput patch) { + return planNext(planningEngine.preparePatch( + Objects.requireNonNull(patch, "patch"), canonicalRoot, resolvedRoot)); + } + PlannedStep planNext(JsonPatch patch) { return planNext(preparePatch(Objects.requireNonNull(patch, "patch"))); } diff --git a/src/main/java/blue/language/processor/WorkingDocument.java b/src/main/java/blue/language/processor/WorkingDocument.java index f515280..191a51a 100644 --- a/src/main/java/blue/language/processor/WorkingDocument.java +++ b/src/main/java/blue/language/processor/WorkingDocument.java @@ -2,6 +2,7 @@ import blue.language.conformance.ConformanceEngine; import blue.language.model.Node; +import blue.language.processor.model.FrozenJsonPatch; import blue.language.processor.model.JsonPatch; import blue.language.processor.util.PointerUtils; import blue.language.snapshot.FrozenNode; @@ -20,8 +21,13 @@ * type generalization, and Type Generalization Policy enforcement. It never * commits to the processor runtime, emits cascades, charges gas, or writes * processor-managed markers.

+ * + *

A working document owns transient snapshot-planning state and must be + * closed when the read-your-writes session is finished. A preview returned by + * this object has an independent handoff lease and remains valid after the + * working document itself is closed.

*/ -public final class WorkingDocument { +public final class WorkingDocument implements AutoCloseable { private static final DocumentProcessingRuntime.UpdateMaterializationMetrics NOOP_MATERIALIZATION_METRICS = new DocumentProcessingRuntime.UpdateMaterializationMetrics() { @@ -47,6 +53,7 @@ public void recordAfterNodeMaterialization() { private final ProcessingMetricsSink metrics; private ProcessingSnapshotManager workingSequenceManager; private ResolvedSnapshot snapshot; + private boolean closed; WorkingDocument(String originScope, FrozenNode canonicalRoot, @@ -99,23 +106,36 @@ public WorkingDocument applyPatch(JsonPatch patch) { } public WorkingDocument applyPatches(List patches) { - applyPatches(patches, false); + applyPatchInputs(PatchInput.mutableList(patches), false); return this; } public Preview previewAndApplyPatches(List patches) { - return applyPatches(patches, true); + return applyPatchInputs(PatchInput.mutableList(patches), true); + } + + public WorkingDocument applyFrozenPatch(FrozenJsonPatch patch) { + if (patch == null) { + return this; + } + return applyFrozenPatches(Collections.singletonList(patch)); } - private Preview applyPatches(List patches, boolean createHandoff) { + public WorkingDocument applyFrozenPatches(List patches) { + applyPatchInputs(PatchInput.frozenList(patches), false); + return this; + } + + public Preview previewAndApplyFrozenPatches(List patches) { + return applyPatchInputs(PatchInput.frozenList(patches), true); + } + + private Preview applyPatchInputs(List patches, boolean createHandoff) { + ensureOpen(); if (patches == null || patches.isEmpty()) { return Preview.empty(originScope); } - List copiedPatches = new ArrayList<>(patches.size()); - for (JsonPatch patch : patches) { - copiedPatches.add(ImmutableJsonPatch.copy(patch)); - } - List previews = new ArrayList<>(copiedPatches.size()); + List previews = new ArrayList<>(patches.size()); ProcessingSnapshotManager sequenceManager = workingSequenceManager(); ConformanceEngine sequenceConformanceEngine = sequenceManager != null ? sequenceManager.transientConformanceEngine(conformanceEngine) @@ -131,7 +151,7 @@ private Preview applyPatches(List patches, boolean createHandoff) { NOOP_MATERIALIZATION_METRICS, metrics); try { - for (JsonPatch patch : copiedPatches) { + for (PatchInput patch : patches) { SequentialPatchPlanningSession.PlannedStep step = planningSession.planNext(patch); previews.add(PatchPreview.from(step)); @@ -141,21 +161,45 @@ private Preview applyPatches(List patches, boolean createHandoff) { sequenceManager.retainTransientState(canonicalRoot, resolvedRoot); } throw ex; + } finally { + planningSession.close(); } canonicalRoot = planningSession.canonicalRoot(); resolvedRoot = planningSession.resolvedRoot(); snapshot = null; - ProcessingSnapshotManager handoff = createHandoff && sequenceManager != null - ? sequenceManager.forkTransientSequence() - : null; - if (sequenceManager != null) { - sequenceManager.retainTransientState(canonicalRoot, resolvedRoot); + ProcessingSnapshotManager handoff = null; + try { + handoff = createHandoff && sequenceManager != null + ? sequenceManager.forkTransientSequence() + : null; + if (sequenceManager != null) { + sequenceManager.retainTransientState(canonicalRoot, resolvedRoot); + } + return new Preview(originScope, previews, handoff); + } catch (RuntimeException | Error ex) { + releaseAfterFailedHandoff(handoff, ex); + throw ex; + } + } + + private static void releaseAfterFailedHandoff(ProcessingSnapshotManager handoff, + Throwable primaryFailure) { + if (handoff == null) { + return; + } + try { + handoff.releaseTransientState(); + } catch (RuntimeException | Error cleanupFailure) { + if (primaryFailure != cleanupFailure) { + primaryFailure.addSuppressed(cleanupFailure); + } } - return new Preview(originScope, previews, handoff); } private ProcessingSnapshotManager workingSequenceManager() { + ensureOpen(); if (workingSequenceManager != null && !workingSequenceManager.isTransientStateCurrent()) { + workingSequenceManager.releaseTransientState(); workingSequenceManager = null; } if (workingSequenceManager == null && snapshotManager != null) { @@ -184,6 +228,7 @@ public Node commitToNode() { } public ResolvedSnapshot commitSnapshot() { + ensureOpen(); ResolvedSnapshot current = snapshot(); if (snapshotManager == null) { snapshot = current; @@ -203,6 +248,25 @@ public ResolvedSnapshot commitSnapshot() { return snapshot; } + @Override + public void close() { + if (closed) { + return; + } + closed = true; + ProcessingSnapshotManager manager = workingSequenceManager; + workingSequenceManager = null; + if (manager != null) { + manager.releaseTransientState(); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Working document is closed"); + } + } + /** * Returns true when this preview had to freeze a materialized runtime tree * because no processor snapshot was available at creation time. @@ -211,11 +275,12 @@ public boolean usedMaterializedFallback() { return materializedFallback; } - public static final class Preview { + public static final class Preview implements AutoCloseable { private final String originScope; private final List patches; - private final ProcessingSnapshotManager resolutionScope; + private ProcessingSnapshotManager resolutionScope; private ProcessingSnapshotManager sequenceSnapshotManager; + private boolean closed; private Preview(String originScope, List patches, @@ -253,11 +318,20 @@ void discardFrom(int index) { patches.set(current, null); } if (index <= 0) { + ProcessingSnapshotManager manager = sequenceSnapshotManager; sequenceSnapshotManager = null; + resolutionScope = null; + closed = true; + if (manager != null) { + manager.releaseTransientState(); + } } } ProcessingSnapshotManager takeSequenceSnapshotManager() { + if (closed) { + return null; + } ProcessingSnapshotManager retained = sequenceSnapshotManager; sequenceSnapshotManager = null; return retained; @@ -267,6 +341,11 @@ boolean isResolutionScopeCurrent() { return resolutionScope == null || resolutionScope.isTransientStateCurrent(); } + + @Override + public void close() { + discardFrom(0); + } } static final class PatchPreview { @@ -329,6 +408,15 @@ boolean matches(JsonPatch candidate) { ImmutableJsonPatch.from(candidate, baseCanonical, baseResolved)); } + boolean matches(PatchInput candidate) { + if (candidate == null) { + return false; + } + ImmutableJsonPatch.PreparationContext preparation = + ImmutableJsonPatch.preparationContext(ProcessingMetricsSink.NOOP); + return patch.matches(candidate.prepare(preparation, baseCanonical, baseResolved)); + } + boolean matches(ImmutableJsonPatch candidate) { return patch.matches(candidate); } diff --git a/src/main/java/blue/language/processor/model/FrozenJsonPatch.java b/src/main/java/blue/language/processor/model/FrozenJsonPatch.java new file mode 100644 index 0000000..f85c5a6 --- /dev/null +++ b/src/main/java/blue/language/processor/model/FrozenJsonPatch.java @@ -0,0 +1,196 @@ +package blue.language.processor.model; + +import blue.language.model.Node; +import blue.language.processor.util.NodeCanonicalizer; +import blue.language.snapshot.FrozenNode; +import blue.language.utils.ParsedJsonPointer; + +import java.util.Objects; + +/** + * Immutable authored JSON patch whose value is already in canonical frozen form. + * + *

This type is the allocation-free handoff for callers that already own a + * {@link FrozenNode}. Values must be canonical authored values; resolved document + * views are deliberately rejected because their inherited fields are ambiguous + * at a patch boundary. The original path spelling is retained for diagnostics, + * while its immutable parsed form is constructed exactly once.

+ */ +public final class FrozenJsonPatch { + + private final JsonPatch.Op op; + private final String authoredPath; + private final ParsedJsonPointer parsedPath; + private final FrozenNode value; + private final long authoredCanonicalSizeBytes; + private volatile String valueBlueId; + private volatile FrozenNode.ResolvedStructuralKey valueStructuralKey; + + private FrozenJsonPatch(JsonPatch.Op op, + String path, + FrozenNode value, + long authoredCanonicalSizeBytes) { + this.op = Objects.requireNonNull(op, "op"); + this.authoredPath = Objects.requireNonNull(path, "path"); + this.parsedPath = ParsedJsonPointer.parse(path); + if (op == JsonPatch.Op.REMOVE) { + this.value = null; + this.authoredCanonicalSizeBytes = 0L; + } else { + FrozenNode checked = Objects.requireNonNull(value, "value"); + if (!checked.isStrictCanonical()) { + throw new IllegalArgumentException( + "Frozen patch values must be authored canonical values, not resolved document views"); + } + this.value = checked; + if (authoredCanonicalSizeBytes < 0L) { + throw new IllegalArgumentException( + "authoredCanonicalSizeBytes must be non-negative"); + } + this.authoredCanonicalSizeBytes = authoredCanonicalSizeBytes; + } + } + + public static FrozenJsonPatch add(String path, FrozenNode value) { + FrozenNode checked = Objects.requireNonNull(value, "value"); + return new FrozenJsonPatch(JsonPatch.Op.ADD, path, checked, + NodeCanonicalizer.canonicalFrozenSize(checked)); + } + + public static FrozenJsonPatch replace(String path, FrozenNode value) { + FrozenNode checked = Objects.requireNonNull(value, "value"); + return new FrozenJsonPatch(JsonPatch.Op.REPLACE, path, checked, + NodeCanonicalizer.canonicalFrozenSize(checked)); + } + + public static FrozenJsonPatch remove(String path) { + return new FrozenJsonPatch(JsonPatch.Op.REMOVE, path, null, 0L); + } + + /** + * Takes an immutable canonical snapshot of a legacy mutable patch value. + */ + public static FrozenJsonPatch from(JsonPatch patch) { + JsonPatch checked = Objects.requireNonNull(patch, "patch"); + switch (checked.getOp()) { + case ADD: + return freezeMutable(JsonPatch.Op.ADD, checked.getPath(), checked.getVal()); + case REPLACE: + return freezeMutable(JsonPatch.Op.REPLACE, checked.getPath(), checked.getVal()); + case REMOVE: + return remove(checked.getPath()); + default: + throw new IllegalStateException("Unsupported patch op: " + checked.getOp()); + } + } + + private static FrozenNode freeze(Node value) { + return FrozenNode.fromNode(Objects.requireNonNull(value, "value")); + } + + private static FrozenJsonPatch freezeMutable(JsonPatch.Op op, String path, Node value) { + Node authored = Objects.requireNonNull(value, "value").clone(); + return new FrozenJsonPatch(op, + path, + freeze(authored), + NodeCanonicalizer.canonicalSize(authored)); + } + + public JsonPatch.Op getOp() { + return op; + } + + /** Returns the path exactly as authored by the caller. */ + public String getPath() { + return authoredPath; + } + + /** Returns the immutable authored value, or {@code null} for remove. */ + public FrozenNode getValue() { + return value; + } + + /** Compatibility-style alias matching {@link JsonPatch#getVal()}. */ + public FrozenNode getVal() { + return value; + } + + /** Exact legacy authored payload size retained for gas-equivalent handoff. */ + public long getAuthoredCanonicalSizeBytes() { + return authoredCanonicalSizeBytes; + } + + /** + * Returns the immutable parsed path retained by this patch. + * Its decoded segment list is unmodifiable. + */ + public ParsedJsonPointer parsedPath() { + return parsedPath; + } + + public ParsedJsonPointer getParsedPath() { + return parsedPath; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof FrozenJsonPatch)) { + return false; + } + FrozenJsonPatch that = (FrozenJsonPatch) other; + return op == that.op + && authoredPath.equals(that.authoredPath) + && authoredCanonicalSizeBytes == that.authoredCanonicalSizeBytes + && (value == that.value + || Objects.equals(semanticValueBlueId(), that.semanticValueBlueId()) + && Objects.equals(exactValueKey(), that.exactValueKey())); + } + + @Override + public int hashCode() { + return Objects.hash(op, authoredPath, authoredCanonicalSizeBytes, + semanticValueBlueId(), exactValueKey()); + } + + private String semanticValueBlueId() { + if (value == null) { + return null; + } + String identity = valueBlueId; + if (identity == null) { + synchronized (this) { + identity = valueBlueId; + if (identity == null) { + identity = value.blueId(); + valueBlueId = identity; + } + } + } + return identity; + } + + private FrozenNode.ResolvedStructuralKey exactValueKey() { + if (value == null) { + return null; + } + FrozenNode.ResolvedStructuralKey key = valueStructuralKey; + if (key == null) { + synchronized (this) { + key = valueStructuralKey; + if (key == null) { + key = value.resolvedStructuralKey(); + valueStructuralKey = key; + } + } + } + return key; + } + + @Override + public String toString() { + return "FrozenJsonPatch{" + op + " " + authoredPath + '}'; + } +} diff --git a/src/main/java/blue/language/processor/util/NodeCanonicalizer.java b/src/main/java/blue/language/processor/util/NodeCanonicalizer.java index 4aa5145..6167df8 100644 --- a/src/main/java/blue/language/processor/util/NodeCanonicalizer.java +++ b/src/main/java/blue/language/processor/util/NodeCanonicalizer.java @@ -1,6 +1,8 @@ package blue.language.processor.util; import blue.language.model.Node; +import blue.language.snapshot.FrozenCanonicalWriter; +import blue.language.snapshot.FrozenNode; import blue.language.utils.NodeToMapListOrValue; import blue.language.utils.UncheckedObjectMapper; import org.erdtman.jcs.JsonCanonicalizer; @@ -19,7 +21,21 @@ public static long canonicalSize(Node node) { if (node == null) { return 0L; } - Object canonical = NodeToMapListOrValue.get(node); + return canonicalSize(NodeToMapListOrValue.get(node)); + } + + /** Calculates the exact authored canonical size without materializing a mutable node. */ + public static long canonicalFrozenSize(FrozenNode node) { + if (node == null) { + return 0L; + } + if (!node.isStrictCanonical()) { + throw new IllegalArgumentException("Gas accounting requires an authored canonical frozen value"); + } + return FrozenCanonicalWriter.officialCanonicalSize(node); + } + + private static long canonicalSize(Object canonical) { try { String json = UncheckedObjectMapper.JSON_MAPPER.writeValueAsString(canonical); String canonicalJson = new JsonCanonicalizer(json).getEncodedString(); diff --git a/src/main/java/blue/language/snapshot/FrozenCanonicalDigester.java b/src/main/java/blue/language/snapshot/FrozenCanonicalDigester.java new file mode 100644 index 0000000..67fc5f6 --- /dev/null +++ b/src/main/java/blue/language/snapshot/FrozenCanonicalDigester.java @@ -0,0 +1,735 @@ +package blue.language.snapshot; + +import blue.language.model.Node; +import blue.language.model.Schema; +import blue.language.utils.Base58; +import blue.language.utils.BlueIdCalculator; +import blue.language.utils.BlueIds; +import blue.language.utils.BlueNumbers; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +import static blue.language.utils.Properties.*; + +/** + * Exact frozen-native BlueId calculator. It preserves the existing recursive + * BlueId protocol while streaming every JCS hash input into SHA-256. + */ +final class FrozenCanonicalDigester { + + private static final ThreadLocal SHA_256 = new ThreadLocal() { + @Override + protected MessageDigest initialValue() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException exception) { + throw new AssertionError("SHA-256 is required", exception); + } + } + }; + + private static final Observer NO_OBSERVER = new Observer() { + }; + private static final Comparator FIELD_ORDER = new Comparator() { + @Override + public int compare(HashField left, HashField right) { + return left.key.compareTo(right.key); + } + }; + + private FrozenCanonicalDigester() { + } + + /** Optional per-call instrumentation; implementations must be thread-safe if shared. */ + interface Observer { + default void canonicalDigest(long canonicalBytes) { + } + + default void genericFallback() { + } + } + + static String calculateBlueId(FrozenNode node) { + return calculateBlueId(node, NO_OBSERVER); + } + + static String calculateBlueId(FrozenNode node, Observer observer) { + Observer actualObserver = observer != null ? observer : NO_OBSERVER; + if (!isDirectlySupported(node)) { + actualObserver.genericFallback(); + return genericNodeBlueId(node); + } + Context context = node.isListElementContext() ? Context.LIST_ELEMENT : Context.ROOT; + int listIndex = node.isListElementContext() ? 0 : -1; + try { + return calculateValidatedNode(node, context, listIndex, actualObserver); + } catch (FrozenCanonicalWriter.UnsupportedCanonicalValueException exception) { + actualObserver.genericFallback(); + return genericNodeBlueId(node); + } catch (IllegalArgumentException exception) { + actualObserver.genericFallback(); + return genericNodeBlueId(node); + } + } + + static String calculateBlueId(List nodes) { + return calculateBlueId(nodes, NO_OBSERVER); + } + + static String calculateBlueId(List nodes, Observer observer) { + List source = nodes == null ? Collections.emptyList() : nodes; + Observer actualObserver = observer != null ? observer : NO_OBSERVER; + if (!isDirectlySupportedList(source)) { + actualObserver.genericFallback(); + return genericListBlueId(source); + } + try { + return calculateValidatedList(source, actualObserver); + } catch (FrozenCanonicalWriter.UnsupportedCanonicalValueException exception) { + actualObserver.genericFallback(); + return genericListBlueId(source); + } catch (IllegalArgumentException exception) { + actualObserver.genericFallback(); + return genericListBlueId(source); + } + } + + /** Explicit compatibility oracle retained for differential testing. */ + static String calculateGenericOracle(FrozenNode node) { + return genericNodeBlueId(node); + } + + private enum Context { + ROOT, + OBJECT_FIELD, + LIST_ELEMENT, + METADATA + } + + private static String calculateValidatedNode(FrozenNode node, + Context context, + int listIndex, + Observer observer) { + if (hasReservedPropertyCollision(node)) { + // FrozenNodeToBlueIdInput writes authored fields first and arbitrary + // properties last. Reserved property names can therefore replace a + // field before BlueIdCalculator's empty-map cleaning, and list + // controls such as $previous have context-sensitive semantics. + // These builder-only representations are uncommon enough that the + // full compatibility oracle is the safer path. + throw new FrozenCanonicalWriter.UnsupportedCanonicalValueException(FrozenNode.class); + } + if (context == Context.LIST_ELEMENT && isEmptyPlaceholder(node)) { + String marker = hashScalar(Boolean.TRUE, observer); + return hashFields(Collections.singletonList(HashField.reference(LIST_CONTROL_EMPTY, marker)), observer); + } + if (node.isReferenceOnly()) { + return node.getReferenceBlueId(); + } + if (node.getPreviousBlueId() != null) { + // A previous anchor is consumed by calculateValidatedList and does + // not have an independently observable element identity there. + return hashFields(Collections.singletonList( + HashField.reference(LIST_CONTROL_PREVIOUS, node.getPreviousBlueId())), observer); + } + if (node.getItems() != null && isPayloadOnlyList(node)) { + return calculateValidatedList(node.getItems(), observer); + } + + List fields = new ArrayList<>(); + addRaw(fields, OBJECT_NAME, node.getName()); + addRaw(fields, OBJECT_DESCRIPTION, node.getDescription()); + + String valueTypeBlueId = null; + if (node.frozenValue() != null && node.getType() == null) { + valueTypeBlueId = inferTypeBlueId(node.frozenValue()); + if (valueTypeBlueId != null) { + addReference(fields, OBJECT_TYPE, valueTypeBlueId); + } + } else if (node.getType() != null) { + valueTypeBlueId = node.getType().getReferenceBlueId(); + addNodeReference(fields, OBJECT_TYPE, node.getType()); + } + addNodeReference(fields, OBJECT_ITEM_TYPE, node.getItemType()); + addNodeReference(fields, OBJECT_KEY_TYPE, node.getKeyType()); + addNodeReference(fields, OBJECT_VALUE_TYPE, node.getValueType()); + if (node.getMergePolicy() != null) { + addReference(fields, OBJECT_MERGE_POLICY, hashScalar(node.getMergePolicy(), observer)); + } + if (node.frozenValue() != null) { + addRaw(fields, OBJECT_VALUE, handleValue(node.frozenValue(), valueTypeBlueId)); + } + if (node.getItems() != null) { + addReference(fields, OBJECT_ITEMS, calculateValidatedList(node.getItems(), observer)); + } + if (node.frozenSchemaView() != null) { + String schemaBlueId = calculateSchemaBlueId(node.frozenSchemaView(), observer); + addReference(fields, OBJECT_SCHEMA, schemaBlueId); + } + addNodeReference(fields, OBJECT_CONTRACTS, node.getContracts()); + + if (node.getProperties() != null) { + for (Map.Entry entry : node.getProperties().entrySet()) { + String key = entry.getKey(); + FrozenNode child = entry.getValue(); + if (isOfficialInputKey(key)) { + remove(fields, key); + } + if (isRawMapKey(key)) { + // This representation is legal but unusual: BlueIdCalculator + // treats these three map keys as raw JCS values. Preserve it + // via the full oracle rather than inventing a composition. + throw new FrozenCanonicalWriter.UnsupportedCanonicalValueException(FrozenNode.class); + } + if (!inputCleansToEmptyMap(child)) { + addReference(fields, key, child.blueId()); + } + } + } + return hashFields(fields, observer); + } + + private static String calculateValidatedList(List nodes, Observer observer) { + for (int index = 0; index < nodes.size(); index++) { + FrozenNode node = nodes.get(index); + if (node.getPreviousBlueId() != null && index != 0 + || node.getProperties() != null + && node.getProperties().containsKey(LIST_CONTROL_PREVIOUS) + || inputCleansToEmptyMap(node)) { + // Only the whole-list oracle can preserve both the positional + // control/placeholder semantics and the original nested + // diagnostic path. + throw new FrozenCanonicalWriter.UnsupportedCanonicalValueException(FrozenNode.class); + } + } + String accumulator = hashListEmpty(observer); + int start = 0; + if (!nodes.isEmpty() && nodes.get(0).isPreviousOnly()) { + accumulator = nodes.get(0).getPreviousBlueId(); + start = 1; + } + for (int index = start; index < nodes.size(); index++) { + FrozenNode node = nodes.get(index); + String elementBlueId = isEmptyPlaceholder(node) + ? calculateValidatedNode(node, Context.LIST_ELEMENT, index, observer) + : node.blueId(); + accumulator = hashListCons(elementBlueId, accumulator, observer); + } + return accumulator; + } + + private static String calculateSchemaBlueId(Schema schema, Observer observer) { + List fields = new ArrayList<>(); + addSchemaScalar(fields, "required", + schema.getRequired() == null ? null : schema.getRequiredValue(), observer); + addSchemaScalar(fields, "minLength", schemaValue(schema.getMinLength()), observer); + addSchemaScalar(fields, "maxLength", schemaValue(schema.getMaxLength()), observer); + addSchemaNumeric(fields, "minimum", schema.getMinimum(), observer); + addSchemaNumeric(fields, "maximum", schema.getMaximum(), observer); + addSchemaNumeric(fields, "exclusiveMinimum", schema.getExclusiveMinimum(), observer); + addSchemaNumeric(fields, "exclusiveMaximum", schema.getExclusiveMaximum(), observer); + addSchemaNumeric(fields, "multipleOf", schema.getMultipleOf(), observer); + addSchemaScalar(fields, "minItems", schemaValue(schema.getMinItems()), observer); + addSchemaScalar(fields, "maxItems", schemaValue(schema.getMaxItems()), observer); + addSchemaScalar(fields, "uniqueItems", + schema.getUniqueItems() == null ? null : schema.getUniqueItemsValue(), observer); + addSchemaScalar(fields, "minFields", schemaValue(schema.getMinFields()), observer); + addSchemaScalar(fields, "maxFields", schemaValue(schema.getMaxFields()), observer); + if (schema.getEnum() != null) { + String accumulator = hashListEmpty(observer); + for (Node value : schema.getEnum()) { + String elementBlueId; + if (FrozenCanonicalWriter.isPlainScalar(value)) { + elementBlueId = hashScalar(value.getValue(), observer); + } else { + FrozenNode frozen = FrozenNode.fromNode(value); + if (inputCleansToEmptyMap(frozen)) { + throw new FrozenCanonicalWriter.UnsupportedCanonicalValueException(FrozenNode.class); + } + elementBlueId = frozen.blueId(); + } + accumulator = hashListCons(elementBlueId, accumulator, observer); + } + addReference(fields, "enum", accumulator); + } + return fields.isEmpty() ? null : hashFields(fields, observer); + } + + private static Object schemaValue(Node node) { + return node == null ? null : node.getValue(); + } + + private static void addSchemaScalar(List fields, + String key, + Object value, + Observer observer) { + if (value != null) addReference(fields, key, hashScalar(value, observer)); + } + + private static void addSchemaNumeric(List fields, + String key, + Node value, + Observer observer) { + if (value == null) return; + if (FrozenCanonicalWriter.isPlainScalar(value)) { + addReference(fields, key, hashScalar(value.getValue(), observer)); + return; + } + FrozenNode frozen = FrozenNode.fromNode(value); + if (!inputCleansToEmptyMap(frozen)) { + addReference(fields, key, frozen.blueId()); + } + } + + private static String hashScalar(final Object value, Observer observer) { + return hash(new WriteAction() { + @Override + public void write(FrozenCanonicalWriter.CanonicalByteSink sink) { + FrozenCanonicalWriter.writeCanonicalValue(value, sink); + } + }, observer); + } + + private static String hashFields(List source, Observer observer) { + final HashField[] fields = source.toArray(new HashField[0]); + Arrays.sort(fields, FIELD_ORDER); + return hash(new WriteAction() { + @Override + public void write(FrozenCanonicalWriter.CanonicalByteSink sink) { + sink.writeByte('{'); + for (int index = 0; index < fields.length; index++) { + if (index > 0) sink.writeByte(','); + writeString(fields[index].key, sink); + sink.writeByte(':'); + if (fields[index].reference) { + writeReference(fields[index].value, sink); + } else { + FrozenCanonicalWriter.writeCanonicalValue(fields[index].rawValue, sink); + } + } + sink.writeByte('}'); + } + }, observer); + } + + private static String hashListEmpty(Observer observer) { + return hash(new WriteAction() { + @Override + public void write(FrozenCanonicalWriter.CanonicalByteSink sink) { + sink.writeByte('{'); + writeString("$list", sink); + sink.writeByte(':'); + writeString("empty", sink); + sink.writeByte('}'); + } + }, observer); + } + + private static String hashListCons(final String element, + final String previous, + Observer observer) { + return hash(new WriteAction() { + @Override + public void write(FrozenCanonicalWriter.CanonicalByteSink sink) { + sink.writeByte('{'); + writeString("$listCons", sink); + sink.writeByte(':'); + sink.writeByte('{'); + writeString("elem", sink); + sink.writeByte(':'); + writeReference(element, sink); + sink.writeByte(','); + writeString("prev", sink); + sink.writeByte(':'); + writeReference(previous, sink); + sink.writeByte('}'); + sink.writeByte('}'); + } + }, observer); + } + + private interface WriteAction { + void write(FrozenCanonicalWriter.CanonicalByteSink sink); + } + + private static String hash(WriteAction action, Observer observer) { + MessageDigest digest = SHA_256.get(); + digest.reset(); + DigestSink sink = new DigestSink(digest); + try { + action.write(sink); + String result = Base58.encode(digest.digest()); + observer.canonicalDigest(sink.bytes); + return result; + } finally { + digest.reset(); + } + } + + private static final class DigestSink implements FrozenCanonicalWriter.CanonicalByteSink { + private final MessageDigest digest; + private long bytes; + + private DigestSink(MessageDigest digest) { + this.digest = digest; + } + + @Override + public void writeByte(int value) { + digest.update((byte) value); + bytes++; + } + + @Override + public void write(byte[] values, int offset, int length) { + digest.update(values, offset, length); + bytes += length; + } + } + + private static final class HashField { + private final String key; + private final boolean reference; + private final String value; + private final Object rawValue; + + private HashField(String key, boolean reference, String value, Object rawValue) { + this.key = key; + this.reference = reference; + this.value = value; + this.rawValue = rawValue; + } + + private static HashField reference(String key, String value) { + return new HashField(key, true, value, null); + } + + private static HashField raw(String key, Object value) { + return new HashField(key, false, null, value); + } + } + + private static void addRaw(List fields, String key, Object value) { + if (value != null) fields.add(HashField.raw(key, value)); + } + + private static void addReference(List fields, String key, String blueId) { + if (blueId != null) fields.add(HashField.reference(key, blueId)); + } + + private static void addNodeReference(List fields, String key, FrozenNode node) { + if (node != null && !inputCleansToEmptyMap(node)) { + fields.add(HashField.reference(key, node.blueId())); + } + } + + private static void remove(List fields, String key) { + for (int index = fields.size() - 1; index >= 0; index--) { + if (fields.get(index).key.equals(key)) fields.remove(index); + } + } + + private static boolean isRawMapKey(String key) { + return OBJECT_NAME.equals(key) || OBJECT_VALUE.equals(key) || OBJECT_DESCRIPTION.equals(key); + } + + private static boolean isOfficialInputKey(String key) { + return isRawMapKey(key) || OBJECT_TYPE.equals(key) || OBJECT_ITEM_TYPE.equals(key) + || OBJECT_KEY_TYPE.equals(key) || OBJECT_VALUE_TYPE.equals(key) + || OBJECT_MERGE_POLICY.equals(key) || OBJECT_ITEMS.equals(key) + || OBJECT_SCHEMA.equals(key) || OBJECT_CONTRACTS.equals(key); + } + + private static String genericNodeBlueId(FrozenNode node) { + if (node == null) { + return BlueIdCalculator.INSTANCE.calculate(FrozenNodeToBlueIdInput.get(null)); + } + if (node.isStrictCanonical() && node.isStrictBlueIdValidation()) { + return BlueIdCalculator.INSTANCE.calculate(FrozenNodeToBlueIdInput.get(node)); + } + return BlueIdCalculator.calculateUncheckedBlueId(node.toNode()); + } + + private static String genericListBlueId(List nodes) { + List objects = new ArrayList<>(nodes.size()); + for (int index = 0; index < nodes.size(); index++) { + objects.add(FrozenNodeToBlueIdInput.getListElement(nodes.get(index), index)); + } + return BlueIdCalculator.INSTANCE.calculate(objects); + } + + private static boolean isDirectlySupported(FrozenNode node) { + try { + Context context = node != null && node.isListElementContext() ? Context.LIST_ELEMENT : Context.ROOT; + int index = node != null && node.isListElementContext() ? 0 : -1; + return node != null + && node.isStrictCanonical() + && node.isStrictBlueIdValidation() + && validate(node, context, index, false); + } catch (RuntimeException exception) { + return false; + } + } + + private static boolean isDirectlySupportedList(List nodes) { + try { + for (int index = 0; index < nodes.size(); index++) { + FrozenNode node = nodes.get(index); + if (node == null || !node.isStrictCanonical() || !node.isStrictBlueIdValidation() + || !validate(node, Context.LIST_ELEMENT, index, false) + || inputCleansToEmptyMap(node)) { + return false; + } + } + return true; + } catch (RuntimeException exception) { + return false; + } + } + + private static boolean validate(FrozenNode node, + Context context, + int listIndex, + boolean typePosition) { + if (node == null || context == Context.METADATA && typePosition && node.isInlineValue() + || node.getBlue() != null || node.getPosition() != null + || node.getProperties() != null && node.getProperties().containsKey(LIST_CONTROL_REPLACE)) { + return false; + } + if (context == Context.LIST_ELEMENT) { + if (node.isEmptyNode() + || node.getProperties() != null && node.getProperties().containsKey(LIST_CONTROL_EMPTY) + && !isEmptyPlaceholder(node) + || node.getPreviousBlueId() != null && listIndex != 0) { + return false; + } + } else if (node.getPreviousBlueId() != null) { + return false; + } + int payloadKinds = (node.frozenValue() != null ? 1 : 0) + + (node.getItems() != null ? 1 : 0) + + (node.getProperties() != null && !node.getProperties().isEmpty() ? 1 : 0); + if (payloadKinds > 1 || node.getReferenceBlueId() != null && !node.isReferenceOnly() + || node.getPreviousBlueId() != null && !node.isPreviousOnly()) { + return false; + } + if (node.isReferenceOnly() + && !BlueIds.isPotentialBlueId(node.getReferenceBlueId())) { + return false; + } + if (node.getPreviousBlueId() != null + && (node.getPreviousBlueId().indexOf('#') >= 0 + || !BlueIds.isPotentialBlueId(node.getPreviousBlueId()))) { + return false; + } + if (node.frozenValue() != null) { + String type = node.getType() != null + ? node.getType().getReferenceBlueId() + : inferTypeBlueId(node.frozenValue()); + Object canonical = handleValue(node.frozenValue(), type); + // Raw containers pass through the legacy recursive Blue cleaning + // rules (empty/null removal and list controls). The direct writer + // deliberately handles only scalar values until it can prove that + // full cleaning equivalence, so containers use the generic oracle. + if (canonical instanceof Map || canonical instanceof List + || canonical != null && canonical.getClass().isArray()) return false; + if (!FrozenCanonicalWriter.supportsCanonicalValue(canonical)) return false; + } + if (node.getType() != null && node.getType().isInlineValue() + || node.getItemType() != null && node.getItemType().isInlineValue() + || node.getKeyType() != null && node.getKeyType().isInlineValue() + || node.getValueType() != null && node.getValueType().isInlineValue()) { + return false; + } + if (node.getProperties() != null) { + if (hasReservedPropertyCollision(node)) { + return false; + } + for (Map.Entry entry : node.getProperties().entrySet()) { + if (isRawMapKey(entry.getKey())) { + return false; + } + } + } + if (node.frozenSchemaView() != null + && !hasDirectSchemaValidationParity(node.frozenSchemaView())) { + return false; + } + return true; + } + + private static boolean hasDirectSchemaValidationParity(Schema schema) { + return isPlainSchemaScalarOrNull(schema.getRequired()) + && isPlainSchemaScalarOrNull(schema.getMinLength()) + && isPlainSchemaScalarOrNull(schema.getMaxLength()) + && isPlainSchemaScalarOrNull(schema.getMinItems()) + && isPlainSchemaScalarOrNull(schema.getMaxItems()) + && isPlainSchemaScalarOrNull(schema.getUniqueItems()) + && isPlainSchemaScalarOrNull(schema.getMinFields()) + && isPlainSchemaScalarOrNull(schema.getMaxFields()) + && hasCompatiblePlainSchemaValue(schema.getMinimum()) + && hasCompatiblePlainSchemaValue(schema.getMaximum()) + && hasCompatiblePlainSchemaValue(schema.getExclusiveMinimum()) + && hasCompatiblePlainSchemaValue(schema.getExclusiveMaximum()) + && hasCompatiblePlainSchemaValue(schema.getMultipleOf()) + && hasCompatibleSchemaEnum(schema.getEnum()); + } + + private static boolean isPlainSchemaScalarOrNull(Node node) { + return node == null || FrozenCanonicalWriter.isPlainScalar(node) + && !isRawContainer(node.getValue()); + } + + private static boolean hasCompatiblePlainSchemaValue(Node node) { + return node == null || !FrozenCanonicalWriter.isPlainScalar(node) + || !isRawContainer(node.getValue()); + } + + private static boolean hasCompatibleSchemaEnum(List values) { + if (values == null) { + return true; + } + for (Node value : values) { + if (!hasCompatiblePlainSchemaValue(value)) { + return false; + } + } + return true; + } + + private static boolean isRawContainer(Object value) { + return value instanceof Map || value instanceof List + || value != null && value.getClass().isArray(); + } + + static boolean isPayloadOnlyList(FrozenNode node) { + return node.getItems() != null + && node.getName() == null && node.getDescription() == null + && node.getType() == null && node.getItemType() == null + && node.getKeyType() == null && node.getValueType() == null + && node.frozenValue() == null && node.getProperties() == null + && node.getContracts() == null && node.getReferenceBlueId() == null + && node.frozenSchemaView() == null && node.getMergePolicy() == null + && node.getPreviousBlueId() == null && node.getPosition() == null + && node.getBlue() == null; + } + + static boolean isEmptyPlaceholder(FrozenNode node) { + if (node == null || node.getProperties() == null || node.getProperties().size() != 1) return false; + FrozenNode marker = node.getProperties().get(LIST_CONTROL_EMPTY); + return marker != null && Boolean.TRUE.equals(marker.getValue()) + && marker.getName() == null && marker.getDescription() == null + && marker.getType() == null && marker.getItemType() == null + && marker.getKeyType() == null && marker.getValueType() == null + && marker.getItems() == null && marker.getProperties() == null + && marker.getContracts() == null && marker.getReferenceBlueId() == null + && marker.frozenSchemaView() == null && marker.getMergePolicy() == null + && marker.getPreviousBlueId() == null && marker.getPosition() == null + && marker.getBlue() == null && node.getName() == null + && node.getDescription() == null && node.getType() == null + && node.getItemType() == null && node.getKeyType() == null + && node.getValueType() == null && node.frozenValue() == null + && node.getItems() == null && node.getContracts() == null + && node.getReferenceBlueId() == null && node.frozenSchemaView() == null + && node.getMergePolicy() == null && node.getPreviousBlueId() == null + && node.getPosition() == null && node.getBlue() == null; + } + + private static boolean inputCleansToEmptyMap(FrozenNode node) { + if (node == null || node.isReferenceOnly() || node.getPreviousBlueId() != null + || isPayloadOnlyList(node)) return false; + if (hasReservedPropertyCollision(node)) { + throw new FrozenCanonicalWriter.UnsupportedCanonicalValueException(FrozenNode.class); + } + if (node.getName() != null || node.getDescription() != null || node.frozenValue() != null + || node.getItems() != null || node.getMergePolicy() != null) return false; + if (node.getType() != null && !inputCleansToEmptyMap(node.getType())) return false; + if (node.getItemType() != null && !inputCleansToEmptyMap(node.getItemType())) return false; + if (node.getKeyType() != null && !inputCleansToEmptyMap(node.getKeyType())) return false; + if (node.getValueType() != null && !inputCleansToEmptyMap(node.getValueType())) return false; + if (node.getContracts() != null && !inputCleansToEmptyMap(node.getContracts())) return false; + if (node.frozenSchemaView() != null && schemaHasInput(node.frozenSchemaView())) return false; + if (node.getProperties() != null) { + for (FrozenNode child : node.getProperties().values()) { + if (!inputCleansToEmptyMap(child)) return false; + } + } + return true; + } + + private static boolean schemaHasInput(Schema schema) { + return schema.getRequired() != null && schema.getRequiredValue() != null + || schemaValue(schema.getMinLength()) != null + || schemaValue(schema.getMaxLength()) != null + || schemaNumericHasInput(schema.getMinimum()) + || schemaNumericHasInput(schema.getMaximum()) + || schemaNumericHasInput(schema.getExclusiveMinimum()) + || schemaNumericHasInput(schema.getExclusiveMaximum()) + || schemaNumericHasInput(schema.getMultipleOf()) + || schemaValue(schema.getMinItems()) != null + || schemaValue(schema.getMaxItems()) != null + || schema.getUniqueItems() != null && schema.getUniqueItemsValue() != null + || schemaValue(schema.getMinFields()) != null + || schemaValue(schema.getMaxFields()) != null + || schema.getEnum() != null; + } + + private static boolean schemaNumericHasInput(Node value) { + if (value == null) return false; + if (FrozenCanonicalWriter.isPlainScalar(value)) return true; + return !inputCleansToEmptyMap(FrozenNode.fromNode(value)); + } + + private static boolean hasReservedPropertyCollision(FrozenNode node) { + if (node == null || node.getProperties() == null) return false; + for (String key : node.getProperties().keySet()) { + if (isOfficialInputKey(key) || OBJECT_BLUE_ID.equals(key) + || OBJECT_BLUE.equals(key) || LIST_CONTROL_POS.equals(key) + || LIST_CONTROL_PREVIOUS.equals(key) || LIST_CONTROL_REPLACE.equals(key)) { + return true; + } + } + return false; + } + + static Object handleValue(Object value, String valueTypeBlueId) { + if (DOUBLE_TYPE_BLUE_ID.equals(valueTypeBlueId)) return BlueNumbers.toCanonicalDoubleValue(value); + if (value instanceof BigInteger) { + BigInteger integer = (BigInteger) value; + if (integer.compareTo(BigInteger.valueOf(-9007199254740991L)) < 0 + || integer.compareTo(BigInteger.valueOf(9007199254740991L)) > 0) { + return integer.toString(); + } + } + return value; + } + + static String inferTypeBlueId(Object value) { + if (value instanceof String) return TEXT_TYPE_BLUE_ID; + if (value instanceof BigInteger) return INTEGER_TYPE_BLUE_ID; + if (value instanceof BigDecimal) return DOUBLE_TYPE_BLUE_ID; + if (value instanceof Boolean) return BOOLEAN_TYPE_BLUE_ID; + return null; + } + + private static void writeReference(String blueId, FrozenCanonicalWriter.CanonicalByteSink sink) { + sink.writeByte('{'); + writeString(OBJECT_BLUE_ID, sink); + sink.writeByte(':'); + writeString(blueId, sink); + sink.writeByte('}'); + } + + private static void writeString(String value, FrozenCanonicalWriter.CanonicalByteSink sink) { + FrozenCanonicalWriter.writeCanonicalValue(value, sink); + } +} diff --git a/src/main/java/blue/language/snapshot/FrozenCanonicalWriter.java b/src/main/java/blue/language/snapshot/FrozenCanonicalWriter.java new file mode 100644 index 0000000..9153337 --- /dev/null +++ b/src/main/java/blue/language/snapshot/FrozenCanonicalWriter.java @@ -0,0 +1,619 @@ +package blue.language.snapshot; + +import blue.language.model.Schema; +import blue.language.model.Node; +import blue.language.utils.UncheckedObjectMapper; +import org.erdtman.jcs.NumberToJSON; +import org.erdtman.jcs.JsonCanonicalizer; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import static blue.language.utils.Properties.*; + +/** + * Writes the exact JCS byte representation of a frozen node's direct BlueId + * input without first materializing that input as a complete map/list graph. + * + *

This class is deliberately stateless. A caller supplies the byte sink, so + * the normal identity path can feed a {@link java.security.MessageDigest} + * directly while tests can capture the bytes and compare them with the legacy + * JSON/JCS pipeline.

+ */ +public final class FrozenCanonicalWriter { + + private static final byte[] TRUE = ascii("true"); + private static final byte[] FALSE = ascii("false"); + private static final byte[] NULL = ascii("null"); + private static final BigInteger MIN_SAFE_INTEGER = BigInteger.valueOf(-9007199254740991L); + private static final BigInteger MAX_SAFE_INTEGER = BigInteger.valueOf(9007199254740991L); + + private FrozenCanonicalWriter() { + } + + /** Receives canonical bytes in encounter order. */ + interface CanonicalByteSink { + void writeByte(int value); + + void write(byte[] bytes, int offset, int length); + } + + /** + * Writes a validated strict frozen node. Validation is performed by the + * digester before this method is used on the production identity path. + */ + static void write(FrozenNode node, CanonicalByteSink sink) { + if (node == null) { + throw new IllegalArgumentException("BlueId input must not contain null nodes. Path: /"); + } + Context context = node.isListElementContext() ? Context.LIST_ELEMENT : Context.ROOT; + int listIndex = node.isListElementContext() ? 0 : -1; + writeNode(node, sink, context, listIndex, Mode.BLUE_ID_INPUT); + } + + /** Streams the JCS form of {@code NodeToMapListOrValue.OFFICIAL}. */ + static void writeOfficial(FrozenNode node, CanonicalByteSink sink) { + if (node == null) { + throw new IllegalArgumentException("node must not be null"); + } + writeNode(node, sink, Context.ROOT, -1, Mode.OFFICIAL); + } + + /** Exact byte count for the official authored representation used by gas. */ + public static long officialCanonicalSize(FrozenNode node) { + if (node == null) return 0L; + CountingSink sink = new CountingSink(); + writeOfficial(node, sink); + return sink.bytes; + } + + static void writeCanonicalValue(Object value, CanonicalByteSink sink) { + if (value == null) { + writeBytes(sink, NULL); + return; + } + if (value instanceof String) { + writeString((String) value, sink); + return; + } + if (value instanceof Character) { + writeString(String.valueOf(value), sink); + return; + } + if (value instanceof Boolean) { + writeBytes(sink, Boolean.TRUE.equals(value) ? TRUE : FALSE); + return; + } + if (value instanceof Enum) { + // Enum wire values may be customized by Jackson annotations. Keep the + // compatibility serializer as the source of truth instead of using name(). + writeLegacyCanonicalValue(value, sink); + return; + } + if (value instanceof BigInteger) { + BigInteger integer = (BigInteger) value; + if (integer.compareTo(MIN_SAFE_INTEGER) < 0 || integer.compareTo(MAX_SAFE_INTEGER) > 0) { + // UncheckedObjectMapper's registered BigInteger serializer uses + // a JSON string outside the interoperable integer range. + writeString(integer.toString(), sink); + } else { + writeNumber(integer.doubleValue(), sink); + } + return; + } + if (value instanceof BigDecimal) { + writeNumber(((BigDecimal) value).doubleValue(), sink); + return; + } + if (value instanceof Float) { + // Jackson preserves the source float's shortest decimal spelling. + // Widening the binary float directly would encode a different JSON number. + writeNumber(Double.parseDouble(Float.toString((Float) value)), sink); + return; + } + if (value instanceof Byte || value instanceof Short || value instanceof Integer + || value instanceof Long || value instanceof Double) { + writeNumber(((Number) value).doubleValue(), sink); + return; + } + if (value instanceof Map) { + writeMap((Map) value, sink); + return; + } + if (value instanceof List) { + writeList((List) value, sink); + return; + } + if (value instanceof byte[]) { + // Jackson's default byte-array serializer uses the standard padded + // Base64 alphabet and emits a JSON string, not a numeric array. + writeString(Base64.getEncoder().encodeToString((byte[]) value), sink); + return; + } + if (value instanceof char[]) { + // Jackson's char-array serializer likewise emits one JSON string. + writeString(new String((char[]) value), sink); + return; + } + if (value.getClass().isArray()) { + writeLegacyCanonicalValue(value, sink); + return; + } + throw new UnsupportedCanonicalValueException(value.getClass()); + } + + static boolean supportsCanonicalValue(Object value) { + if (value == null || value instanceof String || value instanceof Character + || value instanceof Boolean + || value instanceof BigInteger || value instanceof BigDecimal + || value instanceof Byte || value instanceof Short || value instanceof Integer + || value instanceof Long || value instanceof Float || value instanceof Double) { + return !(value instanceof Number) + || Double.isFinite(((Number) value).doubleValue()); + } + if (value instanceof Enum) { + // Direct serialization is compatible, but the optimized digester must + // retain the full generic-Jackson oracle for annotation-driven wire names. + return false; + } + if (value instanceof List) { + for (Object element : (List) value) { + if (!supportsCanonicalValue(element)) { + return false; + } + } + return true; + } + if (value instanceof Map) { + for (Map.Entry entry : ((Map) value).entrySet()) { + if (!(entry.getKey() instanceof String) || !supportsCanonicalValue(entry.getValue())) { + return false; + } + } + return true; + } + return false; + } + + private enum Context { + ROOT, + OBJECT_FIELD, + LIST_ELEMENT, + METADATA + } + + private enum Mode { + BLUE_ID_INPUT, + OFFICIAL + } + + private static void writeNode(FrozenNode node, + CanonicalByteSink sink, + Context context, + int listIndex, + Mode mode) { + if ((mode == Mode.OFFICIAL || context == Context.LIST_ELEMENT) + && FrozenCanonicalDigester.isEmptyPlaceholder(node)) { + sink.writeByte('{'); + writeString(LIST_CONTROL_EMPTY, sink); + sink.writeByte(':'); + writeBytes(sink, TRUE); + sink.writeByte('}'); + return; + } + + if (node.isReferenceOnly()) { + writeReference(node.getReferenceBlueId(), sink); + return; + } + + if (node.getPreviousBlueId() != null) { + sink.writeByte('{'); + writeString(LIST_CONTROL_PREVIOUS, sink); + sink.writeByte(':'); + writeReference(node.getPreviousBlueId(), sink); + sink.writeByte('}'); + return; + } + + List items = node.getItems(); + if (mode == Mode.BLUE_ID_INPUT && items != null + && FrozenCanonicalDigester.isPayloadOnlyList(node)) { + writeNodeList(items, sink, mode); + return; + } + + String[] keys = nodeInputKeys(node, mode); + sink.writeByte('{'); + boolean first = true; + String previous = null; + for (String key : keys) { + if (key.equals(previous)) { + continue; + } + previous = key; + if (!first) { + sink.writeByte(','); + } + first = false; + writeString(key, sink); + sink.writeByte(':'); + writeNodeField(node, key, sink, mode); + } + sink.writeByte('}'); + } + + private static void writeNodeField(FrozenNode node, + String key, + CanonicalByteSink sink, + Mode mode) { + Map properties = node.getProperties(); + if (properties != null && properties.containsKey(key)) { + writeNode(properties.get(key), sink, Context.OBJECT_FIELD, -1, mode); + return; + } + if (OBJECT_NAME.equals(key)) { + writeString(node.getName(), sink); + } else if (OBJECT_DESCRIPTION.equals(key)) { + writeString(node.getDescription(), sink); + } else if (OBJECT_TYPE.equals(key)) { + if (node.getType() != null) { + writeNode(node.getType(), sink, Context.METADATA, -1, mode); + } else { + writeReference(FrozenCanonicalDigester.inferTypeBlueId(node.frozenValue()), sink); + } + } else if (OBJECT_ITEM_TYPE.equals(key)) { + writeNode(node.getItemType(), sink, Context.METADATA, -1, mode); + } else if (OBJECT_KEY_TYPE.equals(key)) { + writeNode(node.getKeyType(), sink, Context.METADATA, -1, mode); + } else if (OBJECT_VALUE_TYPE.equals(key)) { + writeNode(node.getValueType(), sink, Context.METADATA, -1, mode); + } else if (OBJECT_MERGE_POLICY.equals(key)) { + writeString(node.getMergePolicy(), sink); + } else if (OBJECT_VALUE.equals(key)) { + String valueTypeBlueId = node.getType() != null + ? node.getType().getReferenceBlueId() + : FrozenCanonicalDigester.inferTypeBlueId(node.frozenValue()); + writeCanonicalValue(FrozenCanonicalDigester.handleValue( + node.frozenValue(), valueTypeBlueId), sink); + } else if (OBJECT_ITEMS.equals(key)) { + writeNodeList(node.getItems(), sink, mode); + } else if (OBJECT_SCHEMA.equals(key)) { + writeSchema(node.frozenSchemaView(), sink, mode); + } else if (OBJECT_CONTRACTS.equals(key)) { + writeNode(node.getContracts(), sink, Context.METADATA, -1, mode); + } else if (LIST_CONTROL_POS.equals(key)) { + writeCanonicalValue(BigInteger.valueOf(node.getPosition()), sink); + } else if (OBJECT_BLUE.equals(key)) { + writeNode(node.getBlue(), sink, Context.METADATA, -1, mode); + } else { + throw new IllegalStateException("Unknown frozen BlueId input field: " + key); + } + } + + private static String[] nodeInputKeys(FrozenNode node, Mode mode) { + List keys = new ArrayList<>(); + if (node.getName() != null) keys.add(OBJECT_NAME); + if (node.getDescription() != null) keys.add(OBJECT_DESCRIPTION); + if (node.getType() != null + || node.frozenValue() != null + && FrozenCanonicalDigester.inferTypeBlueId(node.frozenValue()) != null) { + keys.add(OBJECT_TYPE); + } + if (node.getItemType() != null) keys.add(OBJECT_ITEM_TYPE); + if (node.getKeyType() != null) keys.add(OBJECT_KEY_TYPE); + if (node.getValueType() != null) keys.add(OBJECT_VALUE_TYPE); + if (node.getMergePolicy() != null) keys.add(OBJECT_MERGE_POLICY); + if (node.frozenValue() != null) keys.add(OBJECT_VALUE); + if (node.getItems() != null) keys.add(OBJECT_ITEMS); + if (node.frozenSchemaView() != null) keys.add(OBJECT_SCHEMA); + if (node.getContracts() != null) keys.add(OBJECT_CONTRACTS); + if (mode == Mode.OFFICIAL && node.getPosition() != null) keys.add(LIST_CONTROL_POS); + if (mode == Mode.OFFICIAL && node.getBlue() != null) keys.add(OBJECT_BLUE); + if (node.getProperties() != null) keys.addAll(node.getProperties().keySet()); + String[] sorted = keys.toArray(new String[0]); + Arrays.sort(sorted); + return sorted; + } + + private static void writeNodeList(List nodes, + CanonicalByteSink sink, + Mode mode) { + sink.writeByte('['); + for (int index = 0; index < nodes.size(); index++) { + if (index > 0) { + sink.writeByte(','); + } + writeNode(nodes.get(index), sink, Context.LIST_ELEMENT, index, mode); + } + sink.writeByte(']'); + } + + private static void writeSchema(Schema schema, + CanonicalByteSink sink, + Mode mode) { + List keys = new ArrayList<>(); + if (schema.getRequired() != null && schema.getRequiredValue() != null) keys.add("required"); + if (schema.getMinLength() != null && schema.getMinLength().getValue() != null) keys.add("minLength"); + if (schema.getMaxLength() != null && schema.getMaxLength().getValue() != null) keys.add("maxLength"); + if (schema.getMinimum() != null) keys.add("minimum"); + if (schema.getMaximum() != null) keys.add("maximum"); + if (schema.getExclusiveMinimum() != null) keys.add("exclusiveMinimum"); + if (schema.getExclusiveMaximum() != null) keys.add("exclusiveMaximum"); + if (schema.getMultipleOf() != null) keys.add("multipleOf"); + if (schema.getMinItems() != null && schema.getMinItems().getValue() != null) keys.add("minItems"); + if (schema.getMaxItems() != null && schema.getMaxItems().getValue() != null) keys.add("maxItems"); + if (schema.getUniqueItems() != null && schema.getUniqueItemsValue() != null) keys.add("uniqueItems"); + if (schema.getMinFields() != null && schema.getMinFields().getValue() != null) keys.add("minFields"); + if (schema.getMaxFields() != null && schema.getMaxFields().getValue() != null) keys.add("maxFields"); + if (schema.getEnum() != null) keys.add("enum"); + String[] sorted = keys.toArray(new String[0]); + Arrays.sort(sorted); + + sink.writeByte('{'); + for (int index = 0; index < sorted.length; index++) { + if (index > 0) sink.writeByte(','); + String key = sorted[index]; + writeString(key, sink); + sink.writeByte(':'); + writeSchemaField(schema, key, sink, mode); + } + sink.writeByte('}'); + } + + private static void writeSchemaField(Schema schema, + String key, + CanonicalByteSink sink, + Mode mode) { + if ("required".equals(key)) { + writeCanonicalValue(schema.getRequiredValue(), sink); + } else if ("minLength".equals(key)) { + writeCanonicalValue(schema.getMinLength().getValue(), sink); + } else if ("maxLength".equals(key)) { + writeCanonicalValue(schema.getMaxLength().getValue(), sink); + } else if ("minimum".equals(key)) { + writeSchemaNumeric(schema.getMinimum(), sink, mode); + } else if ("maximum".equals(key)) { + writeSchemaNumeric(schema.getMaximum(), sink, mode); + } else if ("exclusiveMinimum".equals(key)) { + writeSchemaNumeric(schema.getExclusiveMinimum(), sink, mode); + } else if ("exclusiveMaximum".equals(key)) { + writeSchemaNumeric(schema.getExclusiveMaximum(), sink, mode); + } else if ("multipleOf".equals(key)) { + writeSchemaNumeric(schema.getMultipleOf(), sink, mode); + } else if ("minItems".equals(key)) { + writeCanonicalValue(schema.getMinItems().getValue(), sink); + } else if ("maxItems".equals(key)) { + writeCanonicalValue(schema.getMaxItems().getValue(), sink); + } else if ("uniqueItems".equals(key)) { + writeCanonicalValue(schema.getUniqueItemsValue(), sink); + } else if ("minFields".equals(key)) { + writeCanonicalValue(schema.getMinFields().getValue(), sink); + } else if ("maxFields".equals(key)) { + writeCanonicalValue(schema.getMaxFields().getValue(), sink); + } else if ("enum".equals(key)) { + sink.writeByte('['); + for (int index = 0; index < schema.getEnum().size(); index++) { + if (index > 0) sink.writeByte(','); + writeSchemaScalarOrNode(schema.getEnum().get(index), sink, mode); + } + sink.writeByte(']'); + } else { + throw new IllegalStateException("Unknown schema field: " + key); + } + } + + private static void writeSchemaNumeric(Node value, + CanonicalByteSink sink, + Mode mode) { + writeSchemaScalarOrNode(value, sink, mode); + } + + private static void writeSchemaScalarOrNode(Node value, + CanonicalByteSink sink, + Mode mode) { + if (isPlainScalar(value)) { + writeCanonicalValue(value.getValue(), sink); + } else { + writeNode(FrozenNode.fromNode(value), sink, Context.METADATA, -1, mode); + } + } + + static boolean isPlainScalar(Node node) { + return node != null && node.getValue() != null + && node.getName() == null && node.getDescription() == null + && node.getType() == null && node.getItemType() == null + && node.getKeyType() == null && node.getValueType() == null + && node.getItems() == null && node.getProperties() == null + && node.getContracts() == null && node.getBlueId() == null + && node.getSchema() == null && node.getMergePolicy() == null + && node.getPreviousBlueId() == null && node.getPosition() == null + && node.getBlue() == null; + } + + private static void writeReference(String blueId, CanonicalByteSink sink) { + sink.writeByte('{'); + writeString(OBJECT_BLUE_ID, sink); + sink.writeByte(':'); + writeString(blueId, sink); + sink.writeByte('}'); + } + + private static void writeMap(Map map, CanonicalByteSink sink) { + List retainedKeys = new ArrayList<>(map.size()); + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() == null) { + // Match the legacy mapper's NON_NULL map-value inclusion. + continue; + } + Object key = entry.getKey(); + if (!(key instanceof String)) { + throw new UnsupportedCanonicalValueException(key == null ? null : key.getClass()); + } + retainedKeys.add((String) key); + } + String[] keys = retainedKeys.toArray(new String[0]); + Arrays.sort(keys); + sink.writeByte('{'); + for (int index = 0; index < keys.length; index++) { + if (index > 0) { + sink.writeByte(','); + } + String key = keys[index]; + writeString(key, sink); + sink.writeByte(':'); + writeCanonicalValue(map.get(key), sink); + } + sink.writeByte('}'); + } + + private static void writeList(List list, CanonicalByteSink sink) { + sink.writeByte('['); + for (int index = 0; index < list.size(); index++) { + if (index > 0) { + sink.writeByte(','); + } + writeCanonicalValue(list.get(index), sink); + } + sink.writeByte(']'); + } + + private static void writeLegacyCanonicalValue(Object value, CanonicalByteSink sink) { + try { + byte[] json = UncheckedObjectMapper.JSON_MAPPER.writeValueAsBytes(value); + byte[] wrapped = new byte[json.length + 2]; + wrapped[0] = '['; + System.arraycopy(json, 0, wrapped, 1, json.length); + wrapped[wrapped.length - 1] = ']'; + byte[] canonicalWrapped = new JsonCanonicalizer(wrapped).getEncodedUTF8(); + sink.write(canonicalWrapped, 1, canonicalWrapped.length - 2); + } catch (Exception exception) { + throw new IllegalStateException("Failed to canonicalize legacy raw value", exception); + } + } + + private static void writeString(String value, CanonicalByteSink sink) { + sink.writeByte('"'); + for (int index = 0; index < value.length(); index++) { + char current = value.charAt(index); + switch (current) { + case '\b': + writeEscape(sink, 'b'); + break; + case '\t': + writeEscape(sink, 't'); + break; + case '\n': + writeEscape(sink, 'n'); + break; + case '\f': + writeEscape(sink, 'f'); + break; + case '\r': + writeEscape(sink, 'r'); + break; + case '"': + case '\\': + writeEscape(sink, current); + break; + default: + if (current < 0x20) { + sink.writeByte('\\'); + sink.writeByte('u'); + sink.writeByte('0'); + sink.writeByte('0'); + sink.writeByte(hex((current >>> 4) & 0x0f)); + sink.writeByte(hex(current & 0x0f)); + } else if (Character.isHighSurrogate(current) + && index + 1 < value.length() + && Character.isLowSurrogate(value.charAt(index + 1))) { + int codePoint = Character.toCodePoint(current, value.charAt(++index)); + writeUtf8CodePoint(codePoint, sink); + } else if (Character.isSurrogate(current)) { + // String.getBytes(UTF_8), used by JsonCanonicalizer 1.1, + // replaces an unpaired UTF-16 surrogate with '?'. + sink.writeByte('?'); + } else { + writeUtf8CodePoint(current, sink); + } + } + } + sink.writeByte('"'); + } + + private static void writeNumber(double value, CanonicalByteSink sink) { + if (!Double.isFinite(value)) { + throw new UnsupportedCanonicalValueException(Double.class); + } + try { + writeBytes(sink, ascii(NumberToJSON.serializeNumber(value))); + } catch (IOException exception) { + throw new IllegalArgumentException("Problem when generating canonized json.", exception); + } + } + + private static void writeEscape(CanonicalByteSink sink, int escaped) { + sink.writeByte('\\'); + sink.writeByte(escaped); + } + + private static int hex(int nibble) { + return nibble < 10 ? '0' + nibble : 'a' + nibble - 10; + } + + private static void writeUtf8CodePoint(int codePoint, CanonicalByteSink sink) { + if (codePoint <= 0x7f) { + sink.writeByte(codePoint); + } else if (codePoint <= 0x7ff) { + sink.writeByte(0xc0 | (codePoint >>> 6)); + sink.writeByte(0x80 | (codePoint & 0x3f)); + } else if (codePoint <= 0xffff) { + sink.writeByte(0xe0 | (codePoint >>> 12)); + sink.writeByte(0x80 | ((codePoint >>> 6) & 0x3f)); + sink.writeByte(0x80 | (codePoint & 0x3f)); + } else { + sink.writeByte(0xf0 | (codePoint >>> 18)); + sink.writeByte(0x80 | ((codePoint >>> 12) & 0x3f)); + sink.writeByte(0x80 | ((codePoint >>> 6) & 0x3f)); + sink.writeByte(0x80 | (codePoint & 0x3f)); + } + } + + private static byte[] ascii(String value) { + byte[] bytes = new byte[value.length()]; + for (int index = 0; index < value.length(); index++) { + bytes[index] = (byte) value.charAt(index); + } + return bytes; + } + + private static void writeBytes(CanonicalByteSink sink, byte[] bytes) { + sink.write(bytes, 0, bytes.length); + } + + private static final class CountingSink implements CanonicalByteSink { + private long bytes; + + @Override + public void writeByte(int value) { + bytes++; + } + + @Override + public void write(byte[] values, int offset, int length) { + bytes += length; + } + } + + static final class UnsupportedCanonicalValueException extends RuntimeException { + UnsupportedCanonicalValueException(Class type) { + super(type == null ? "Unsupported null map key" : "Unsupported canonical value: " + type.getName()); + } + } +} diff --git a/src/main/java/blue/language/snapshot/FrozenNode.java b/src/main/java/blue/language/snapshot/FrozenNode.java index c45f107..a5216e1 100644 --- a/src/main/java/blue/language/snapshot/FrozenNode.java +++ b/src/main/java/blue/language/snapshot/FrozenNode.java @@ -10,11 +10,15 @@ import blue.language.utils.NodeToMapListOrValue; import blue.language.utils.SchemaToMapListOrValue; +import java.lang.reflect.Array; import java.math.BigInteger; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.Iterator; import java.util.LinkedHashMap; +import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -104,10 +108,90 @@ public static FrozenNode fromUncheckedCanonicalNode(Node node) { return fromNode(node, true, null, false); } + /** + * Reframes an authored canonical value for the construction mode of a target tree. + * + *

This is a structural immutable copy only: it does not resolve references, + * inherit fields, or materialize an intermediate {@link Node}. It exists for + * immutable patch values that must be applied to both canonical and resolved + * snapshot trees.

+ * + * @param authoredCanonicalValue a canonical authored value, never a resolved view + * @param modeTemplate a node whose canonical/validation mode should be used + */ + public static FrozenNode authoredValueInModeOf(FrozenNode authoredCanonicalValue, + FrozenNode modeTemplate) { + FrozenNode source = Objects.requireNonNull(authoredCanonicalValue, "authoredCanonicalValue"); + FrozenNode template = Objects.requireNonNull(modeTemplate, "modeTemplate"); + if (!source.strictCanonical) { + throw new IllegalArgumentException("Authored frozen values must be canonical"); + } + if (source.strictCanonical == template.strictCanonical + && source.strictBlueIdValidation == template.strictBlueIdValidation + && source.constructionModeNormalized) { + return source; + } + return source.copyInConstructionMode(template.strictCanonical, + template.strictBlueIdValidation, + false); + } + private static FrozenNode fromNode(Node node, boolean strictCanonical) { return fromNode(node, strictCanonical, null, true); } + private FrozenNode copyInConstructionMode(boolean targetStrictCanonical, + boolean targetStrictBlueIdValidation, + boolean listElement) { + List nextItems = null; + if (items != null) { + nextItems = new ArrayList<>(items.size()); + for (FrozenNode item : items) { + nextItems.add(item.copyInConstructionMode(targetStrictCanonical, + targetStrictBlueIdValidation, + true)); + } + } + Map nextProperties = null; + if (properties != null) { + nextProperties = new LinkedHashMap<>(); + for (Map.Entry entry : properties.entrySet()) { + nextProperties.put(entry.getKey(), entry.getValue().copyInConstructionMode( + targetStrictCanonical, targetStrictBlueIdValidation, false)); + } + } + return builder() + .name(name) + .description(description) + .type(copyInConstructionMode(type, targetStrictCanonical, targetStrictBlueIdValidation, false)) + .itemType(copyInConstructionMode(itemType, targetStrictCanonical, targetStrictBlueIdValidation, false)) + .keyType(copyInConstructionMode(keyType, targetStrictCanonical, targetStrictBlueIdValidation, false)) + .valueType(copyInConstructionMode(valueType, targetStrictCanonical, targetStrictBlueIdValidation, false)) + .frozenValue(value) + .items(nextItems) + .properties(nextProperties) + .contracts(copyInConstructionMode(contracts, targetStrictCanonical, targetStrictBlueIdValidation, false)) + .referenceBlueId(referenceBlueId) + .schema(schema) + .mergePolicy(mergePolicy) + .previousBlueId(previousBlueId) + .position(position) + .blue(copyInConstructionMode(blue, targetStrictCanonical, targetStrictBlueIdValidation, false)) + .inlineValue(inlineValue) + .strictCanonical(targetStrictCanonical) + .strictBlueIdValidation(targetStrictBlueIdValidation) + .previousAnchorContext(listElement) + .build(); + } + + private static FrozenNode copyInConstructionMode(FrozenNode node, + boolean targetStrictCanonical, + boolean targetStrictBlueIdValidation, + boolean listElement) { + return node == null ? null : node.copyInConstructionMode( + targetStrictCanonical, targetStrictBlueIdValidation, listElement); + } + private static FrozenNode fromNode(Node node, boolean strictCanonical, ResolvedStructuralInterner interner) { return fromNode(node, strictCanonical, interner, strictCanonical); } @@ -205,15 +289,7 @@ private static Map freezeProperties(Map source } public static String calculateBlueId(List nodes) { - List source = nodes == null ? Collections.emptyList() : nodes; - if (canFoldCachedListBlueIds(source)) { - return foldCachedListBlueIds(source); - } - List objects = new ArrayList<>(source.size()); - for (int i = 0; i < source.size(); i++) { - objects.add(FrozenNodeToBlueIdInput.getListElement(source.get(i), i)); - } - return BlueIdCalculator.INSTANCE.calculate(objects); + return FrozenCanonicalDigester.calculateBlueId(nodes); } /** @@ -235,7 +311,8 @@ public boolean sameResolvedStructure(FrozenNode other) { || !sameResolvedStructure(itemType, other.itemType) || !sameResolvedStructure(keyType, other.keyType) || !sameResolvedStructure(valueType, other.valueType) - || !Objects.equals(value, other.value) + || !Objects.equals(ResolvedStructuralKey.valueKeyOf(value), + ResolvedStructuralKey.valueKeyOf(other.value)) || !sameResolvedItems(items, other.items) || !sameResolvedProperties(properties, other.properties) || !sameResolvedStructure(contracts, other.contracts) @@ -295,7 +372,9 @@ private static boolean sameSchema(Schema left, Schema right) { return true; } return left != null && right != null - && schemaObject(left).equals(schemaObject(right)); + && Objects.equals( + ResolvedStructuralKey.valueKeyOf(schemaObject(left)), + ResolvedStructuralKey.valueKeyOf(schemaObject(right))); } public Node toNode() { @@ -306,7 +385,7 @@ public Node toNode() { .itemType(itemType != null ? itemType.toNode() : null) .keyType(keyType != null ? keyType.toNode() : null) .valueType(valueType != null ? valueType.toNode() : null) - .value(value) + .value(mutableValueCopy(value)) .blueId(referenceBlueId) .schema(schema != null ? schema.clone() : null) .mergePolicy(mergePolicy) @@ -348,6 +427,11 @@ public String getName() { } public Object getValue() { + return publicValueView(value); + } + + /** Internal immutable value graph without compatibility-boundary copies. */ + Object frozenValue() { return value; } @@ -383,6 +467,305 @@ public Schema getSchema() { return schema != null ? schema.clone() : null; } + /** + * Read-only package view used by frozen-native algorithms. The stored + * schema is an owned clone and callers in this package must never mutate it. + */ + Schema frozenSchemaView() { + return schema; + } + + /** + * Returns a conservative allocation-light retained-weight estimate for + * this immutable graph. The estimate is intended for cache admission and + * eviction, not heap-accounting assertions; it never materializes a + * {@link Node} or computes an identity. + */ + public long approximateRetainedWeightBytes() { + return approximateRetainedWeightBytesOf(this); + } + + /** + * Estimates only this node and its directly owned containers/keys. Child + * nodes are deliberately excluded so caches that weigh each interned node + * independently do not multiply-count shared descendants. + */ + public long approximateShallowRetainedWeightBytes() { + IdentityHashMap seen = new IdentityHashMap<>(); + seen.put(this, Boolean.TRUE); + long weight = 112L; + weight += retainedString(name, seen); + weight += retainedString(description, seen); + weight += retainedValue(value, seen); + weight += retainedString(referenceBlueId, seen); + weight += retainedString(mergePolicy, seen); + weight += retainedString(previousBlueId, seen); + weight += retainedString(blueId, seen); + if (items != null) weight += 32L + 8L * items.size(); + if (properties != null) { + weight += 64L + 40L * properties.size(); + for (String key : properties.keySet()) weight += retainedString(key, seen); + } + weight += retainedSchema(schema, seen); + weight += retainedShallowStructuralKey(resolvedStructuralKey, seen); + return weight; + } + + /** + * Estimates multiple roots as one graph, deduplicating structurally shared + * frozen nodes and other shared objects by reference identity. + */ + public static long approximateRetainedWeightBytesOf(FrozenNode... roots) { + IdentityHashMap seen = new IdentityHashMap<>(); + long weight = 0L; + if (roots != null) { + for (FrozenNode root : roots) { + weight += retainedWeight(root, seen); + } + } + return weight; + } + + private static long retainedWeight(FrozenNode node, + IdentityHashMap seen) { + if (node == null || seen.put(node, Boolean.TRUE) != null) { + return 0L; + } + // Object header plus references/booleans, rounded conservatively for + // the Java 8 compressed-oops layout used by supported runtimes. + long weight = 112L; + weight += retainedString(node.name, seen); + weight += retainedString(node.description, seen); + weight += retainedValue(node.value, seen); + weight += retainedString(node.referenceBlueId, seen); + weight += retainedString(node.mergePolicy, seen); + weight += retainedString(node.previousBlueId, seen); + weight += retainedWeight(node.type, seen); + weight += retainedWeight(node.itemType, seen); + weight += retainedWeight(node.keyType, seen); + weight += retainedWeight(node.valueType, seen); + weight += retainedWeight(node.contracts, seen); + weight += retainedWeight(node.blue, seen); + if (node.items != null && seen.put(node.items, Boolean.TRUE) == null) { + weight += 32L + 8L * node.items.size(); + for (FrozenNode item : node.items) weight += retainedWeight(item, seen); + } + if (node.properties != null && seen.put(node.properties, Boolean.TRUE) == null) { + weight += 64L + 40L * node.properties.size(); + for (Map.Entry entry : node.properties.entrySet()) { + weight += retainedString(entry.getKey(), seen); + weight += retainedWeight(entry.getValue(), seen); + } + } + weight += retainedSchema(node.schema, seen); + weight += retainedString(node.blueId, seen); + weight += retainedStructuralObject(node.resolvedStructuralKey, seen); + return weight; + } + + private static long retainedSchema(Schema schema, + IdentityHashMap seen) { + if (schema == null || seen.put(schema, Boolean.TRUE) != null) { + return 0L; + } + long weight = 80L; + weight += retainedMutableNode(schema.getRequired(), seen); + weight += retainedMutableNode(schema.getMinLength(), seen); + weight += retainedMutableNode(schema.getMaxLength(), seen); + weight += retainedMutableNode(schema.getMinimum(), seen); + weight += retainedMutableNode(schema.getMaximum(), seen); + weight += retainedMutableNode(schema.getExclusiveMinimum(), seen); + weight += retainedMutableNode(schema.getExclusiveMaximum(), seen); + weight += retainedMutableNode(schema.getMultipleOf(), seen); + weight += retainedMutableNode(schema.getMinItems(), seen); + weight += retainedMutableNode(schema.getMaxItems(), seen); + weight += retainedMutableNode(schema.getUniqueItems(), seen); + weight += retainedMutableNode(schema.getMinFields(), seen); + weight += retainedMutableNode(schema.getMaxFields(), seen); + if (schema.getEnum() != null && seen.put(schema.getEnum(), Boolean.TRUE) == null) { + weight += 32L + 8L * schema.getEnum().size(); + for (Node value : schema.getEnum()) weight += retainedMutableNode(value, seen); + } + return weight; + } + + private static long retainedMutableNode(Node node, + IdentityHashMap seen) { + if (node == null || seen.put(node, Boolean.TRUE) != null) { + return 0L; + } + long weight = 104L; + weight += retainedString(node.getName(), seen); + weight += retainedString(node.getDescription(), seen); + weight += retainedValue(node.getRawValue(), seen); + weight += retainedString(node.getBlueId(), seen); + weight += retainedString(node.getMergePolicy(), seen); + weight += retainedString(node.getPreviousBlueId(), seen); + weight += retainedMutableNode(node.getType(), seen); + weight += retainedMutableNode(node.getItemType(), seen); + weight += retainedMutableNode(node.getKeyType(), seen); + weight += retainedMutableNode(node.getValueType(), seen); + weight += retainedMutableNode(node.getContracts(), seen); + weight += retainedMutableNode(node.getBlue(), seen); + if (node.getItems() != null && seen.put(node.getItems(), Boolean.TRUE) == null) { + weight += 32L + 8L * node.getItems().size(); + for (Node item : node.getItems()) weight += retainedMutableNode(item, seen); + } + if (node.getProperties() != null + && seen.put(node.getProperties(), Boolean.TRUE) == null) { + weight += 64L + 40L * node.getProperties().size(); + for (Map.Entry entry : node.getProperties().entrySet()) { + weight += retainedString(entry.getKey(), seen); + weight += retainedMutableNode(entry.getValue(), seen); + } + } + weight += retainedSchema(node.getSchema(), seen); + return weight; + } + + private static long retainedValue(Object value, + IdentityHashMap seen) { + if (value == null) return 0L; + if (value instanceof String) return retainedString((String) value, seen); + if (seen.put(value, Boolean.TRUE) != null) return 0L; + if (value instanceof BigInteger) { + return 48L + 4L * ((((BigInteger) value).abs().bitLength() + 31L) / 32L); + } + if (value instanceof java.math.BigDecimal) { + java.math.BigDecimal decimal = (java.math.BigDecimal) value; + return 64L + retainedValue(decimal.unscaledValue(), seen); + } + if (value instanceof Boolean) return 16L; + if (value instanceof Number) return 24L; + if (value instanceof List) { + List values = (List) value; + long weight = 32L + 8L * values.size(); + for (Object item : values) weight += retainedValue(item, seen); + return weight; + } + if (value instanceof Map) { + Map values = (Map) value; + long weight = 64L + 40L * values.size(); + for (Map.Entry entry : values.entrySet()) { + weight += entry.getKey() instanceof String + ? retainedString((String) entry.getKey(), seen) + : retainedStructuralObject(entry.getKey(), seen); + weight += retainedValue(entry.getValue(), seen); + } + return weight; + } + if (value.getClass().isArray()) { + int length = Array.getLength(value); + long weight = 24L + 8L * length; + for (int index = 0; index < length; index++) { + weight += retainedValue(Array.get(value, index), seen); + } + return weight; + } + return 48L; + } + + private static long retainedString(String value, + IdentityHashMap seen) { + if (value == null || seen.put(value, Boolean.TRUE) != null) return 0L; + return 48L + 2L * value.length(); + } + + private static long retainedStructuralObject(Object value, + IdentityHashMap seen) { + if (value == null) return 0L; + if (value instanceof String) return retainedString((String) value, seen); + if (value instanceof Number || value instanceof Boolean) { + return retainedValue(value, seen); + } + if (seen.put(value, Boolean.TRUE) != null) return 0L; + if (value instanceof ResolvedStructuralKey) { + ResolvedStructuralKey key = (ResolvedStructuralKey) value; + return 32L + retainedStructuralObject(key.fields, seen); + } + if (value instanceof PropertyKey) { + PropertyKey key = (PropertyKey) value; + return 24L + + retainedString(key.name, seen) + + retainedStructuralObject(key.value, seen); + } + if (value instanceof List) { + List values = (List) value; + long weight = 32L + 8L * values.size(); + for (Object item : values) { + weight += retainedStructuralObject(item, seen); + } + return weight; + } + if (value instanceof Map) { + Map values = (Map) value; + long weight = 64L + 40L * values.size(); + for (Map.Entry entry : values.entrySet()) { + weight += retainedStructuralObject(entry.getKey(), seen); + weight += retainedStructuralObject(entry.getValue(), seen); + } + return weight; + } + return 48L; + } + + /** + * Weighs only the containers owned directly by this node's structural key. + * Child structural keys are references to separately interned entries and + * must not be recursively charged once per ancestor. + */ + private static long retainedShallowStructuralKey( + ResolvedStructuralKey key, + IdentityHashMap seen) { + if (key == null || seen.put(key, Boolean.TRUE) != null) { + return 0L; + } + long weight = 32L; + if (seen.put(key.fields, Boolean.TRUE) != null) { + return weight; + } + weight += 32L + 8L * key.fields.size(); + for (int index = 0; index < key.fields.size(); index++) { + Object field = key.fields.get(index); + if (field instanceof ResolvedStructuralKey) { + continue; + } + if (index == 7) { + weight += retainedChildKeyList(field, seen); + } else if (index == 8) { + weight += retainedPropertyKeyList(field, seen); + } else { + weight += retainedStructuralObject(field, seen); + } + } + return weight; + } + + private static long retainedChildKeyList(Object field, + IdentityHashMap seen) { + if (!(field instanceof List) || seen.put(field, Boolean.TRUE) != null) { + return 0L; + } + return 32L + 8L * ((List) field).size(); + } + + private static long retainedPropertyKeyList(Object field, + IdentityHashMap seen) { + if (!(field instanceof List) || seen.put(field, Boolean.TRUE) != null) { + return 0L; + } + List properties = (List) field; + long weight = 32L + 8L * properties.size(); + for (Object value : properties) { + if (!(value instanceof PropertyKey) || seen.put(value, Boolean.TRUE) != null) { + continue; + } + PropertyKey property = (PropertyKey) value; + weight += 24L + retainedString(property.name, seen); + } + return weight; + } + public String getMergePolicy() { return mergePolicy; } @@ -722,7 +1105,7 @@ private Builder toBuilder() { .itemType(itemType) .keyType(keyType) .valueType(valueType) - .value(value) + .frozenValue(value) .items(items) .properties(properties) .contracts(contracts) @@ -743,10 +1126,7 @@ private String computeBlueId() { if (!strictBlueIdValidation) { return BlueIdCalculator.calculateUncheckedBlueId(toNode()); } - if (isPayloadOnlyList()) { - return calculateBlueId(items); - } - return BlueIdCalculator.INSTANCE.calculate(FrozenNodeToBlueIdInput.get(this)); + return FrozenCanonicalDigester.calculateBlueId(this); } return computeResolvedStructuralBlueId(); } @@ -1083,6 +1463,244 @@ private static Map freezeMap(Map source) return Collections.unmodifiableMap(new LinkedHashMap<>(source)); } + /** + * Takes an owned immutable snapshot of a JSON value payload. Blue values are + * JSON values, so accepting an arbitrary mutable Java object here would make + * the frozen node's memoized identity and structural key stale after mutation. + */ + private static Object freezeValue(Object source) { + return freezeValue(source, new IdentityHashMap()); + } + + private static Object freezeValue(Object source, + IdentityHashMap activeContainers) { + if (source instanceof Float && !Float.isFinite((Float) source) + || source instanceof Double && !Double.isFinite((Double) source)) { + throw new IllegalArgumentException( + "Frozen node values must not contain non-finite numbers"); + } + if (source == null || source instanceof String || source instanceof Boolean + || source instanceof Character + || source instanceof Enum + || source instanceof BigInteger || source instanceof java.math.BigDecimal + || source instanceof Byte || source instanceof Short + || source instanceof Integer || source instanceof Long + || source instanceof Float || source instanceof Double) { + return source; + } + if (source instanceof List) { + enterValueContainer(source, activeContainers); + try { + List values = (List) source; + List snapshot = new ArrayList<>(values.size()); + for (Object value : values) { + snapshot.add(freezeValue(value, activeContainers)); + } + return Collections.unmodifiableList(snapshot); + } finally { + activeContainers.remove(source); + } + } + if (source instanceof Map) { + enterValueContainer(source, activeContainers); + try { + Map values = (Map) source; + Map snapshot = new LinkedHashMap<>(); + for (Map.Entry entry : values.entrySet()) { + if (!(entry.getKey() instanceof String)) { + throw unsupportedValue(entry.getKey()); + } + snapshot.put((String) entry.getKey(), + freezeValue(entry.getValue(), activeContainers)); + } + return Collections.unmodifiableMap(snapshot); + } finally { + activeContainers.remove(source); + } + } + if (source.getClass().isArray()) { + enterValueContainer(source, activeContainers); + try { + int length = Array.getLength(source); + Class componentType = source.getClass().getComponentType(); + Object snapshot = Array.newInstance(componentType, length); + if (componentType.isPrimitive()) { + if (componentType == float.class || componentType == double.class) { + for (int index = 0; index < length; index++) { + freezeValue(Array.get(source, index), activeContainers); + } + } + System.arraycopy(source, 0, snapshot, 0, length); + return snapshot; + } + for (int index = 0; index < length; index++) { + Object element = Array.get(source, index); + Object frozenElement = freezeValue(element, activeContainers); + if (frozenElement != null && !componentType.isInstance(frozenElement)) { + Object concreteElement = freezeConcreteArrayElement( + element, componentType, activeContainers); + if (concreteElement == null) { + Object[] fallback = new Object[length]; + for (int copiedIndex = 0; copiedIndex < index; copiedIndex++) { + fallback[copiedIndex] = Array.get(snapshot, copiedIndex); + } + fallback[index] = frozenElement; + for (int remainingIndex = index + 1; + remainingIndex < length; + remainingIndex++) { + fallback[remainingIndex] = freezeValue( + Array.get(source, remainingIndex), activeContainers); + } + return fallback; + } + frozenElement = concreteElement; + } + Array.set(snapshot, index, frozenElement); + } + return snapshot; + } finally { + activeContainers.remove(source); + } + } + throw unsupportedValue(source); + } + + private static Object freezeConcreteArrayElement( + Object source, + Class componentType, + IdentityHashMap activeContainers) { + if (source instanceof List) { + List values = (List) source; + List snapshot = mutableListLike(values); + if (!componentType.isInstance(snapshot)) { + return null; + } + enterValueContainer(source, activeContainers); + try { + for (Object value : values) { + snapshot.add(freezeValue(value, activeContainers)); + } + return snapshot; + } finally { + activeContainers.remove(source); + } + } + if (source instanceof Map) { + Map values = (Map) source; + Map snapshot = mutableMapLike(values); + if (!componentType.isInstance(snapshot)) { + return null; + } + enterValueContainer(source, activeContainers); + try { + for (Map.Entry entry : values.entrySet()) { + if (!(entry.getKey() instanceof String)) { + throw unsupportedValue(entry.getKey()); + } + snapshot.put((String) entry.getKey(), + freezeValue(entry.getValue(), activeContainers)); + } + return snapshot; + } finally { + activeContainers.remove(source); + } + } + return null; + } + + private static void enterValueContainer(Object source, + IdentityHashMap activeContainers) { + if (activeContainers.put(source, Boolean.TRUE) != null) { + throw new IllegalArgumentException("Frozen node values must not contain cycles"); + } + } + + private static IllegalArgumentException unsupportedValue(Object value) { + String type = value == null ? "null" : value.getClass().getName(); + return new IllegalArgumentException( + "Frozen node values must contain only JSON-compatible values; found " + type); + } + + /** Returns a detached mutable JSON graph for the mutable Node compatibility boundary. */ + private static Object mutableValueCopy(Object source) { + if (source instanceof List) { + List values = (List) source; + List copy = mutableListLike(values); + for (Object value : values) { + copy.add(mutableValueCopy(value)); + } + return copy; + } + if (source instanceof Map) { + Map values = (Map) source; + Map copy = mutableMapLike(values); + for (Map.Entry entry : values.entrySet()) { + copy.put((String) entry.getKey(), mutableValueCopy(entry.getValue())); + } + return copy; + } + if (source != null && source.getClass().isArray()) { + int length = Array.getLength(source); + Class componentType = source.getClass().getComponentType(); + Object copy = Array.newInstance(componentType, length); + if (componentType.isPrimitive()) { + System.arraycopy(source, 0, copy, 0, length); + return copy; + } + for (int index = 0; index < length; index++) { + Array.set(copy, index, mutableValueCopy(Array.get(source, index))); + } + return copy; + } + return source; + } + + private static List mutableListLike(List source) { + if (source instanceof LinkedList) { + return new LinkedList<>(); + } + return new ArrayList<>(source.size()); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Map mutableMapLike(Map source) { + if (source instanceof TreeMap) { + return new TreeMap(((TreeMap) source).comparator()); + } + if (source instanceof LinkedHashMap) { + return new LinkedHashMap<>(); + } + if (source instanceof HashMap) { + return new HashMap<>(); + } + return new LinkedHashMap<>(); + } + + private static Object publicValueView(Object source) { + if (source instanceof List) { + List values = (List) source; + List copy = new ArrayList<>(values.size()); + for (Object value : values) { + copy.add(publicValueView(value)); + } + return Collections.unmodifiableList(copy); + } + if (source instanceof Map) { + Map values = (Map) source; + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : values.entrySet()) { + copy.put((String) entry.getKey(), publicValueView(entry.getValue())); + } + return Collections.unmodifiableMap(copy); + } + if (source != null && source.getClass().isArray()) { + // Arrays cannot be made immutable while retaining their runtime type. + // Return a fully detached mutable graph instead; mutations are harmless. + return mutableValueCopy(source); + } + return source; + } + private static Builder builder() { return new Builder(); } @@ -1141,6 +1759,11 @@ Builder valueType(FrozenNode valueType) { } Builder value(Object value) { + this.nodeValue = freezeValue(value); + return this; + } + + Builder frozenValue(Object value) { this.nodeValue = value; return this; } @@ -1243,12 +1866,14 @@ private ResolvedStructuralKey(FrozenNode node) { exact.add(keyOf(node.itemType)); exact.add(keyOf(node.keyType)); exact.add(keyOf(node.valueType)); - exact.add(node.value); + exact.add(valueKeyOf(node.value)); exact.add(keysOf(node.items)); exact.add(propertyKeysOf(node.properties)); exact.add(keyOf(node.contracts)); exact.add(node.referenceBlueId); - exact.add(node.schema != null ? schemaObject(node.schema) : null); + exact.add(node.schema != null + ? valueKeyOf(schemaObject(node.schema)) + : null); exact.add(node.mergePolicy); exact.add(node.previousBlueId); exact.add(node.position); @@ -1287,6 +1912,33 @@ private static List propertyKeysOf(Map properti return Collections.unmodifiableList(keys); } + private static Object valueKeyOf(Object value) { + if (value instanceof List) { + List source = (List) value; + List keys = new ArrayList<>(source.size()); + for (Object item : source) { + keys.add(valueKeyOf(item)); + } + return Collections.unmodifiableList(keys); + } + if (value instanceof Map) { + Map source = (Map) value; + Map keys = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + keys.put((String) entry.getKey(), valueKeyOf(entry.getValue())); + } + return Collections.unmodifiableMap(keys); + } + if (value != null && value.getClass().isArray()) { + List elements = new ArrayList<>(Array.getLength(value)); + for (int index = 0; index < Array.getLength(value); index++) { + elements.add(valueKeyOf(Array.get(value, index))); + } + return new RawArrayKey(value.getClass(), elements); + } + return value; + } + @Override public boolean equals(Object other) { return this == other || other instanceof ResolvedStructuralKey @@ -1299,6 +1951,33 @@ public int hashCode() { } } + private static final class RawArrayKey { + private final Class arrayType; + private final List elements; + + private RawArrayKey(Class arrayType, List elements) { + this.arrayType = arrayType; + this.elements = Collections.unmodifiableList(elements); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof RawArrayKey)) { + return false; + } + RawArrayKey that = (RawArrayKey) other; + return arrayType.equals(that.arrayType) && elements.equals(that.elements); + } + + @Override + public int hashCode() { + return Objects.hash(arrayType, elements); + } + } + private static final class PropertyKey { private final String name; private final ResolvedStructuralKey value; diff --git a/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java b/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java index b634f68..2cc172b 100644 --- a/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java +++ b/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java @@ -1,14 +1,24 @@ package blue.language.snapshot; +import blue.language.BlueCachePolicy; import blue.language.model.Node; import blue.language.merge.Merger.VerifiedReferenceResolution; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.ArrayDeque; import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicLong; @@ -21,12 +31,14 @@ * {@code blueId}: inherited schema and other contextual contributions can make * such a node differ from the standalone content addressed by that identity.

*/ -public final class ResolvedReferenceCache { +public final class ResolvedReferenceCache implements AutoCloseable { private final ResolvedReferenceCache readThroughParent; private final CacheGeneration cacheGeneration; + private final BlueCachePolicy cachePolicy; private final long openedGeneration; private volatile long observedGeneration; + private volatile boolean locallyClosed; private final ConcurrentMap entriesByBlueId = new ConcurrentHashMap<>(); private final ConcurrentMap transientTrustedCanonicalByBlueId = new ConcurrentHashMap<>(); @@ -34,14 +46,35 @@ public final class ResolvedReferenceCache { new ConcurrentHashMap<>(); private final FrozenNode.ResolvedStructuralInterner resolvedGraphInterner; private final FrozenNode.ResolvedStructuralInterner existingResolvedGraphInterner; + private final Set pinnedVerifiedBlueIds = new HashSet<>(); + private final LinkedHashSet verifiedInsertionOrder = new LinkedHashSet<>(); + private final LinkedHashSet trustedInsertionOrder = new LinkedHashSet<>(); + private final LinkedHashSet structuralInsertionOrder = + new LinkedHashSet<>(); + private long verifiedCurrentWeight; + private long verifiedHighWaterWeight; + private long verifiedEvictions; + private long verifiedOversizedRejections; + private long trustedCurrentWeight; + private long trustedHighWaterWeight; + private long structuralCurrentWeight; + private long structuralHighWaterWeight; + private long structuralEvictions; + private long structuralOversizedRejections; public ResolvedReferenceCache() { + this(BlueCachePolicy.boundedDefaults()); + } + + public ResolvedReferenceCache(BlueCachePolicy cachePolicy) { this.readThroughParent = null; + this.cachePolicy = Objects.requireNonNull(cachePolicy, "cachePolicy"); this.cacheGeneration = new CacheGeneration(); this.openedGeneration = -1L; this.observedGeneration = cacheGeneration.value.get(); this.resolvedGraphInterner = newResolvedGraphInterner(); this.existingResolvedGraphInterner = newExistingResolvedGraphInterner(); + cacheGeneration.register(this); } private ResolvedReferenceCache(ResolvedReferenceCache readThroughParent) { @@ -54,11 +87,13 @@ private ResolvedReferenceCache(ResolvedReferenceCache readThroughParent) { private ResolvedReferenceCache(ResolvedReferenceCache readThroughParent, long openedGeneration) { this.readThroughParent = readThroughParent; + this.cachePolicy = readThroughParent.cachePolicy; this.cacheGeneration = readThroughParent.cacheGeneration; this.openedGeneration = openedGeneration; this.observedGeneration = cacheGeneration.value.get(); this.resolvedGraphInterner = newResolvedGraphInterner(); this.existingResolvedGraphInterner = newExistingResolvedGraphInterner(); + cacheGeneration.register(this); } private FrozenNode.ResolvedStructuralInterner newResolvedGraphInterner() { @@ -78,7 +113,11 @@ public FrozenNode intern(FrozenNode.ResolvedStructuralKey structuralKey, } FrozenNode existing = resolvedGraphNodesByStructure.putIfAbsent( structuralKey, node); - return existing != null ? existing : node; + if (existing != null) { + return existing; + } + recordStructuralInsertion(structuralKey, node); + return node; } } }; @@ -101,7 +140,10 @@ public FrozenNode intern(FrozenNode.ResolvedStructuralKey structuralKey, * child therefore discards every transient working-state cache insertion. */ public ResolvedReferenceCache transientChild() { - return new ResolvedReferenceCache(this); + synchronized (cacheGeneration.mutationLock) { + ensureCurrentGeneration(); + return new ResolvedReferenceCache(this); + } } /** Returns an independent transient cache with the same parent and local retained entries. */ @@ -119,11 +161,46 @@ public ResolvedReferenceCache forkTransient() { fork.transientTrustedCanonicalByBlueId.putAll( transientTrustedCanonicalByBlueId); fork.resolvedGraphNodesByStructure.putAll(resolvedGraphNodesByStructure); + fork.rebuildLocalWeightAccounting(); } return fork; } } + /** + * Creates an independent root cache containing only the caller-pinned + * verified entries visible at the time of this call. The returned cache + * shares immutable frozen graphs, but it has its own generation, mutation + * state, and bounded storage for entries discovered later. Reloadable, + * transient-trusted, and structural-interner entries are not copied. + * + *

The caller owns the returned cache and should close it when the + * retained snapshot is no longer needed.

+ */ + public ResolvedReferenceCache isolatedCopyOfPinnedVerifiedEntries() { + Map retainedPinned = new HashMap<>(); + BlueCachePolicy retainedPolicy; + synchronized (cacheGeneration.mutationLock) { + ensureCurrentGeneration(); + ResolvedReferenceCache root = rootCache(); + root.ensureCurrentGeneration(); + retainedPolicy = root.cachePolicy; + for (String blueId : root.pinnedVerifiedBlueIds) { + VerifiedReferenceEntry entry = root.entriesByBlueId.get(blueId); + if (entry != null) { + retainedPinned.put(blueId, entry); + } + } + } + + ResolvedReferenceCache isolated = new ResolvedReferenceCache(retainedPolicy); + synchronized (isolated.cacheGeneration.mutationLock) { + isolated.entriesByBlueId.putAll(retainedPinned); + isolated.rebuildLocalWeightAccounting(retainedPinned.keySet()); + } + return isolated; + } + /** * Returns non-certifying host-trusted content retained only by this * transient sequence. Such content is never read from or promoted to the @@ -151,6 +228,9 @@ public FrozenNode putTransientTrustedCanonical(String blueId, FrozenNode canonic ensureCurrentGeneration(); FrozenNode existing = transientTrustedCanonicalByBlueId.putIfAbsent( blueId, canonicalContent); + if (existing == null) { + recordTrustedInsertion(blueId, canonicalContent); + } return existing != null ? existing : canonicalContent; } } @@ -187,12 +267,12 @@ public FrozenNode putVerifiedCanonical(String blueId, FrozenNode canonicalConten if (inherited != null) { return inherited.canonicalContent; } - VerifiedReferenceEntry retained = entriesByBlueId.compute(blueId, (ignored, existing) -> { - if (existing != null) { - return existing; - } - return new VerifiedReferenceEntry(canonicalContent, null); - }); + VerifiedReferenceEntry created = new VerifiedReferenceEntry(canonicalContent, null); + VerifiedReferenceEntry retained = entriesByBlueId.putIfAbsent(blueId, created); + if (retained == null) { + recordVerifiedInsertion(blueId, created); + return canonicalContent; + } return retained.canonicalContent; } } @@ -201,24 +281,79 @@ public FrozenNode getOrLoadVerifiedCanonical(String blueId, Supplier canonicalLoader) { Objects.requireNonNull(blueId, "blueId"); Objects.requireNonNull(canonicalLoader, "canonicalLoader"); - synchronized (cacheGeneration.loadingLock(blueId)) { - while (true) { - long loadingGeneration; - synchronized (cacheGeneration.mutationLock) { - ensureCurrentGeneration(); - VerifiedReferenceEntry local = entriesByBlueId.get(blueId); - if (local != null) { - return local.canonicalContent; - } - VerifiedReferenceEntry inherited = inheritedEntry(blueId); - if (inherited != null) { - return inherited.canonicalContent; + while (true) { + long loadingGeneration; + synchronized (cacheGeneration.mutationLock) { + ensureCurrentGeneration(); + VerifiedReferenceEntry local = entriesByBlueId.get(blueId); + if (local != null) { + return local.canonicalContent; + } + VerifiedReferenceEntry inherited = inheritedEntry(blueId); + if (inherited != null) { + return inherited.canonicalContent; + } + loadingGeneration = cacheGeneration.value.get(); + } + + CanonicalLoadKey loadKey = new CanonicalLoadKey(loadingGeneration, blueId); + Deque loadingStack = cacheGeneration.loadingStack.get(); + if (isLoadingBlueId(loadingStack, blueId)) { + throw new IllegalStateException("Recursive verified reference load: " + blueId); + } + CanonicalLoadFlight candidate = new CanonicalLoadFlight(Thread.currentThread()); + CanonicalLoadFlight existing = cacheGeneration.canonicalLoads.putIfAbsent( + loadKey, candidate); + CanonicalLoadFlight flight = existing != null ? existing : candidate; + boolean ownsLoad = existing == null; + if (!ownsLoad && flight.owner == Thread.currentThread()) { + throw new IllegalStateException("Recursive verified reference load: " + blueId); + } + + try { + if (ownsLoad) { + // Another flight may have published after this thread's + // initial cache check but before it installed a new flight. + // Recheck after winning ownership so that late contenders + // do not invoke the provider a second time. + synchronized (cacheGeneration.mutationLock) { + if (loadingGeneration != cacheGeneration.value.get()) { + continue; + } + ensureCurrentGeneration(); + VerifiedReferenceEntry published = entriesByBlueId.get(blueId); + if (published == null) { + published = inheritedEntry(blueId); + } + if (published != null) { + return published.canonicalContent; + } } - loadingGeneration = cacheGeneration.value.get(); } - FrozenNode loaded = canonicalLoader.get(); - requireCanonical(blueId, loaded); + FrozenNode loaded; + if (ownsLoad) { + loadingStack.addLast(loadKey); + try { + loaded = canonicalLoader.get(); + requireCanonical(blueId, loaded); + flight.result.complete(loaded); + } catch (Throwable failure) { + flight.result.completeExceptionally(failure); + throw propagateLoadFailure(failure); + } finally { + CanonicalLoadKey removed = loadingStack.removeLast(); + if (!loadKey.equals(removed)) { + throw new IllegalStateException( + "Verified reference load stack became unbalanced"); + } + if (loadingStack.isEmpty()) { + cacheGeneration.loadingStack.remove(); + } + } + } else { + loaded = awaitCanonicalLoad(flight); + } synchronized (cacheGeneration.mutationLock) { if (loadingGeneration != cacheGeneration.value.get()) { @@ -235,10 +370,48 @@ public FrozenNode getOrLoadVerifiedCanonical(String blueId, } VerifiedReferenceEntry retained = entriesByBlueId.putIfAbsent( blueId, new VerifiedReferenceEntry(loaded, null)); - return retained != null ? retained.canonicalContent : loaded; + if (retained != null) { + return retained.canonicalContent; + } + recordVerifiedInsertion(blueId, entriesByBlueId.get(blueId)); + return loaded; } + } finally { + if (ownsLoad) { + cacheGeneration.canonicalLoads.remove(loadKey, flight); + } + } + } + } + + private static boolean isLoadingBlueId(Deque loadingStack, + String blueId) { + for (CanonicalLoadKey active : loadingStack) { + if (active.blueId.equals(blueId)) { + return true; } } + return false; + } + + private static FrozenNode awaitCanonicalLoad(CanonicalLoadFlight flight) { + try { + return flight.result.join(); + } catch (CompletionException failure) { + throw propagateLoadFailure(failure.getCause() != null + ? failure.getCause() + : failure); + } + } + + private static RuntimeException propagateLoadFailure(Throwable failure) { + if (failure instanceof RuntimeException) { + return (RuntimeException) failure; + } + if (failure instanceof Error) { + throw (Error) failure; + } + return new IllegalStateException("Verified reference load failed", failure); } public FrozenNode putVerifiedResolved(VerifiedReferenceResolution verification) { @@ -248,6 +421,36 @@ public FrozenNode putVerifiedResolved(VerifiedReferenceResolution verification) verification.resolvedRoot()); } + /** + * Retains caller-registered authoritative content until explicit clear. + * Derived entries remain subject to this cache's configured weight bounds. + */ + public FrozenNode putPinnedVerifiedResolved(VerifiedReferenceResolution verification) { + Objects.requireNonNull(verification, "verification"); + synchronized (cacheGeneration.mutationLock) { + ensureCurrentGeneration(); + if (!isCurrentGeneration()) { + throw new IllegalStateException( + "Stale transient reference cache cannot publish pinned evidence"); + } + if (readThroughParent != null) { + return rootCache().putPinnedVerifiedResolved(verification); + } + pinnedVerifiedBlueIds.add(verification.requestedBlueId()); + return retainVerifiedResolved(verification.requestedBlueId(), + verification.canonicalRoot(), + verification.resolvedRoot()); + } + } + + private ResolvedReferenceCache rootCache() { + ResolvedReferenceCache root = this; + while (root.readThroughParent != null) { + root = root.readThroughParent; + } + return root; + } + private FrozenNode retainVerifiedResolved(String blueId, FrozenNode canonicalContent, FrozenNode fullyResolvedContent) { @@ -264,15 +467,16 @@ private FrozenNode retainVerifiedResolved(String blueId, if (inherited != null && inherited.fullyResolvedContent != null) { return inherited.fullyResolvedContent; } - VerifiedReferenceEntry retained = entriesByBlueId.compute(blueId, (ignored, existing) -> { - FrozenNode retainedCanonical = existing != null - ? existing.canonicalContent - : inherited != null ? inherited.canonicalContent : canonicalContent; - FrozenNode retainedResolved = existing != null && existing.fullyResolvedContent != null - ? existing.fullyResolvedContent - : fullyResolvedContent; - return new VerifiedReferenceEntry(retainedCanonical, retainedResolved); - }); + FrozenNode retainedCanonical = local != null + ? local.canonicalContent + : inherited != null ? inherited.canonicalContent : canonicalContent; + FrozenNode retainedResolved = local != null && local.fullyResolvedContent != null + ? local.fullyResolvedContent + : fullyResolvedContent; + VerifiedReferenceEntry retained = new VerifiedReferenceEntry( + retainedCanonical, retainedResolved); + entriesByBlueId.put(blueId, retained); + recordVerifiedReplacement(blueId, local, retained); return retained.fullyResolvedContent; } } @@ -324,11 +528,15 @@ public void promoteReferencesReachableFrom(FrozenNode canonicalRoot) { continue; } VerifiedReferenceEntry local = entriesByBlueId.get(blueId); - if (local == null) { + VerifiedReferenceEntry visible = local != null + ? local + : readThroughParent.findEntry(blueId); + if (visible == null) { continue; } - FrozenNode retainedCanonical = readThroughParent.putVerifiedCanonical( - blueId, local.canonicalContent); + FrozenNode retainedCanonical = local != null + ? readThroughParent.putVerifiedCanonical(blueId, local.canonicalContent) + : visible.canonicalContent; Set dependencies = new HashSet<>(); collectReferenceBlueIds(retainedCanonical, new HashSet<>(), dependencies); for (String dependency : dependencies) { @@ -336,7 +544,8 @@ public void promoteReferencesReachableFrom(FrozenNode canonicalRoot) { pending.addLast(dependency); } } - if (local.fullyResolvedContent != null + if (local != null + && local.fullyResolvedContent != null && (retainedCanonical == local.canonicalContent || retainedCanonical.sameResolvedStructure(local.canonicalContent))) { readThroughParent.retainVerifiedResolved( @@ -377,13 +586,25 @@ public void retainOnlyReachableFrom(FrozenNode canonicalRoot, FrozenNode resolve } } } - entriesByBlueId.keySet().removeIf(blueId -> !reachableReferences.contains(blueId)); - transientTrustedCanonicalByBlueId.keySet().removeIf( - blueId -> !reachableReferences.contains(blueId)); + for (String blueId : new HashSet<>(entriesByBlueId.keySet())) { + if (!reachableReferences.contains(blueId)) { + removeVerifiedEntry(blueId); + } + } + for (String blueId : new HashSet<>(transientTrustedCanonicalByBlueId.keySet())) { + if (!reachableReferences.contains(blueId)) { + removeTrustedEntry(blueId); + } + } Set reachableGraphNodes = new HashSet<>(); collectResolvedGraphKeys(resolvedRoot, reachableGraphNodes); - resolvedGraphNodesByStructure.keySet().removeIf(key -> !reachableGraphNodes.contains(key)); + for (FrozenNode.ResolvedStructuralKey key + : new HashSet<>(resolvedGraphNodesByStructure.keySet())) { + if (!reachableGraphNodes.contains(key)) { + removeStructuralEntry(key); + } + } } } @@ -454,22 +675,354 @@ private void rememberResolvedGraph(FrozenNode node, } } + private void recordVerifiedInsertion(String blueId, VerifiedReferenceEntry entry) { + recordVerifiedReplacement(blueId, null, entry); + } + + private void recordVerifiedReplacement(String blueId, + VerifiedReferenceEntry previous, + VerifiedReferenceEntry replacement) { + long replacementWeight = verifiedWeight(blueId, replacement); + if (readThroughParent == null + && !pinnedVerifiedBlueIds.contains(blueId) + && (replacementWeight > cachePolicy.maximumDerivedEntryWeightBytes() + || replacementWeight > cachePolicy.transientReferenceMaxWeightBytes())) { + verifiedOversizedRejections++; + if (previous == null) { + entriesByBlueId.remove(blueId, replacement); + } else { + entriesByBlueId.put(blueId, previous); + } + return; + } + if (previous != null) { + verifiedCurrentWeight = subtractFloorZero( + verifiedCurrentWeight, verifiedWeight(blueId, previous)); + } + verifiedInsertionOrder.remove(blueId); + verifiedInsertionOrder.add(blueId); + verifiedCurrentWeight = saturatedAdd(verifiedCurrentWeight, replacementWeight); + verifiedHighWaterWeight = Math.max(verifiedHighWaterWeight, verifiedCurrentWeight); + evictVerifiedToBounds(); + } + + private void evictVerifiedToBounds() { + if (readThroughParent != null) { + return; + } + while (entriesByBlueId.size() > cachePolicy.transientReferenceMaxEntries() + || verifiedCurrentWeight > cachePolicy.transientReferenceMaxWeightBytes()) { + String victim = null; + for (String candidate : verifiedInsertionOrder) { + if (!pinnedVerifiedBlueIds.contains(candidate)) { + victim = candidate; + break; + } + } + if (victim == null) { + return; + } + removeVerifiedEntry(victim); + verifiedEvictions++; + } + } + + private void recordTrustedInsertion(String blueId, FrozenNode node) { + trustedInsertionOrder.remove(blueId); + trustedInsertionOrder.add(blueId); + trustedCurrentWeight = saturatedAdd( + trustedCurrentWeight, + trustedWeight(blueId, node)); + trustedHighWaterWeight = Math.max(trustedHighWaterWeight, trustedCurrentWeight); + } + + private void recordStructuralInsertion(FrozenNode.ResolvedStructuralKey key, + FrozenNode node) { + long weight = structuralWeight(node); + if (readThroughParent == null + && (weight > cachePolicy.maximumDerivedEntryWeightBytes() + || weight > cachePolicy.resolvedStructuralMaxWeightBytes())) { + resolvedGraphNodesByStructure.remove(key, node); + structuralOversizedRejections++; + return; + } + structuralInsertionOrder.remove(key); + structuralInsertionOrder.add(key); + structuralCurrentWeight = saturatedAdd(structuralCurrentWeight, weight); + structuralHighWaterWeight = Math.max( + structuralHighWaterWeight, structuralCurrentWeight); + evictStructuralToBounds(); + } + + private void evictStructuralToBounds() { + if (readThroughParent != null) { + return; + } + while (resolvedGraphNodesByStructure.size() > cachePolicy.resolvedStructuralMaxEntries() + || structuralCurrentWeight > cachePolicy.resolvedStructuralMaxWeightBytes()) { + if (structuralInsertionOrder.isEmpty()) { + return; + } + FrozenNode.ResolvedStructuralKey victim = structuralInsertionOrder.iterator().next(); + removeStructuralEntry(victim); + structuralEvictions++; + } + } + + private void removeVerifiedEntry(String blueId) { + VerifiedReferenceEntry removed = entriesByBlueId.remove(blueId); + verifiedInsertionOrder.remove(blueId); + if (removed != null) { + verifiedCurrentWeight = subtractFloorZero( + verifiedCurrentWeight, verifiedWeight(blueId, removed)); + } + } + + private void removeTrustedEntry(String blueId) { + FrozenNode removed = transientTrustedCanonicalByBlueId.remove(blueId); + trustedInsertionOrder.remove(blueId); + if (removed != null) { + trustedCurrentWeight = subtractFloorZero( + trustedCurrentWeight, trustedWeight(blueId, removed)); + } + } + + private void removeStructuralEntry(FrozenNode.ResolvedStructuralKey key) { + FrozenNode removed = resolvedGraphNodesByStructure.remove(key); + structuralInsertionOrder.remove(key); + if (removed != null) { + structuralCurrentWeight = subtractFloorZero( + structuralCurrentWeight, structuralWeight(removed)); + } + } + + private void rebuildLocalWeightAccounting() { + rebuildLocalWeightAccounting(Collections.emptySet()); + } + + private void rebuildLocalWeightAccounting(Set retainedPinnedBlueIds) { + clearLocalWeightAccounting(); + pinnedVerifiedBlueIds.addAll(retainedPinnedBlueIds); + for (java.util.Map.Entry entry : entriesByBlueId.entrySet()) { + verifiedInsertionOrder.add(entry.getKey()); + verifiedCurrentWeight = saturatedAdd(verifiedCurrentWeight, + verifiedWeight(entry.getKey(), entry.getValue())); + } + for (java.util.Map.Entry entry + : transientTrustedCanonicalByBlueId.entrySet()) { + trustedInsertionOrder.add(entry.getKey()); + trustedCurrentWeight = saturatedAdd(trustedCurrentWeight, + trustedWeight(entry.getKey(), entry.getValue())); + } + for (java.util.Map.Entry entry + : resolvedGraphNodesByStructure.entrySet()) { + structuralInsertionOrder.add(entry.getKey()); + structuralCurrentWeight = saturatedAdd(structuralCurrentWeight, + structuralWeight(entry.getValue())); + } + verifiedHighWaterWeight = Math.max(verifiedHighWaterWeight, verifiedCurrentWeight); + trustedHighWaterWeight = Math.max(trustedHighWaterWeight, trustedCurrentWeight); + structuralHighWaterWeight = Math.max(structuralHighWaterWeight, structuralCurrentWeight); + } + + private void clearLocalWeightAccounting() { + pinnedVerifiedBlueIds.clear(); + verifiedInsertionOrder.clear(); + trustedInsertionOrder.clear(); + structuralInsertionOrder.clear(); + verifiedCurrentWeight = 0L; + trustedCurrentWeight = 0L; + structuralCurrentWeight = 0L; + } + + private long verifiedWeight(String blueId, VerifiedReferenceEntry entry) { + return saturatedAdd(128L + 2L * blueId.length(), + FrozenNode.approximateRetainedWeightBytesOf( + entry.canonicalContent, entry.fullyResolvedContent)); + } + + private long trustedWeight(String blueId, FrozenNode node) { + return saturatedAdd(96L + 2L * blueId.length(), + node.approximateRetainedWeightBytes()); + } + + private long structuralWeight(FrozenNode node) { + return saturatedAdd(64L, node.approximateShallowRetainedWeightBytes()); + } + + private static long saturatedAdd(long left, long right) { + return Long.MAX_VALUE - left < right ? Long.MAX_VALUE : left + right; + } + + private static int saturatedAdd(int left, int right) { + return Integer.MAX_VALUE - left < right ? Integer.MAX_VALUE : left + right; + } + + private static long subtractFloorZero(long left, long right) { + return right >= left ? 0L : left - right; + } + + /** Immutable approximate cache accounting for integration and lifecycle reports. */ + public CacheStats cacheStats() { + synchronized (cacheGeneration.mutationLock) { + if (readThroughParent != null) { + return localCacheStats(); + } + int verifiedEntries = 0; + int pinnedVerifiedEntries = 0; + long verifiedCurrentWeightBytes = 0L; + long verifiedHighWaterWeightBytes = 0L; + long verifiedEvictions = 0L; + long verifiedOversizedRejections = 0L; + int transientTrustedEntries = 0; + long transientTrustedCurrentWeightBytes = 0L; + long transientTrustedHighWaterWeightBytes = 0L; + int structuralEntries = 0; + long structuralCurrentWeightBytes = 0L; + long structuralHighWaterWeightBytes = 0L; + long structuralEvictions = 0L; + long structuralOversizedRejections = 0L; + for (ResolvedReferenceCache cache : cacheGeneration.liveCaches()) { + CacheStats local = cache.localCacheStats(); + verifiedEntries = saturatedAdd(verifiedEntries, local.verifiedEntries()); + pinnedVerifiedEntries = saturatedAdd( + pinnedVerifiedEntries, local.pinnedVerifiedEntries()); + verifiedCurrentWeightBytes = saturatedAdd( + verifiedCurrentWeightBytes, local.verifiedCurrentWeightBytes()); + verifiedHighWaterWeightBytes = saturatedAdd( + verifiedHighWaterWeightBytes, local.verifiedHighWaterWeightBytes()); + verifiedEvictions = saturatedAdd(verifiedEvictions, local.verifiedEvictions()); + verifiedOversizedRejections = saturatedAdd( + verifiedOversizedRejections, local.verifiedOversizedRejections()); + transientTrustedEntries = saturatedAdd( + transientTrustedEntries, local.transientTrustedEntries()); + transientTrustedCurrentWeightBytes = saturatedAdd( + transientTrustedCurrentWeightBytes, + local.transientTrustedCurrentWeightBytes()); + transientTrustedHighWaterWeightBytes = saturatedAdd( + transientTrustedHighWaterWeightBytes, + local.transientTrustedHighWaterWeightBytes()); + structuralEntries = saturatedAdd(structuralEntries, local.structuralEntries()); + structuralCurrentWeightBytes = saturatedAdd( + structuralCurrentWeightBytes, local.structuralCurrentWeightBytes()); + structuralHighWaterWeightBytes = saturatedAdd( + structuralHighWaterWeightBytes, local.structuralHighWaterWeightBytes()); + structuralEvictions = saturatedAdd( + structuralEvictions, local.structuralEvictions()); + structuralOversizedRejections = saturatedAdd( + structuralOversizedRejections, local.structuralOversizedRejections()); + } + cacheGeneration.verifiedHighWaterWeight = Math.max( + cacheGeneration.verifiedHighWaterWeight, verifiedHighWaterWeightBytes); + cacheGeneration.trustedHighWaterWeight = Math.max( + cacheGeneration.trustedHighWaterWeight, transientTrustedHighWaterWeightBytes); + cacheGeneration.structuralHighWaterWeight = Math.max( + cacheGeneration.structuralHighWaterWeight, structuralHighWaterWeightBytes); + return new CacheStats( + verifiedEntries, + pinnedVerifiedEntries, + verifiedCurrentWeightBytes, + cacheGeneration.verifiedHighWaterWeight, + verifiedEvictions, + verifiedOversizedRejections, + transientTrustedEntries, + transientTrustedCurrentWeightBytes, + cacheGeneration.trustedHighWaterWeight, + structuralEntries, + structuralCurrentWeightBytes, + cacheGeneration.structuralHighWaterWeight, + structuralEvictions, + structuralOversizedRejections); + } + } + + private CacheStats localCacheStats() { + return new CacheStats( + entriesByBlueId.size(), + pinnedVerifiedBlueIds.size(), + verifiedCurrentWeight, + verifiedHighWaterWeight, + verifiedEvictions, + verifiedOversizedRejections, + transientTrustedCanonicalByBlueId.size(), + trustedCurrentWeight, + trustedHighWaterWeight, + resolvedGraphNodesByStructure.size(), + structuralCurrentWeight, + structuralHighWaterWeight, + structuralEvictions, + structuralOversizedRejections); + } + public int size() { ensureCurrentGeneration(); return entriesByBlueId.size(); } + /** Approximate weight of caller-pinned verified entries retained across configuration refresh. */ + public long pinnedVerifiedWeightBytes() { + synchronized (cacheGeneration.mutationLock) { + ensureCurrentGeneration(); + long weight = 0L; + for (String blueId : pinnedVerifiedBlueIds) { + VerifiedReferenceEntry entry = entriesByBlueId.get(blueId); + if (entry != null) { + weight = saturatedAdd(weight, verifiedWeight(blueId, entry)); + } + } + return weight; + } + } + + /** + * Invalidates transient children and reloadable acceleration data while + * preserving caller-pinned verified content in the root cache. + */ + public void clearReloadable() { + synchronized (cacheGeneration.mutationLock) { + if (locallyClosed || cacheGeneration.closed) { + throw new IllegalStateException("Resolved reference cache is closed"); + } + if (readThroughParent != null) { + throw new IllegalStateException( + "Reloadable state can only be cleared from the root reference cache"); + } + retainLiveHighWaterMarks(); + Map retainedPinned = new HashMap<>(); + for (String blueId : pinnedVerifiedBlueIds) { + VerifiedReferenceEntry entry = entriesByBlueId.get(blueId); + if (entry != null) { + retainedPinned.put(blueId, entry); + } + } + Set retainedPinnedIds = new HashSet<>(retainedPinned.keySet()); + observedGeneration = cacheGeneration.value.incrementAndGet(); + for (ResolvedReferenceCache cache : cacheGeneration.liveCaches()) { + cache.clearLocalState(); + cache.observedGeneration = observedGeneration; + } + entriesByBlueId.putAll(retainedPinned); + rebuildLocalWeightAccounting(retainedPinnedIds); + } + } + /** Clears entries retained directly by this cache; inherited entries remain readable by a transient child. */ public void clear() { synchronized (cacheGeneration.mutationLock) { + if (locallyClosed || cacheGeneration.closed) { + throw new IllegalStateException("Resolved reference cache is closed"); + } + retainLiveHighWaterMarks(); if (readThroughParent == null) { observedGeneration = cacheGeneration.value.incrementAndGet(); + for (ResolvedReferenceCache cache : cacheGeneration.liveCaches()) { + cache.clearLocalState(); + cache.observedGeneration = observedGeneration; + } } else { observedGeneration = cacheGeneration.value.get(); + clearLocalState(); } - entriesByBlueId.clear(); - transientTrustedCanonicalByBlueId.clear(); - resolvedGraphNodesByStructure.clear(); } } @@ -480,11 +1033,15 @@ public int resolvedGraphSize() { /** Returns false when the parent cache has been invalidated since this child was opened. */ public boolean isCurrentGeneration() { - return readThroughParent == null - || openedGeneration == cacheGeneration.value.get(); + return !locallyClosed && !hasClosedAncestor() && !cacheGeneration.closed + && (readThroughParent == null + || openedGeneration == cacheGeneration.value.get()); } private void ensureCurrentGeneration() { + if (locallyClosed || hasClosedAncestor() || cacheGeneration.closed) { + throw new IllegalStateException("Resolved reference cache is closed"); + } long current = cacheGeneration.value.get(); if (observedGeneration == current) { return; @@ -497,10 +1054,100 @@ private void ensureCurrentGeneration() { entriesByBlueId.clear(); transientTrustedCanonicalByBlueId.clear(); resolvedGraphNodesByStructure.clear(); + clearLocalWeightAccounting(); observedGeneration = current; } } + /** + * Closes this cache handle. Closing a transient child releases that child + * scope and every descendant scope; closing the root permanently invalidates + * the shared generation and eagerly releases every live child. + */ + @Override + public void close() { + synchronized (cacheGeneration.mutationLock) { + if (locallyClosed) { + return; + } + if (readThroughParent != null) { + retainLiveHighWaterMarks(); + List closedScopes = new ArrayList<>(); + for (ResolvedReferenceCache cache : cacheGeneration.liveCaches()) { + if (cache == this || cache.isDescendantOf(this)) { + cache.locallyClosed = true; + cache.clearLocalState(); + closedScopes.add(cache); + } + } + for (ResolvedReferenceCache cache : closedScopes) { + cacheGeneration.unregister(cache); + } + return; + } + if (cacheGeneration.closed) { + locallyClosed = true; + clearLocalState(); + return; + } + retainLiveHighWaterMarks(); + cacheGeneration.closed = true; + cacheGeneration.value.incrementAndGet(); + for (ResolvedReferenceCache cache : cacheGeneration.liveCaches()) { + cache.locallyClosed = true; + cache.clearLocalState(); + } + cacheGeneration.caches.clear(); + } + } + + private void clearLocalState() { + entriesByBlueId.clear(); + transientTrustedCanonicalByBlueId.clear(); + resolvedGraphNodesByStructure.clear(); + clearLocalWeightAccounting(); + } + + /** Preserves aggregate lifetime peaks before a live scope is cleared or unregistered. */ + private void retainLiveHighWaterMarks() { + long verified = 0L; + long trusted = 0L; + long structural = 0L; + for (ResolvedReferenceCache cache : cacheGeneration.liveCaches()) { + verified = saturatedAdd(verified, cache.verifiedHighWaterWeight); + trusted = saturatedAdd(trusted, cache.trustedHighWaterWeight); + structural = saturatedAdd(structural, cache.structuralHighWaterWeight); + } + cacheGeneration.verifiedHighWaterWeight = Math.max( + cacheGeneration.verifiedHighWaterWeight, verified); + cacheGeneration.trustedHighWaterWeight = Math.max( + cacheGeneration.trustedHighWaterWeight, trusted); + cacheGeneration.structuralHighWaterWeight = Math.max( + cacheGeneration.structuralHighWaterWeight, structural); + } + + private boolean isDescendantOf(ResolvedReferenceCache ancestor) { + ResolvedReferenceCache current = readThroughParent; + while (current != null) { + if (current == ancestor) { + return true; + } + current = current.readThroughParent; + } + return false; + } + + private boolean hasClosedAncestor() { + ResolvedReferenceCache current = readThroughParent; + while (current != null) { + if (current.locallyClosed) { + return true; + } + current = current.readThroughParent; + } + return false; + } + private VerifiedReferenceEntry findEntry(String blueId) { ensureCurrentGeneration(); VerifiedReferenceEntry local = entriesByBlueId.get(blueId); @@ -545,6 +1192,81 @@ private void requireResolved(String blueId, FrozenNode resolvedContent) { } } + public static final class CacheStats { + private final int verifiedEntries; + private final int pinnedVerifiedEntries; + private final long verifiedCurrentWeightBytes; + private final long verifiedHighWaterWeightBytes; + private final long verifiedEvictions; + private final long verifiedOversizedRejections; + private final int transientTrustedEntries; + private final long transientTrustedCurrentWeightBytes; + private final long transientTrustedHighWaterWeightBytes; + private final int structuralEntries; + private final long structuralCurrentWeightBytes; + private final long structuralHighWaterWeightBytes; + private final long structuralEvictions; + private final long structuralOversizedRejections; + + private CacheStats(int verifiedEntries, + int pinnedVerifiedEntries, + long verifiedCurrentWeightBytes, + long verifiedHighWaterWeightBytes, + long verifiedEvictions, + long verifiedOversizedRejections, + int transientTrustedEntries, + long transientTrustedCurrentWeightBytes, + long transientTrustedHighWaterWeightBytes, + int structuralEntries, + long structuralCurrentWeightBytes, + long structuralHighWaterWeightBytes, + long structuralEvictions, + long structuralOversizedRejections) { + this.verifiedEntries = verifiedEntries; + this.pinnedVerifiedEntries = pinnedVerifiedEntries; + this.verifiedCurrentWeightBytes = verifiedCurrentWeightBytes; + this.verifiedHighWaterWeightBytes = verifiedHighWaterWeightBytes; + this.verifiedEvictions = verifiedEvictions; + this.verifiedOversizedRejections = verifiedOversizedRejections; + this.transientTrustedEntries = transientTrustedEntries; + this.transientTrustedCurrentWeightBytes = transientTrustedCurrentWeightBytes; + this.transientTrustedHighWaterWeightBytes = transientTrustedHighWaterWeightBytes; + this.structuralEntries = structuralEntries; + this.structuralCurrentWeightBytes = structuralCurrentWeightBytes; + this.structuralHighWaterWeightBytes = structuralHighWaterWeightBytes; + this.structuralEvictions = structuralEvictions; + this.structuralOversizedRejections = structuralOversizedRejections; + } + + public int verifiedEntries() { return verifiedEntries; } + + public int pinnedVerifiedEntries() { return pinnedVerifiedEntries; } + + public long verifiedCurrentWeightBytes() { return verifiedCurrentWeightBytes; } + + public long verifiedHighWaterWeightBytes() { return verifiedHighWaterWeightBytes; } + + public long verifiedEvictions() { return verifiedEvictions; } + + public long verifiedOversizedRejections() { return verifiedOversizedRejections; } + + public int transientTrustedEntries() { return transientTrustedEntries; } + + public long transientTrustedCurrentWeightBytes() { return transientTrustedCurrentWeightBytes; } + + public long transientTrustedHighWaterWeightBytes() { return transientTrustedHighWaterWeightBytes; } + + public int structuralEntries() { return structuralEntries; } + + public long structuralCurrentWeightBytes() { return structuralCurrentWeightBytes; } + + public long structuralHighWaterWeightBytes() { return structuralHighWaterWeightBytes; } + + public long structuralEvictions() { return structuralEvictions; } + + public long structuralOversizedRejections() { return structuralOversizedRejections; } + } + private static final class VerifiedReferenceEntry { private final FrozenNode canonicalContent; private final FrozenNode fullyResolvedContent; @@ -558,20 +1280,71 @@ private VerifiedReferenceEntry(FrozenNode canonicalContent, FrozenNode fullyReso } } + private static final class CanonicalLoadKey { + private final long generation; + private final String blueId; + + private CanonicalLoadKey(long generation, String blueId) { + this.generation = generation; + this.blueId = blueId; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof CanonicalLoadKey)) { + return false; + } + CanonicalLoadKey other = (CanonicalLoadKey) object; + return generation == other.generation && blueId.equals(other.blueId); + } + + @Override + public int hashCode() { + return 31 * Long.hashCode(generation) + blueId.hashCode(); + } + } + + private static final class CanonicalLoadFlight { + private final Thread owner; + private final CompletableFuture result = new CompletableFuture<>(); + + private CanonicalLoadFlight(Thread owner) { + this.owner = owner; + } + } + private static final class CacheGeneration { - private static final int LOADING_STRIPES = 64; private final AtomicLong value = new AtomicLong(); private final Object mutationLock = new Object(); - private final Object[] loadingLocks = new Object[LOADING_STRIPES]; + private volatile boolean closed; + private final ConcurrentMap canonicalLoads = + new ConcurrentHashMap<>(); + private final ThreadLocal> loadingStack = + ThreadLocal.withInitial(ArrayDeque::new); + private final Set caches = Collections.newSetFromMap( + new WeakHashMap()); + private long verifiedHighWaterWeight; + private long trustedHighWaterWeight; + private long structuralHighWaterWeight; - private CacheGeneration() { - for (int index = 0; index < loadingLocks.length; index++) { - loadingLocks[index] = new Object(); + private void register(ResolvedReferenceCache cache) { + synchronized (mutationLock) { + if (closed || cache.hasClosedAncestor()) { + throw new IllegalStateException("Resolved reference cache is closed"); + } + caches.add(cache); } } - private Object loadingLock(String blueId) { - return loadingLocks[(blueId.hashCode() & Integer.MAX_VALUE) % loadingLocks.length]; + private List liveCaches() { + return new ArrayList<>(caches); + } + + private void unregister(ResolvedReferenceCache target) { + caches.remove(target); } } } diff --git a/src/main/java/blue/language/utils/FrozenTypeMatcher.java b/src/main/java/blue/language/utils/FrozenTypeMatcher.java index 3a34e43..4b339bf 100644 --- a/src/main/java/blue/language/utils/FrozenTypeMatcher.java +++ b/src/main/java/blue/language/utils/FrozenTypeMatcher.java @@ -1,6 +1,7 @@ package blue.language.utils; import blue.language.Blue; +import blue.language.BlueCachePolicy; import blue.language.model.Node; import blue.language.model.Schema; import blue.language.snapshot.FrozenNode; @@ -8,10 +9,12 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import static blue.language.utils.Properties.*; @@ -26,12 +29,15 @@ */ public final class FrozenTypeMatcher { + private static final int CACHE_RESOLVED_REFERENCE = 1; + private static final int CACHE_SUBTYPE = 2; + private static final int CACHE_MATCH = 3; + private static final int CACHE_TYPE_COMPATIBILITY = 4; + private static final int CACHE_UNRESOLVED_REFERENCE = 5; + private static final Object PRESENT = new Object(); + private final Blue blue; - private final Map resolvedReferenceCache = new HashMap<>(); - private final Map subtypeCache = new HashMap<>(); - private final Map matchCache = new HashMap<>(); - private final Map typeCompatibilityIdentityCache = new HashMap<>(); - private final Set unresolvedReferenceCache = new HashSet<>(); + private final BoundedPlanCache planCache; private final boolean resolveCandidateReferences; public FrozenTypeMatcher(Blue blue) { @@ -39,8 +45,18 @@ public FrozenTypeMatcher(Blue blue) { } FrozenTypeMatcher(Blue blue, boolean resolveCandidateReferences) { + this(blue, + resolveCandidateReferences, + blue != null ? blue.cachePolicy() : BlueCachePolicy.boundedDefaults()); + } + + FrozenTypeMatcher(Blue blue, + boolean resolveCandidateReferences, + BlueCachePolicy cachePolicy) { this.blue = blue; this.resolveCandidateReferences = resolveCandidateReferences; + this.planCache = new BoundedPlanCache( + Objects.requireNonNull(cachePolicy, "cachePolicy")); } public boolean matchesType(FrozenNode resolvedNode, FrozenNode resolvedTargetType) { @@ -53,15 +69,37 @@ public boolean matchesType(FrozenNode resolvedNode, FrozenNode resolvedTargetTyp return matches(resolvedNode, resolvedTargetType); } + /** Releases every reloadable matching and type-resolution cache entry. */ + public void clearCaches() { + planCache.clear(); + } + + /** Returns the number of entries retained across all five matcher cache regions. */ + public int cacheEntryCount() { + return planCache.size(); + } + + /** Returns the approximate retained weight across all five matcher cache regions. */ + public long cacheWeightBytes() { + return planCache.currentWeightBytes(); + } + private boolean matches(FrozenNode node, FrozenNode target) { - MatchKey key = new MatchKey(node.resolvedStructuralKey(), target.resolvedStructuralKey()); - Boolean cached = matchCache.get(key); + MatchKey key = new MatchKey( + node.resolvedStructuralKey(), + target.resolvedStructuralKey(), + 0L); + Boolean cached = (Boolean) planCache.get(CACHE_MATCH, key); if (cached != null) { return cached; } boolean result = computeMatch(node, target); - matchCache.put(key, result); + MatchKey retainedKey = new MatchKey( + key.candidate, + key.target, + FrozenNode.approximateRetainedWeightBytesOf(node, target)); + planCache.put(CACHE_MATCH, retainedKey, result); return result; } @@ -628,13 +666,13 @@ private boolean isSubtype(FrozenNode candidateType, FrozenNode targetType) { return false; } String key = typeIdentity(candidateType) + "->" + typeIdentity(targetType); - Boolean cached = subtypeCache.get(key); + Boolean cached = (Boolean) planCache.get(CACHE_SUBTYPE, key); if (cached != null) { return cached; } boolean result = computeSubtype(candidateType, targetType); - subtypeCache.put(key, result); + planCache.put(CACHE_SUBTYPE, key, result); return result; } @@ -673,10 +711,10 @@ private FrozenNode resolveTypeReference(FrozenNode type) { if (CORE_TYPE_BLUE_IDS.contains(blueId)) { return coreType(blueId); } - if (unresolvedReferenceCache.contains(blueId)) { + if (planCache.get(CACHE_UNRESOLVED_REFERENCE, blueId) != null) { return null; } - FrozenNode cached = resolvedReferenceCache.get(blueId); + FrozenNode cached = (FrozenNode) planCache.get(CACHE_RESOLVED_REFERENCE, blueId); if (cached != null) { return cached; } @@ -686,11 +724,11 @@ private FrozenNode resolveTypeReference(FrozenNode type) { } catch (RuntimeException ex) { resolved = rawTypeDefinition(blueId); if (resolved == null) { - unresolvedReferenceCache.add(blueId); + planCache.put(CACHE_UNRESOLVED_REFERENCE, blueId, PRESENT); return null; } } - resolvedReferenceCache.put(blueId, resolved); + planCache.put(CACHE_RESOLVED_REFERENCE, blueId, resolved); return resolved; } @@ -710,12 +748,12 @@ private FrozenNode rawTypeDefinition(String blueId) { } private FrozenNode coreType(String blueId) { - FrozenNode cached = resolvedReferenceCache.get(blueId); + FrozenNode cached = (FrozenNode) planCache.get(CACHE_RESOLVED_REFERENCE, blueId); if (cached != null) { return cached; } FrozenNode core = FrozenNode.fromResolvedNode(new Node().blueId(blueId)); - resolvedReferenceCache.put(blueId, core); + planCache.put(CACHE_RESOLVED_REFERENCE, blueId, core); return core; } @@ -747,12 +785,12 @@ private String typeCompatibilityIdentity(FrozenNode type) { return identityBlueId; } String cacheKey = typeIdentity(resolved) + "|" + resolved.blueId(); - String cached = typeCompatibilityIdentityCache.get(cacheKey); + String cached = (String) planCache.get(CACHE_TYPE_COMPATIBILITY, cacheKey); if (cached != null) { return cached; } String identity = BlueIdCalculator.calculateBlueId(labelNeutralNode(resolved.toNode())); - typeCompatibilityIdentityCache.put(cacheKey, identity); + planCache.put(CACHE_TYPE_COMPATIBILITY, cacheKey, identity); return identity; } @@ -832,14 +870,133 @@ private boolean isDictionaryType(FrozenNode type) { return isSubtype(type, coreType(DICTIONARY_TYPE_BLUE_ID)); } + private static final class BoundedPlanCache { + private final int maximumEntries; + private final long maximumWeightBytes; + private final long maximumEntryWeightBytes; + private final LinkedHashMap entries = + new LinkedHashMap(16, 0.75f, true); + private long currentWeightBytes; + + private BoundedPlanCache(BlueCachePolicy policy) { + this.maximumEntries = policy.conformancePlanMaxEntries(); + this.maximumWeightBytes = policy.conformancePlanMaxWeightBytes(); + this.maximumEntryWeightBytes = Math.min( + policy.maximumDerivedEntryWeightBytes(), maximumWeightBytes); + } + + private synchronized Object get(int region, Object key) { + CacheEntry entry = entries.get(new PlanCacheKey(region, key)); + return entry != null ? entry.value : null; + } + + private synchronized void put(int region, Object key, Object value) { + PlanCacheKey cacheKey = new PlanCacheKey(region, key); + long weight = estimateWeight(cacheKey, value); + if (weight > maximumEntryWeightBytes || weight > maximumWeightBytes) { + return; + } + CacheEntry previous = entries.remove(cacheKey); + if (previous != null) { + currentWeightBytes -= previous.weightBytes; + } + entries.put(cacheKey, new CacheEntry(value, weight)); + currentWeightBytes = saturatedAdd(currentWeightBytes, weight); + evictToBounds(); + } + + private synchronized void clear() { + entries.clear(); + currentWeightBytes = 0L; + } + + private synchronized int size() { + return entries.size(); + } + + private synchronized long currentWeightBytes() { + return currentWeightBytes; + } + + private void evictToBounds() { + Iterator> iterator = entries.entrySet().iterator(); + while ((entries.size() > maximumEntries + || currentWeightBytes > maximumWeightBytes) && iterator.hasNext()) { + CacheEntry eldest = iterator.next().getValue(); + currentWeightBytes -= eldest.weightBytes; + iterator.remove(); + } + } + + private long estimateWeight(PlanCacheKey key, Object value) { + long weight = 80L + retainedWeight(key.key); + return saturatedAdd(weight, retainedWeight(value)); + } + + private long retainedWeight(Object value) { + if (value == null || value == PRESENT || value instanceof Boolean) { + return 16L; + } + if (value instanceof String) { + return 48L + 2L * ((String) value).length(); + } + if (value instanceof FrozenNode) { + return ((FrozenNode) value).approximateRetainedWeightBytes(); + } + if (value instanceof MatchKey) { + return ((MatchKey) value).retainedWeightBytes; + } + return 128L; + } + + private long saturatedAdd(long left, long right) { + return Long.MAX_VALUE - left < right ? Long.MAX_VALUE : left + right; + } + } + + private static final class PlanCacheKey { + private final int region; + private final Object key; + + private PlanCacheKey(int region, Object key) { + this.region = region; + this.key = Objects.requireNonNull(key, "key"); + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof PlanCacheKey + && region == ((PlanCacheKey) other).region + && key.equals(((PlanCacheKey) other).key); + } + + @Override + public int hashCode() { + return 31 * region + key.hashCode(); + } + } + + private static final class CacheEntry { + private final Object value; + private final long weightBytes; + + private CacheEntry(Object value, long weightBytes) { + this.value = Objects.requireNonNull(value, "value"); + this.weightBytes = weightBytes; + } + } + private static final class MatchKey { private final FrozenNode.ResolvedStructuralKey candidate; private final FrozenNode.ResolvedStructuralKey target; + private final long retainedWeightBytes; private MatchKey(FrozenNode.ResolvedStructuralKey candidate, - FrozenNode.ResolvedStructuralKey target) { + FrozenNode.ResolvedStructuralKey target, + long retainedWeightBytes) { this.candidate = candidate; this.target = target; + this.retainedWeightBytes = retainedWeightBytes; } @Override diff --git a/src/main/java/blue/language/utils/NodePathAccessor.java b/src/main/java/blue/language/utils/NodePathAccessor.java index 910ae91..5529382 100644 --- a/src/main/java/blue/language/utils/NodePathAccessor.java +++ b/src/main/java/blue/language/utils/NodePathAccessor.java @@ -83,7 +83,7 @@ private static Node getNodeForSegment(Node node, String segment, Function items = node.getItems(); if (items == null || itemIndex >= items.size()) { @@ -123,7 +123,7 @@ private static Node getStructuralNodeForSegment(Node node, String segment) { return node.getContracts(); } - if (segment.matches("\\d+")) { + if (isAsciiDigits(segment)) { int itemIndex = Integer.parseInt(segment); List items = node.getItems(); if (items == null || itemIndex >= items.size()) { @@ -139,6 +139,19 @@ private static Node getStructuralNodeForSegment(Node node, String segment) { return properties.get(segment); } + private static boolean isAsciiDigits(String value) { + if (value == null || value.isEmpty()) { + return false; + } + for (int index = 0; index < value.length(); index++) { + char digit = value.charAt(index); + if (digit < '0' || digit > '9') { + return false; + } + } + return true; + } + private static Node link(Node node, Function linkingProvider) { Node linked = linkingProvider.apply(node); return linked == null ? node : linked; diff --git a/src/main/java/blue/language/utils/TypeClassResolver.java b/src/main/java/blue/language/utils/TypeClassResolver.java index f1f73a1..2c6965f 100644 --- a/src/main/java/blue/language/utils/TypeClassResolver.java +++ b/src/main/java/blue/language/utils/TypeClassResolver.java @@ -8,14 +8,72 @@ import org.reflections.util.ConfigurationBuilder; import org.reflections.util.FilterBuilder; +import java.util.AbstractMap; +import java.util.AbstractSet; import java.util.Collections; import java.util.HashMap; +import java.util.Iterator; import java.util.Map; import java.util.Set; public class TypeClassResolver { private final Map> blueIdMap = new HashMap<>(); + private final Map> blueIdView = Collections.unmodifiableMap( + new AbstractMap>() { + private final Set>> entries = + new AbstractSet>>() { + @Override + public Iterator>> iterator() { + synchronized (TypeClassResolver.this) { + return Collections.unmodifiableMap( + new HashMap<>(blueIdMap)) + .entrySet() + .iterator(); + } + } + + @Override + public int size() { + synchronized (TypeClassResolver.this) { + return blueIdMap.size(); + } + } + + @Override + public boolean contains(Object entry) { + synchronized (TypeClassResolver.this) { + return blueIdMap.entrySet().contains(entry); + } + } + }; + + @Override + public Class get(Object key) { + synchronized (TypeClassResolver.this) { + return blueIdMap.get(key); + } + } + + @Override + public boolean containsKey(Object key) { + synchronized (TypeClassResolver.this) { + return blueIdMap.containsKey(key); + } + } + + @Override + public int size() { + synchronized (TypeClassResolver.this) { + return blueIdMap.size(); + } + } + + @Override + public Set>> entrySet() { + return entries; + } + }); public TypeClassResolver() { } @@ -26,7 +84,7 @@ public TypeClassResolver(String... packagesToScan) { } } - public TypeClassResolver scanPackage(String packageName) { + public synchronized TypeClassResolver scanPackage(String packageName) { Reflections reflections = new Reflections(new ConfigurationBuilder() .setUrls(ClasspathHelper.forPackage(packageName)) .filterInputsBy(new FilterBuilder().includePackage(packageName)) @@ -40,7 +98,7 @@ public TypeClassResolver scanPackage(String packageName) { return this; } - public TypeClassResolver registerAnnotatedClass(Class clazz) { + public synchronized TypeClassResolver registerAnnotatedClass(Class clazz) { TypeBlueId annotation = clazz.getAnnotation(TypeBlueId.class); if (annotation == null) { throw new IllegalArgumentException("Class lacks @TypeBlueId: " + clazz.getName()); @@ -65,7 +123,7 @@ public TypeClassResolver registerAnnotatedClass(Class clazz) { return this; } - public TypeClassResolver register(String blueId, Class clazz) { + public synchronized TypeClassResolver register(String blueId, Class clazz) { if (blueId == null || blueId.isEmpty()) { throw new IllegalArgumentException("blueId must not be empty"); } @@ -80,7 +138,7 @@ public TypeClassResolver register(String blueId, Class clazz) { return this; } - public Class resolveClass(Node node) { + public synchronized Class resolveClass(Node node) { String blueId = getEffectiveBlueId(node); if (blueId == null) { return null; @@ -89,7 +147,7 @@ public Class resolveClass(Node node) { return resolveClass(blueId); } - public Class resolveClass(String blueId) { + public synchronized Class resolveClass(String blueId) { return blueIdMap.get(blueId); } @@ -102,7 +160,8 @@ private String getEffectiveBlueId(Node node) { return null; } - public Map> getBlueIdMap() { - return Collections.unmodifiableMap(blueIdMap); + public synchronized Map> getBlueIdMap() { + return blueIdView; } + } diff --git a/src/test/java/blue/language/BlueCacheLifecycleTest.java b/src/test/java/blue/language/BlueCacheLifecycleTest.java new file mode 100644 index 0000000..4e19ddd --- /dev/null +++ b/src/test/java/blue/language/BlueCacheLifecycleTest.java @@ -0,0 +1,1357 @@ +package blue.language; + +import blue.language.conformance.ConformanceEngine; +import blue.language.model.Node; +import blue.language.merge.MergingProcessor; +import blue.language.merge.NodeResolver; +import blue.language.processor.ProcessingMetricsSnapshot; +import blue.language.processor.ProcessingMetricsSink; +import blue.language.processor.ProcessingSnapshotManager; +import blue.language.processor.RecordingProcessingMetricsSink; +import blue.language.processor.ContractProcessor; +import blue.language.processor.DocumentProcessor; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.model.Contract; +import blue.language.processor.model.MarkerContract; +import blue.language.provider.BasicNodeProvider; +import blue.language.snapshot.ResolvedSnapshot; +import blue.language.utils.BlueIdCalculator; +import blue.language.utils.NodeProviderWrapper; +import blue.language.utils.limits.Limits; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BlueCacheLifecycleTest { + + @Test + void derivedSnapshotsAreWeightAndEntryBoundedWithoutChangingReloadIdentity() { + BlueCachePolicy policy = BlueCachePolicy.builder() + .derivedSnapshots(2, 1024L * 1024L) + .canonicalAliases(2, 1024L) + .maximumDerivedEntryWeightBytes(1024L * 1024L) + .build(); + Blue blue = Blue.withCachePolicy(policy); + ResolvedSnapshot first = null; + + for (int index = 0; index < 6; index++) { + ResolvedSnapshot snapshot = blue.resolveToSnapshot(document(index)); + if (index == 0) { + first = snapshot; + } + } + + BlueCacheStats.Region derived = blue.cacheStats().region("derivedResolvedSnapshots"); + assertTrue(derived.entries() <= 2); + assertTrue(derived.evictions() >= 4L); + ResolvedSnapshot reloaded = blue.resolveToSnapshot(first.canonicalRoot()); + assertEquals(first.blueId(), reloaded.blueId()); + assertEquals(blue.nodeToJson(first.resolvedRoot()), blue.nodeToJson(reloaded.resolvedRoot())); + } + + @Test + void publicAuthoritativeSnapshotRegistrationRemainsPinnedAcrossDerivedEviction() { + BlueCachePolicy policy = BlueCachePolicy.builder() + .derivedSnapshots(1, 1024L * 1024L) + .canonicalAliases(1, 1024L) + .maximumDerivedEntryWeightBytes(1024L * 1024L) + .build(); + Blue blue = Blue.withCachePolicy(policy); + ResolvedSnapshot authoritative = blue.resolveToSnapshot(document(10)); + blue.clearResolvedSnapshotCache(); + blue.cacheResolvedSnapshot(authoritative); + + for (int index = 0; index < 5; index++) { + blue.resolveToSnapshot(document(100 + index)); + } + + ResolvedSnapshot loaded = blue.loadSnapshot(authoritative.canonicalRoot()); + assertSame(authoritative, loaded); + assertEquals(1, blue.cacheStats().region("pinnedAuthoritativeSnapshots").entries()); + assertTrue(blue.cacheStats().region("derivedResolvedSnapshots").entries() <= 1); + } + + @Test + void configurationRefreshPreservesCallerPinnedAuthoritativeContent() { + Blue blue = new Blue(node -> null); + ResolvedSnapshot authoritative = blue.resolveToSnapshot(document(17)); + blue.cacheResolvedSnapshot(authoritative); + + blue.preprocessingAliases(Collections.singletonMap("alias", authoritative.blueId())); + blue.setGlobalLimits(Limits.NO_LIMITS); + blue.nodeProvider(node -> null); + + ResolvedSnapshot loaded = blue.loadSnapshot(authoritative.blueId()); + assertEquals(authoritative.blueId(), loaded.blueId()); + assertEquals(blue.nodeToJson(authoritative.resolvedRoot()), + blue.nodeToJson(loaded.resolvedRoot())); + assertTrue(blue.cacheStats().region("pinnedAuthoritativeSnapshots").entries() > 0); + } + + @Test + void refreshedProcessorRetainsSharedBorrowedRegistryAndTypeMappingsSafely() { + DocumentProcessor shared = new DocumentProcessor(); + Blue first = new Blue().documentProcessor(shared); + Blue second = new Blue().documentProcessor(shared); + + first.nodeProvider(node -> null); + DocumentProcessor refreshed = first.getDocumentProcessor(); + assertSame(shared.getContractRegistry(), refreshed.getContractRegistry()); + assertSame(shared.getContractTypeResolver(), refreshed.getContractTypeResolver()); + + RegistrationMarkerProcessor processor = new RegistrationMarkerProcessor(); + second.registerContractProcessor("shared-registration", processor); + + assertSame(processor, + refreshed.getContractRegistry().processors().get("shared-registration")); + assertSame(RegistrationMarker.class, + refreshed.getContractTypeResolver().resolveClass("shared-registration")); + } + + @Test + void ordinaryCacheClearKeepsOwnedProcessorUsable() { + Blue blue = new Blue(); + DocumentProcessor processor = blue.getDocumentProcessor(); + + blue.clearResolvedSnapshotCache(); + + assertFalse(processor.isClosed()); + assertTrue(blue.initializeDocument(new Node()).document() != null); + } + + @Test + void injectedProcessorRemainsBorrowedAcrossRuntimeClose() { + DocumentProcessor shared = new DocumentProcessor(); + Blue first = new Blue().documentProcessor(shared); + Blue second = new Blue().documentProcessor(shared); + + first.close(); + + assertFalse(shared.isClosed()); + assertSame(shared, second.getDocumentProcessor()); + second.initializeDocument(new Node()); + second.close(); + assertFalse(shared.isClosed()); + } + + @Test + void injectingBorrowedProcessorClosesOnlyDisplacedOwnedProcessor() { + Blue blue = new Blue(); + DocumentProcessor owned = blue.getDocumentProcessor(); + owned.markersFor(new Node(), "/"); + DocumentProcessor borrowed = new DocumentProcessor(); + + blue.documentProcessor(borrowed); + + assertTrue(owned.isClosed()); + assertEquals(0, owned.cacheEntryCount()); + assertFalse(borrowed.isClosed()); + blue.close(); + assertFalse(borrowed.isClosed()); + } + + @Test + void reinjectingSameOwnedProcessorDoesNotLaunderOwnership() { + Blue blue = new Blue(); + DocumentProcessor owned = blue.getDocumentProcessor(); + + blue.documentProcessor(owned); + blue.close(); + + assertTrue(owned.isClosed()); + assertThrows(IllegalStateException.class, + () -> owned.markersFor(new Node(), "/")); + } + + @Test + void aliasAndLimitChangesPreserveBorrowedProcessorOwnership() { + DocumentProcessor borrowed = new DocumentProcessor(); + Blue blue = new Blue().documentProcessor(borrowed); + + blue.addPreprocessingAliases(Collections.singletonMap("one", "value")); + assertSame(borrowed, blue.getDocumentProcessor()); + blue.preprocessingAliases(Collections.singletonMap("two", "value")); + assertSame(borrowed, blue.getDocumentProcessor()); + blue.setGlobalLimits(Limits.NO_LIMITS); + assertSame(borrowed, blue.getDocumentProcessor()); + + blue.close(); + assertFalse(borrowed.isClosed()); + } + + @Test + void reentrantMetricsCloseIsRejectedWithoutDeadlockOrImplicitShutdown() { + Blue blue = new Blue(); + AtomicBoolean closeOnce = new AtomicBoolean(); + blue.getDocumentProcessor().processingMetricsSink(new ProcessingMetricsSink() { + @Override + public void setCacheCurrentWeightBytes(String cacheName, long bytes) { + if (closeOnce.compareAndSet(false, true)) { + blue.close(); + } + } + }); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> blue.resolveToSnapshot(document(1))); + + assertEquals("Blue runtime cannot close from active runtime work", + failure.getMessage()); + assertFalse(blue.isClosed()); + blue.close(); + assertTrue(blue.isClosed()); + assertEquals(0, blue.cacheStats().entries()); + assertEquals(0L, blue.cacheStats().currentWeightBytes()); + } + + @Test + void closeTimeMetricsMayReenterCloseWithoutRecursion() { + Blue blue = new Blue(); + AtomicInteger callbacks = new AtomicInteger(); + blue.getDocumentProcessor().processingMetricsSink(new ProcessingMetricsSink() { + @Override + public void incrementRuntimeCloseCalls() { + callbacks.incrementAndGet(); + blue.close(); + } + }); + + blue.close(); + + assertTrue(blue.isClosed()); + assertEquals(1, callbacks.get()); + assertEquals(0, blue.cacheStats().entries()); + } + + @Test + void concurrentCloseWaitsForOwnedProcessorRelease() throws Exception { + BlockingCloseDocumentProcessor processor = new BlockingCloseDocumentProcessor(); + Blue blue = new Blue().documentProcessor(processor); + Field ownership = Blue.class.getDeclaredField("documentProcessorOwned"); + ownership.setAccessible(true); + ownership.setBoolean(blue, true); + AtomicReference failure = new AtomicReference<>(); + CountDownLatch secondReturned = new CountDownLatch(1); + Thread first = new Thread(() -> { + try { + blue.close(); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } + }); + first.start(); + assertTrue(processor.closeEntered.await(5L, TimeUnit.SECONDS)); + Thread second = new Thread(() -> { + try { + blue.close(); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + secondReturned.countDown(); + } + }); + second.start(); + + assertFalse(secondReturned.await(200L, TimeUnit.MILLISECONDS)); + assertFalse(processor.isClosed()); + processor.allowClose.countDown(); + first.join(TimeUnit.SECONDS.toMillis(5L)); + second.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(first.isAlive()); + assertFalse(second.isAlive()); + assertNull(failure.get()); + assertTrue(processor.isClosed()); + } + + @Test + void concurrentClosersQueuedBehindInvalidationShareOneCloseCompletion() throws Exception { + BlockingCloseDocumentProcessor processor = new BlockingCloseDocumentProcessor(); + Blue blue = new Blue().documentProcessor(processor); + Field ownership = Blue.class.getDeclaredField("documentProcessorOwned"); + ownership.setAccessible(true); + ownership.setBoolean(blue, true); + AtomicReference failure = new AtomicReference<>(); + + Thread clearing = new Thread(() -> { + try { + blue.clearResolvedSnapshotCache(); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } + }); + clearing.start(); + assertTrue(processor.clearEntered.await(5L, TimeUnit.SECONDS)); + + CountDownLatch closersStarted = new CountDownLatch(2); + CountDownLatch anyCloserReturned = new CountDownLatch(1); + Thread first = closingThread(blue, failure, closersStarted, anyCloserReturned); + Thread second = closingThread(blue, failure, closersStarted, anyCloserReturned); + first.start(); + second.start(); + assertTrue(closersStarted.await(5L, TimeUnit.SECONDS)); + assertFalse(anyCloserReturned.await(200L, TimeUnit.MILLISECONDS)); + + processor.allowClear.countDown(); + assertTrue(processor.closeEntered.await(5L, TimeUnit.SECONDS)); + assertFalse(anyCloserReturned.await(200L, TimeUnit.MILLISECONDS), + "all concurrent close callers must await the owned close cleanup"); + + processor.allowClose.countDown(); + clearing.join(TimeUnit.SECONDS.toMillis(5L)); + first.join(TimeUnit.SECONDS.toMillis(5L)); + second.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(clearing.isAlive()); + assertFalse(first.isAlive()); + assertFalse(second.isAlive()); + assertNull(failure.get()); + assertTrue(processor.isClosed()); + } + + private static Thread closingThread(Blue blue, + AtomicReference failure, + CountDownLatch started, + CountDownLatch returned) { + return new Thread(() -> { + started.countDown(); + try { + blue.close(); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + returned.countDown(); + } + }); + } + + @Test + void oversizedDerivedSnapshotIsUsableButNotRetainedAndCanStillBePinned() { + BlueCachePolicy policy = BlueCachePolicy.builder() + .derivedSnapshots(4, 4096L) + .maximumDerivedEntryWeightBytes(64L) + .build(); + Blue blue = Blue.withCachePolicy(policy); + + ResolvedSnapshot snapshot = blue.resolveToSnapshot(document(1)); + + assertEquals(0, blue.cacheStats().region("derivedResolvedSnapshots").entries()); + assertEquals(1L, + blue.cacheStats().region("derivedResolvedSnapshots").oversizedRejections()); + blue.cacheResolvedSnapshot(snapshot); + assertEquals(1, blue.cacheStats().region("pinnedAuthoritativeSnapshots").entries()); + } + + @Test + void closeIsIdempotentReleasesOwnedStateAndRejectsRuntimeWork() { + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + Blue blue = Blue.withCachePolicy(BlueCachePolicy.boundedDefaults()); + DocumentProcessor leakedProcessor = blue.getDocumentProcessor(); + leakedProcessor.processingMetricsSink(metrics); + ResolvedSnapshot snapshot = blue.resolveToSnapshot(document(1)); + blue.cacheResolvedSnapshot(snapshot); + assertTrue(blue.cacheStats().currentWeightBytes() > 0L); + + blue.close(); + blue.close(); + + assertTrue(blue.isClosed()); + assertEquals(0L, blue.cacheStats().currentWeightBytes()); + assertEquals(0, blue.cacheStats().entries()); + assertThrows(IllegalStateException.class, + () -> blue.resolveToSnapshot(document(2))); + assertThrows(IllegalStateException.class, + () -> blue.cacheResolvedSnapshot(snapshot)); + assertThrows(IllegalStateException.class, + () -> blue.cacheResolvedSnapshots(Collections.emptyList())); + assertThrows(IllegalStateException.class, blue::clearResolvedSnapshotCache); + assertThrows(IllegalStateException.class, + () -> blue.registerTypeDictionaries(Collections.emptyList())); + assertThrows(IllegalStateException.class, + () -> blue.registerExternalContractType("closed", null, null)); + assertThrows(IllegalStateException.class, + () -> blue.isInitialized(document(2))); + assertThrows(IllegalStateException.class, + () -> blue.isInitialized(snapshot)); + assertThrows(IllegalStateException.class, + () -> blue.resolvePreservingPaths(document(2), + Limits.NO_LIMITS, + Collections.singletonList("/"))); + assertThrows(IllegalStateException.class, + () -> blue.nodeMatchesType(new Node(), new Node())); + assertThrows(IllegalStateException.class, + () -> blue.nodeMatchesType( + snapshot.frozenResolvedRoot(), snapshot.frozenResolvedRoot())); + assertThrows(IllegalStateException.class, + () -> blue.nodeMatchesType( + snapshot, "/", snapshot.frozenResolvedRoot())); + assertThrows(IllegalStateException.class, + () -> blue.extend(document(2), Limits.NO_LIMITS)); + assertThrows(IllegalStateException.class, + () -> blue.preprocess(document(2))); + assertThrows(IllegalStateException.class, + () -> blue.yamlToNode("value: 2")); + assertThrows(IllegalStateException.class, + () -> blue.jsonToNode("{\"value\":2}")); + assertThrows(IllegalStateException.class, + () -> blue.determineClass(document(2))); + assertThrows(IllegalStateException.class, + () -> blue.nodeToObject(document(2), Node.class)); + assertThrows(IllegalStateException.class, + () -> blue.isNodeSubtypeOf(document(2), document(3))); + assertThrows(IllegalStateException.class, + () -> blue.cachedResolvedSnapshot(snapshot.blueId())); + assertThrows(IllegalStateException.class, blue::conformanceEngine); + assertThrows(IllegalStateException.class, + () -> leakedProcessor.initializeDocument(document(4)), + "a processor handle obtained before close must observe cache invalidation"); + assertThrows(IllegalStateException.class, + () -> leakedProcessor.markersFor(new Node(), "/"), + "a leaked processor handle must not repopulate owned caches after runtime close"); + assertTrue(leakedProcessor.isClosed()); + assertFalse(leakedProcessor.supportsSnapshotProcessing(), + "closed leaked handles must detach the runtime snapshot collaborator"); + assertEquals(0, leakedProcessor.cacheEntryCount()); + assertTrue(blue.nodeToJson(document(3)).contains("value"), + "pure serialization remains available after close"); + assertEquals("3", blue.parseSourceJson("{\"value\":3}").getValue().toString()); + + ProcessingMetricsSnapshot recorded = metrics.snapshot(); + assertEquals(2L, recorded.counter("runtimeCloseCalls")); + assertTrue(recorded.counter("runtimeCloseReleasedWeightBytes") > 0L); + } + + @Test + void closeDoesNotDeadlockWithConcurrentCacheReaders() throws Exception { + Blue blue = Blue.withCachePolicy(BlueCachePolicy.boundedDefaults()); + blue.resolveToSnapshot(document(1)); + Thread reader = new Thread(() -> { + for (int index = 0; index < 10_000 && !blue.isClosed(); index++) { + blue.cacheStats(); + } + }); + reader.start(); + + blue.close(); + reader.join(TimeUnit.SECONDS.toMillis(5L)); + + assertTrue(!reader.isAlive(), "cache reader must finish when close completes"); + } + + @Test + void closeFromPreservedPathPredicateIsRejectedForTheWholeCompositeOperation() { + Blue blue = new Blue(); + AtomicReference closeFailure = new AtomicReference<>(); + + Node resolved = blue.resolvePreservingMatchingPaths( + document(5), + Collections.singletonList("/value"), + node -> { + closeFailure.set(assertThrows(IllegalStateException.class, blue::close)); + return true; + }); + + assertEquals("Blue runtime cannot close from active runtime work", + closeFailure.get().getMessage()); + assertFalse(blue.isClosed()); + assertTrue(resolved != null); + blue.close(); + } + + @Test + void closeWaitsForLazyProcessorPublicationAndReleasesThePublishedProcessor() throws Exception { + BlockingProviderBlue blue = new BlockingProviderBlue(); + Field processorField = Blue.class.getDeclaredField("documentProcessor"); + processorField.setAccessible(true); + processorField.set(blue, null); + blue.blockProvider = true; + AtomicReference failure = new AtomicReference<>(); + Thread getter = new Thread(() -> { + try { + blue.getDocumentProcessor(); + } catch (Throwable throwable) { + failure.set(throwable); + } + }); + getter.start(); + assertTrue(blue.providerEntered.await(5L, TimeUnit.SECONDS)); + + CountDownLatch closeStarted = new CountDownLatch(1); + CountDownLatch closeReturned = new CountDownLatch(1); + Thread closer = new Thread(() -> { + closeStarted.countDown(); + blue.close(); + closeReturned.countDown(); + }); + closer.start(); + assertTrue(closeStarted.await(5L, TimeUnit.SECONDS)); + assertFalse(closeReturned.await(200L, TimeUnit.MILLISECONDS), + "close must serialize with an in-flight lazy processor publication"); + + blue.releaseProvider.countDown(); + getter.join(TimeUnit.SECONDS.toMillis(5L)); + closer.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(getter.isAlive()); + assertFalse(closer.isAlive()); + assertNull(failure.get()); + assertTrue(blue.isClosed()); + assertNull(processorField.get(blue)); + assertEquals(0, blue.cacheStats().entries()); + assertEquals(0L, blue.cacheStats().currentWeightBytes()); + } + + @Test + void closeWaitsForAdmittedOwnedProcessingThenReleasesItsPublication() throws Exception { + Node completedDocument = document(42); + ResolvedSnapshot completedSnapshot = new ResolvedSnapshot( + completedDocument, + completedDocument.clone(), + BlueIdCalculator.calculateBlueId(completedDocument)); + BlockingDocumentProcessor processor = new BlockingDocumentProcessor(completedSnapshot); + Blue blue = new Blue().documentProcessor(processor); + Field ownership = Blue.class.getDeclaredField("documentProcessorOwned"); + ownership.setAccessible(true); + ownership.setBoolean(blue, true); + AtomicReference failure = new AtomicReference<>(); + Thread processing = new Thread(() -> { + try { + blue.processDocument(document(1), new Node().value("event")); + } catch (Throwable throwable) { + failure.set(throwable); + } + }); + processing.start(); + assertTrue(processor.entered.await(5L, TimeUnit.SECONDS)); + + CountDownLatch closeReturned = new CountDownLatch(1); + Thread closing = new Thread(() -> { + try { + blue.close(); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + closeReturned.countDown(); + } + }); + closing.start(); + assertFalse(closeReturned.await(200L, TimeUnit.MILLISECONDS)); + processor.release.countDown(); + processing.join(TimeUnit.SECONDS.toMillis(5L)); + closing.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(processing.isAlive()); + assertFalse(closing.isAlive()); + assertNull(failure.get()); + assertTrue(blue.isClosed()); + assertTrue(processor.isClosed()); + assertEquals(0, blue.cacheStats().region("recentProcessingSnapshots").entries()); + assertEquals(0L, + blue.cacheStats().region("recentProcessingSnapshots").currentWeightBytes()); + } + + @Test + void closeWaitsForAdmittedDirectResolutionBeforeReleasingCaches() throws Exception { + Node canonical = document(52); + String blueId = BlueIdCalculator.calculateBlueId(canonical); + CountDownLatch providerEntered = new CountDownLatch(1); + CountDownLatch releaseProvider = new CountDownLatch(1); + Blue blue = new Blue(requestedBlueId -> { + providerEntered.countDown(); + try { + if (!releaseProvider.await(5L, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting to release snapshot provider"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + return Collections.singletonList(canonical.clone()); + }); + AtomicReference failure = new AtomicReference<>(); + AtomicReference result = new AtomicReference<>(); + Thread resolving = new Thread(() -> { + try { + result.set(blue.loadSnapshot(blueId)); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } + }); + resolving.start(); + assertTrue(providerEntered.await(5L, TimeUnit.SECONDS)); + + CountDownLatch closeReturned = new CountDownLatch(1); + Thread closing = new Thread(() -> { + try { + blue.close(); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + closeReturned.countDown(); + } + }); + closing.start(); + assertFalse(closeReturned.await(200L, TimeUnit.MILLISECONDS)); + assertThrows(IllegalStateException.class, () -> blue.loadSnapshot(blueId), + "close must reject new work while draining the admitted resolution"); + + releaseProvider.countDown(); + resolving.join(TimeUnit.SECONDS.toMillis(5L)); + closing.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(resolving.isAlive()); + assertFalse(closing.isAlive()); + assertNull(failure.get()); + assertEquals(blueId, result.get().blueId()); + assertTrue(blue.isClosed()); + assertEquals(0, blue.cacheStats().entries()); + } + + @Test + void closeWaitsAcrossCompositeObjectConversionAndRuntimePhase() throws Exception { + BlockingObjectConversionBlue blue = new BlockingObjectConversionBlue(); + Map source = new HashMap<>(); + source.put("payload", "composite-operation"); + AtomicReference result = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + Thread resolving = new Thread(() -> { + try { + result.set(blue.resolveToSnapshot((Object) source)); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } + }); + resolving.start(); + assertTrue(blue.conversionCompleted.await(5L, TimeUnit.SECONDS)); + + CountDownLatch closeReturned = new CountDownLatch(1); + Thread closing = new Thread(() -> { + try { + blue.close(); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + closeReturned.countDown(); + } + }); + closing.start(); + assertFalse(closeReturned.await(200L, TimeUnit.MILLISECONDS), + "close must wait across conversion and the runtime-backed second phase"); + + blue.releaseConversion.countDown(); + resolving.join(TimeUnit.SECONDS.toMillis(5L)); + closing.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(resolving.isAlive()); + assertFalse(closing.isAlive()); + assertNull(failure.get()); + assertTrue(result.get() != null); + assertTrue(blue.isClosed()); + } + + @Test + void providerReplacementWaitsForRecursiveExpandAndCannotMixProviders() throws Exception { + CountDownLatch rootFetchEntered = new CountDownLatch(1); + CountDownLatch releaseRootFetch = new CountDownLatch(1); + NodeProvider original = blueId -> { + if ("root".equals(blueId)) { + rootFetchEntered.countDown(); + try { + if (!releaseRootFetch.await(5L, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting to release root expansion"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + return Collections.singletonList(new Node().properties( + "child", new Node().blueId("nested"))); + } + return Collections.singletonList(new Node().value("original")); + }; + Blue blue = new Blue(NodeProviderWrapper.unverified(original)); + AtomicReference failure = new AtomicReference<>(); + AtomicReference expanded = new AtomicReference<>(); + Thread expanding = new Thread(() -> { + try { + expanded.set(blue.expand(new Node().blueId("root"))); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } + }); + expanding.start(); + assertTrue(rootFetchEntered.await(5L, TimeUnit.SECONDS)); + + CountDownLatch replacementReturned = new CountDownLatch(1); + Thread replacement = new Thread(() -> { + try { + blue.nodeProvider(NodeProviderWrapper.unverified(blueId -> + Collections.singletonList(new Node().value("replacement")))); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + replacementReturned.countDown(); + } + }); + replacement.start(); + assertFalse(replacementReturned.await(200L, TimeUnit.MILLISECONDS)); + + releaseRootFetch.countDown(); + expanding.join(TimeUnit.SECONDS.toMillis(5L)); + replacement.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(expanding.isAlive()); + assertFalse(replacement.isAlive()); + assertNull(failure.get()); + assertEquals("original", expanded.get().getProperties().get("child").getValue()); + assertEquals("replacement", blue.expand(new Node().blueId("nested")).getValue()); + } + + @Test + void providerReplacementWaitsForSubtypeTraversalAndCannotMixProviders() throws Exception { + Node superType = new Node().name("Subtype gate supertype"); + String superTypeBlueId = BlueIdCalculator.calculateBlueId(superType); + Node candidateType = new Node() + .name("Subtype gate candidate") + .type(new Node().blueId(superTypeBlueId)); + String candidateTypeBlueId = BlueIdCalculator.calculateBlueId(candidateType); + CountDownLatch candidateFetchEntered = new CountDownLatch(1); + CountDownLatch releaseCandidateFetch = new CountDownLatch(1); + NodeProvider original = blueId -> { + if (candidateTypeBlueId.equals(blueId)) { + candidateFetchEntered.countDown(); + try { + if (!releaseCandidateFetch.await(5L, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting to release subtype lookup"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + return Collections.singletonList(candidateType.clone()); + } + if (superTypeBlueId.equals(blueId)) { + return Collections.singletonList(superType.clone()); + } + return null; + }; + Blue blue = new Blue(original); + AtomicReference result = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + Thread matching = new Thread(() -> { + try { + result.set(blue.isNodeSubtypeOf( + new Node().blueId(candidateTypeBlueId), + new Node().blueId(superTypeBlueId))); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } + }); + matching.start(); + assertTrue(candidateFetchEntered.await(5L, TimeUnit.SECONDS)); + + CountDownLatch replacementReturned = new CountDownLatch(1); + Thread replacement = new Thread(() -> { + try { + blue.nodeProvider(blueId -> null); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + replacementReturned.countDown(); + } + }); + replacement.start(); + assertFalse(replacementReturned.await(200L, TimeUnit.MILLISECONDS)); + + releaseCandidateFetch.countDown(); + matching.join(TimeUnit.SECONDS.toMillis(5L)); + replacement.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(matching.isAlive()); + assertFalse(replacement.isAlive()); + assertNull(failure.get()); + assertEquals(Boolean.TRUE, result.get()); + assertFalse(blue.isNodeSubtypeOf( + new Node().blueId(candidateTypeBlueId), + new Node().blueId(superTypeBlueId))); + } + + @Test + void retainedConformanceEngineCannotPublishStaleMergerEvidenceAfterRefresh() { + Node type = new Node().properties("typeMarker", new Node().value(true)); + String typeBlueId = BlueIdCalculator.calculateBlueId(type); + NodeProvider oldProvider = blueId -> typeBlueId.equals(blueId) + ? Collections.singletonList(type.clone()) : null; + NodeProvider newProvider = blueId -> typeBlueId.equals(blueId) + ? Collections.singletonList(type.clone()) : null; + Blue blue = new Blue(oldProvider, new EvidenceMergingProcessor("oldEvidence")); + ConformanceEngine staleEngine = blue.conformanceEngine(); + try { + blue.nodeProvider(newProvider); + blue.mergingProcessor(new EvidenceMergingProcessor("newEvidence")); + assertEquals(0, blue.resolvedReferenceCacheSize()); + + assertTrue(staleEngine.conforms( + new Node().type(new Node().blueId(typeBlueId)))); + assertEquals(0, blue.resolvedReferenceCacheSize(), + "a retained engine must not publish into Blue's current cache generation"); + + Node resolved = blue.resolve(new Node().type(new Node().blueId(typeBlueId))); + assertTrue(resolved.getProperties().get("newEvidence") != null); + assertTrue(resolved.getProperties().get("oldEvidence") == null, + "current resolution must not consume stale merger output"); + } finally { + staleEngine.close(); + } + } + + @Test + void conformanceEngineRetainsVisibilityOfCallerPinnedVerifiedSnapshots() { + Node type = new Node().properties("pinnedMarker", new Node().value(true)); + String typeBlueId = BlueIdCalculator.calculateBlueId(type); + BasicNodeProvider provider = new BasicNodeProvider(); + provider.addSingleNodes(type); + Blue source = new Blue(provider); + Blue target = new Blue(blueId -> null); + ConformanceEngine engine = null; + try { + ResolvedSnapshot verifiedType = source.loadSnapshot(typeBlueId); + target.cacheResolvedSnapshot(verifiedType); + + engine = target.conformanceEngine(); + assertTrue(engine.conforms( + new Node().type(new Node().blueId(typeBlueId)))); + + target.clearResolvedSnapshotCache(); + assertTrue(engine.conforms( + new Node().type(new Node().blueId(typeBlueId))), + "the retained handle must own its pinned-evidence snapshot"); + } finally { + if (engine != null) { + engine.close(); + } + target.close(); + source.close(); + } + } + + @Test + void displacedProcessorCannotPublishOldSnapshotAfterProviderReplacement() throws Exception { + Node completedDocument = document(77); + ResolvedSnapshot completedSnapshot = new ResolvedSnapshot( + completedDocument, + completedDocument.clone(), + BlueIdCalculator.calculateBlueId(completedDocument)); + BlockingDocumentProcessor processor = new BlockingDocumentProcessor(completedSnapshot); + Blue blue = new Blue().documentProcessor(processor); + AtomicReference failure = new AtomicReference<>(); + Thread processing = new Thread(() -> { + try { + blue.processDocument(document(1), new Node().value("event")); + } catch (Throwable throwable) { + failure.set(throwable); + } + }); + processing.start(); + assertTrue(processor.entered.await(5L, TimeUnit.SECONDS)); + + AtomicReference replacementFailure = new AtomicReference<>(); + CountDownLatch replacementReturned = new CountDownLatch(1); + Thread replacement = new Thread(() -> { + try { + blue.nodeProvider(node -> null); + } catch (Throwable throwable) { + replacementFailure.set(throwable); + } finally { + replacementReturned.countDown(); + } + }); + replacement.start(); + // Configuration replacement is a cache-generation barrier: it must + // wait until the old processor can no longer publish its result. + assertFalse(replacementReturned.await(200L, TimeUnit.MILLISECONDS)); + processor.release.countDown(); + processing.join(TimeUnit.SECONDS.toMillis(5L)); + replacement.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(processing.isAlive()); + assertFalse(replacement.isAlive()); + assertNull(failure.get()); + assertNull(replacementFailure.get()); + assertEquals(0, blue.cacheStats().region("recentProcessingSnapshots").entries()); + assertEquals(0, blue.cacheStats().region("derivedResolvedSnapshots").entries()); + } + + @Test + void processorRegistrationWaitsForConfigurationRefreshAndTargetsPublishedProcessor() + throws Exception { + Node completedDocument = document(78); + ResolvedSnapshot completedSnapshot = new ResolvedSnapshot( + completedDocument, + completedDocument.clone(), + BlueIdCalculator.calculateBlueId(completedDocument)); + BlockingDocumentProcessor displaced = new BlockingDocumentProcessor(completedSnapshot); + Blue blue = new Blue().documentProcessor(displaced); + AtomicReference failure = new AtomicReference<>(); + Thread processing = new Thread(() -> { + try { + blue.processDocument(document(1), new Node().value("event")); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } + }); + processing.start(); + assertTrue(displaced.entered.await(5L, TimeUnit.SECONDS)); + + CountDownLatch replacementReturned = new CountDownLatch(1); + Thread replacement = new Thread(() -> { + try { + blue.nodeProvider(node -> null); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + replacementReturned.countDown(); + } + }); + replacement.start(); + assertFalse(replacementReturned.await(200L, TimeUnit.MILLISECONDS)); + + RegistrationMarkerProcessor processor = new RegistrationMarkerProcessor(); + CountDownLatch registrationReturned = new CountDownLatch(1); + Thread registration = new Thread(() -> { + try { + blue.registerContractProcessor("registration-race", processor); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + registrationReturned.countDown(); + } + }); + registration.start(); + assertFalse(displaced.registrationEntered.await(200L, TimeUnit.MILLISECONDS), + "registration must not mutate the displaced processor during refresh"); + assertFalse(registrationReturned.await(200L, TimeUnit.MILLISECONDS)); + + displaced.release.countDown(); + processing.join(TimeUnit.SECONDS.toMillis(5L)); + replacement.join(TimeUnit.SECONDS.toMillis(5L)); + registration.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(processing.isAlive()); + assertFalse(replacement.isAlive()); + assertFalse(registration.isAlive()); + assertNull(failure.get()); + assertSame(processor, blue.getDocumentProcessor().getContractRegistry() + .processors().get("registration-race")); + } + + @Test + void explicitClearRejectsLateBorrowedProcessorPublication() throws Exception { + Node completedDocument = document(88); + ResolvedSnapshot completedSnapshot = new ResolvedSnapshot( + completedDocument, + completedDocument.clone(), + BlueIdCalculator.calculateBlueId(completedDocument)); + BlockingDocumentProcessor processor = new BlockingDocumentProcessor(completedSnapshot); + Blue blue = new Blue().documentProcessor(processor); + AtomicReference failure = new AtomicReference<>(); + Thread processing = new Thread(() -> { + try { + blue.processDocument(document(1), new Node().value("event")); + } catch (Throwable throwable) { + failure.set(throwable); + } + }); + processing.start(); + assertTrue(processor.entered.await(5L, TimeUnit.SECONDS)); + + CountDownLatch clearReturned = new CountDownLatch(1); + Thread clearing = new Thread(() -> { + try { + blue.clearResolvedSnapshotCache(); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + clearReturned.countDown(); + } + }); + clearing.start(); + assertFalse(clearReturned.await(200L, TimeUnit.MILLISECONDS)); + processor.release.countDown(); + processing.join(TimeUnit.SECONDS.toMillis(5L)); + clearing.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(processing.isAlive()); + assertFalse(clearing.isAlive()); + assertNull(failure.get()); + assertEquals(0, blue.cacheStats().region("recentProcessingSnapshots").entries()); + assertEquals(0, blue.cacheStats().region("derivedResolvedSnapshots").entries()); + } + + @Test + void concurrentCloseWaitsForInProgressInvalidationWithoutStrandingGate() throws Exception { + Node completedDocument = document(89); + ResolvedSnapshot completedSnapshot = new ResolvedSnapshot( + completedDocument, + completedDocument.clone(), + BlueIdCalculator.calculateBlueId(completedDocument)); + BlockingDocumentProcessor processor = new BlockingDocumentProcessor(completedSnapshot); + Blue blue = new Blue().documentProcessor(processor); + AtomicReference failure = new AtomicReference<>(); + Thread processing = new Thread(() -> { + try { + blue.processDocument(document(1), new Node().value("event")); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } + }); + processing.start(); + assertTrue(processor.entered.await(5L, TimeUnit.SECONDS)); + + CountDownLatch clearReturned = new CountDownLatch(1); + Thread clearing = new Thread(() -> { + try { + blue.clearResolvedSnapshotCache(); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + clearReturned.countDown(); + } + }); + clearing.start(); + assertFalse(clearReturned.await(200L, TimeUnit.MILLISECONDS)); + + CountDownLatch closeReturned = new CountDownLatch(1); + Thread closing = new Thread(() -> { + try { + blue.close(); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + closeReturned.countDown(); + } + }); + closing.start(); + assertFalse(closeReturned.await(200L, TimeUnit.MILLISECONDS)); + + processor.release.countDown(); + processing.join(TimeUnit.SECONDS.toMillis(5L)); + clearing.join(TimeUnit.SECONDS.toMillis(5L)); + closing.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(processing.isAlive()); + assertFalse(clearing.isAlive()); + assertFalse(closing.isAlive()); + assertNull(failure.get()); + assertTrue(blue.isClosed()); + assertTimeoutPreemptively(Duration.ofSeconds(2L), () -> + assertThrows(IllegalStateException.class, + () -> blue.processDocument(document(2), new Node()))); + } + + @Test + void mergerReplacementWaitsForDirectSnapshotResolutionThenClearsItsResult() throws Exception { + BlockingMergingProcessor blocking = new BlockingMergingProcessor(); + Blue blue = new Blue(node -> null, blocking); + AtomicReference failure = new AtomicReference<>(); + Thread resolving = new Thread(() -> { + try { + blue.resolveToSnapshot(document(5)); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } + }); + resolving.start(); + assertTrue(blocking.entered.await(5L, TimeUnit.SECONDS)); + + CountDownLatch replacementReturned = new CountDownLatch(1); + Thread replacement = new Thread(() -> { + try { + blue.mergingProcessor((target, source, provider, resolver) -> { }); + } catch (Throwable throwable) { + failure.compareAndSet(null, throwable); + } finally { + replacementReturned.countDown(); + } + }); + replacement.start(); + assertFalse(replacementReturned.await(200L, TimeUnit.MILLISECONDS)); + + blocking.release.countDown(); + resolving.join(TimeUnit.SECONDS.toMillis(5L)); + replacement.join(TimeUnit.SECONDS.toMillis(5L)); + + assertFalse(resolving.isAlive()); + assertFalse(replacement.isAlive()); + assertNull(failure.get()); + assertEquals(0, blue.cacheStats().region("derivedResolvedSnapshots").entries()); + assertEquals(0, blue.resolvedReferenceCacheSize()); + } + + @Test + void aliasReplacementRefreshesTheEagerProcessorAndOwnsCallerMap() throws Exception { + Node aliasTarget = new Node() + .name("Alias Target") + .properties("provided", new Node().value(true)); + BasicNodeProvider provider = new BasicNodeProvider(aliasTarget); + String targetBlueId = provider.getBlueIdByName("Alias Target"); + Blue blue = new Blue(provider); + Map aliases = new HashMap<>(); + aliases.put("friendly", targetBlueId); + + blue.preprocessingAliases(aliases); + aliases.put("friendly", "invalid-after-registration"); + Field managerField = DocumentProcessor.class.getDeclaredField("snapshotManager"); + managerField.setAccessible(true); + ProcessingSnapshotManager manager = (ProcessingSnapshotManager) managerField.get( + blue.getDocumentProcessor()); + Field aliasesField = manager.getClass().getDeclaredField("aliases"); + aliasesField.setAccessible(true); + @SuppressWarnings("unchecked") + Map capturedAliases = + (Map) aliasesField.get(manager); + + assertEquals(targetBlueId, capturedAliases.get("friendly")); + assertEquals(targetBlueId, blue.getPreprocessingAliases().get("friendly")); + assertThrows(UnsupportedOperationException.class, + () -> blue.getPreprocessingAliases().put("other", targetBlueId)); + } + + @Test + void verifiedReferenceAccelerationIsBoundedWhileExplicitRegistrationPinsContent() { + BasicNodeProvider provider = new BasicNodeProvider(); + for (int index = 0; index < 6; index++) { + provider.addSingleNodes(new Node() + .name("Reference Type " + index) + .properties("ordinal", new Node().value(index))); + } + BlueCachePolicy policy = BlueCachePolicy.builder() + .derivedSnapshots(2, 1024L * 1024L) + .transientReferences(2, 1024L * 1024L) + .maximumDerivedEntryWeightBytes(1024L * 1024L) + .build(); + Blue blue = new Blue(provider, null, null, policy); + ResolvedSnapshot authoritative = blue.loadSnapshot( + provider.getBlueIdByName("Reference Type 0")); + blue.clearResolvedSnapshotCache(); + blue.cacheResolvedSnapshot(authoritative); + + for (int index = 1; index < 6; index++) { + blue.loadSnapshot(provider.getBlueIdByName("Reference Type " + index)); + } + + BlueCacheStats.Region references = blue.cacheStats().region("verifiedReferences"); + assertTrue(references.entries() <= 2, + "one pinned entry plus bounded derived reference evidence"); + assertTrue(references.evictions() > 0L); + assertSame(authoritative, + blue.cachedResolvedSnapshot(authoritative.blueId()).orElseThrow( + AssertionError::new)); + } + + @Test + void verifiedReplacementOfPinnedSnapshotPromotesItsReferenceEvidence() { + BlueCachePolicy policy = BlueCachePolicy.builder() + .transientReferences(1, 1024L * 1024L) + .maximumDerivedEntryWeightBytes(1024L * 1024L) + .build(); + Blue blue = Blue.withCachePolicy(policy); + Node canonical = document(7); + String blueId = blue.calculateBlueId(canonical); + ResolvedSnapshot unverified = new ResolvedSnapshot( + canonical, canonical.clone(), blueId); + + blue.cacheResolvedSnapshot(unverified); + ResolvedSnapshot verified = blue.loadSnapshot(canonical); + + assertTrue(verified.verifiedReferenceResolution() != null); + assertTrue(blue.cacheStats().region("verifiedReferences").isPinned()); + assertSame(verified, + blue.cachedResolvedSnapshot(blueId).orElseThrow(AssertionError::new)); + } + + private Node document(int value) { + return new Node() + .properties("value", new Node().value(value)) + .properties("payload", new Node().value("payload-" + value)); + } + + private static final class BlockingProviderBlue extends Blue { + private final CountDownLatch providerEntered = new CountDownLatch(1); + private final CountDownLatch releaseProvider = new CountDownLatch(1); + private volatile boolean blockProvider; + + @Override + public NodeProvider getNodeProvider() { + if (blockProvider) { + providerEntered.countDown(); + try { + if (!releaseProvider.await(5L, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting to release provider lookup"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + } + return super.getNodeProvider(); + } + } + + private static final class BlockingObjectConversionBlue extends Blue { + private final CountDownLatch conversionCompleted = new CountDownLatch(1); + private final CountDownLatch releaseConversion = new CountDownLatch(1); + + @Override + public Node objectToNode(Object object) { + Node converted = super.objectToNode(object); + conversionCompleted.countDown(); + try { + if (!releaseConversion.await(5L, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting to release object conversion"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + return converted; + } + } + + private static final class BlockingDocumentProcessor extends DocumentProcessor { + private final ResolvedSnapshot resultSnapshot; + private final CountDownLatch entered = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final CountDownLatch registrationEntered = new CountDownLatch(1); + + private BlockingDocumentProcessor(ResolvedSnapshot resultSnapshot) { + this.resultSnapshot = resultSnapshot; + } + + @Override + public DocumentProcessingResult processDocument(Node document, Node event) { + entered.countDown(); + try { + if (!release.await(5L, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting to complete processing"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + return DocumentProcessingResult.of( + resultSnapshot, Collections.emptyList(), 0L); + } + + @Override + public DocumentProcessor registerContractProcessor( + String blueId, + ContractProcessor processor) { + registrationEntered.countDown(); + return super.registerContractProcessor(blueId, processor); + } + } + + private static final class RegistrationMarker extends MarkerContract { + } + + private static final class RegistrationMarkerProcessor + implements ContractProcessor { + @Override + public Class contractType() { + return RegistrationMarker.class; + } + } + + private static final class BlockingCloseDocumentProcessor extends DocumentProcessor { + private final CountDownLatch clearEntered = new CountDownLatch(1); + private final CountDownLatch allowClear = new CountDownLatch(1); + private final CountDownLatch closeEntered = new CountDownLatch(1); + private final CountDownLatch allowClose = new CountDownLatch(1); + + @Override + public void clearCaches() { + clearEntered.countDown(); + try { + if (!allowClear.await(5L, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting to clear processor caches"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + super.clearCaches(); + } + + @Override + public void close() { + closeEntered.countDown(); + try { + if (!allowClose.await(5L, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting to close processor"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + super.close(); + } + } + + private static final class BlockingMergingProcessor implements MergingProcessor { + private final CountDownLatch entered = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + + @Override + public void process(Node target, + Node source, + NodeProvider nodeProvider, + NodeResolver nodeResolver) { + entered.countDown(); + try { + if (!release.await(5L, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting to complete merge"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + } + } + + private static final class EvidenceMergingProcessor implements MergingProcessor { + private final String evidenceProperty; + + private EvidenceMergingProcessor(String evidenceProperty) { + this.evidenceProperty = evidenceProperty; + } + + @Override + public void process(Node target, + Node source, + NodeProvider nodeProvider, + NodeResolver nodeResolver) { + if (source.getProperties() != null + && source.getProperties().containsKey("typeMarker")) { + target.properties(evidenceProperty, new Node().value(true)); + } + } + } +} diff --git a/src/test/java/blue/language/BlueCachePolicyTest.java b/src/test/java/blue/language/BlueCachePolicyTest.java new file mode 100644 index 0000000..fefb43c --- /dev/null +++ b/src/test/java/blue/language/BlueCachePolicyTest.java @@ -0,0 +1,39 @@ +package blue.language; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BlueCachePolicyTest { + + @Test + void builderProducesImmutableExplicitBounds() { + BlueCachePolicy policy = BlueCachePolicy.builder() + .derivedSnapshots(3, 1_000L) + .canonicalAliases(4, 2_000L) + .resolvedStructuralEntries(5, 3_000L) + .transientReferences(6, 4_000L) + .conformancePlans(7, 5_000L) + .maximumDerivedEntryWeightBytes(700L) + .build(); + + assertEquals(3, policy.derivedSnapshotMaxEntries()); + assertEquals(1_000L, policy.derivedSnapshotMaxWeightBytes()); + assertEquals(4, policy.canonicalAliasMaxEntries()); + assertEquals(5, policy.resolvedStructuralMaxEntries()); + assertEquals(6, policy.transientReferenceMaxEntries()); + assertEquals(7, policy.conformancePlanMaxEntries()); + assertEquals(700L, policy.maximumDerivedEntryWeightBytes()); + } + + @Test + void rejectsNonPositiveBounds() { + assertThrows(IllegalArgumentException.class, + () -> BlueCachePolicy.builder().derivedSnapshots(0, 1L).build()); + assertThrows(IllegalArgumentException.class, + () -> BlueCachePolicy.builder().canonicalAliases(1, 0L).build()); + assertThrows(IllegalArgumentException.class, + () -> BlueCachePolicy.builder().maximumDerivedEntryWeightBytes(0L).build()); + } +} diff --git a/src/test/java/blue/language/BlueViewPathTest.java b/src/test/java/blue/language/BlueViewPathTest.java index a93c78c..b9f3e94 100644 --- a/src/test/java/blue/language/BlueViewPathTest.java +++ b/src/test/java/blue/language/BlueViewPathTest.java @@ -50,4 +50,18 @@ void badEscapesAreRejected() { assertThrows(IllegalArgumentException.class, () -> BlueViewPath.split("/bad~2escape")); assertThrows(IllegalArgumentException.class, () -> BlueViewPath.split("/bad~")); } + + @Test + void arrayIndexesRemainCanonicalAsciiDecimals() throws Exception { + Node root = YAML_MAPPER.readValue( + "array:\n" + + " items:\n" + + " - first", Node.class); + + assertEquals("first", BlueViewPath.select(root, "/array/items/0").getValue()); + assertThrows(IllegalArgumentException.class, + () -> BlueViewPath.select(root, "/array/items/00")); + assertThrows(IllegalArgumentException.class, + () -> BlueViewPath.select(root, "/array/items/\u0660")); + } } diff --git a/src/test/java/blue/language/NodeDeserializerTest.java b/src/test/java/blue/language/NodeDeserializerTest.java index 34a1d85..69bcb7b 100644 --- a/src/test/java/blue/language/NodeDeserializerTest.java +++ b/src/test/java/blue/language/NodeDeserializerTest.java @@ -521,6 +521,25 @@ public void schemaKeywordValueShapesAreStrict() { assertEquals(new BigInteger("9007199254740992"), enumNode.getSchema().getEnum().get(0).getValue()); } + @Test + public void explicitIntegerStringsRetainCanonicalAsciiGrammar() throws Exception { + Node negativeZero = YAML_MAPPER.readValue( + "schema:\n" + + " minimum:\n" + + " type: Integer\n" + + " value: \"-0\"", Node.class); + + assertEquals("-0", negativeZero.getSchema().getMinimum().getRawValue()); + Node preprocessedNegativeZero = new Blue().preprocess(negativeZero); + assertEquals(BigInteger.ZERO, preprocessedNegativeZero.getSchema().getMinimum().getValue()); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue( + "schema:\n minimum:\n type: Integer\n value: \"01\"", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue( + "schema:\n minimum:\n type: Integer\n value: \"+1\"", Node.class)); + assertThrows(RuntimeException.class, () -> YAML_MAPPER.readValue( + "schema:\n minimum:\n type: Integer\n value: \"\u0661\"", Node.class)); + } + @Test public void schemaEnumRejectsContractsOnExplicitScalar() { assertThrows(RuntimeException.class, diff --git a/src/test/java/blue/language/WeightedLruCacheTest.java b/src/test/java/blue/language/WeightedLruCacheTest.java new file mode 100644 index 0000000..f735af1 --- /dev/null +++ b/src/test/java/blue/language/WeightedLruCacheTest.java @@ -0,0 +1,60 @@ +package blue.language; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class WeightedLruCacheTest { + + @Test + void evictsLeastRecentlyUsedEntriesByWeightAndCount() { + WeightedLruCache cache = new WeightedLruCache<>(2, 6L, 6L, + value -> value.length()); + cache.put("a", "aa"); + cache.put("b", "bb"); + assertEquals("aa", cache.get("a")); + cache.put("c", "cccc"); + + assertEquals("aa", cache.get("a")); + assertNull(cache.get("b")); + assertEquals("cccc", cache.get("c")); + assertEquals(1L, cache.evictions()); + assertEquals(6L, cache.currentWeight()); + } + + @Test + void rejectsOversizedEntriesWithoutDroppingAnExistingValue() { + WeightedLruCache cache = new WeightedLruCache<>(2, 8L, 4L, + value -> value.length()); + cache.put("a", "old"); + assertEquals("old", cache.put("a", "oversized")); + assertEquals("old", cache.get("a")); + assertEquals(1L, cache.oversizedRejections()); + } + + @Test + void clearReportsReleasedWeight() { + WeightedLruCache cache = new WeightedLruCache<>(4, 100L, 100L, + value -> value.length()); + cache.put("a", "abc"); + cache.put("b", "defg"); + assertEquals(7L, cache.clear()); + assertEquals(0L, cache.currentWeight()); + assertEquals(0, cache.size()); + } + + @Test + void reportsLookupHitsAndMissesWithoutCountingPeeks() { + WeightedLruCache cache = new WeightedLruCache<>(4, 100L, 100L, + value -> value.length()); + cache.put("a", "abc"); + + assertEquals("abc", cache.get("a")); + assertNull(cache.get("missing")); + assertEquals("abc", cache.peek("a")); + + assertEquals(1L, cache.hits()); + assertEquals(1L, cache.misses()); + } +} diff --git a/src/test/java/blue/language/processor/DocumentProcessorBoundaryTest.java b/src/test/java/blue/language/processor/DocumentProcessorBoundaryTest.java index d938611..9fd26e0 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorBoundaryTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorBoundaryTest.java @@ -1,17 +1,168 @@ package blue.language.processor; import blue.language.model.Node; +import blue.language.processor.contracts.SetPropertyContractProcessor; +import blue.language.processor.model.FrozenJsonPatch; import blue.language.processor.model.JsonPatch; import blue.language.processor.model.ProcessEmbedded; import blue.language.processor.ContractBundle; +import blue.language.processor.model.SetProperty; +import blue.language.utils.BlueIdCalculator; +import blue.language.utils.TypeClassResolver; import org.junit.jupiter.api.Test; +import java.util.Collections; import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; import static org.junit.jupiter.api.Assertions.*; class DocumentProcessorBoundaryTest { + @Test + void processorRegistryViewRemainsLiveAndUnmodifiableAcrossRegistration() { + ContractProcessorRegistry registry = new ContractProcessorRegistry(); + Map> view = + registry.processors(); + Set>> entries = + view.entrySet(); + SetPropertyContractProcessor processor = new SetPropertyContractProcessor(); + + registry.register("retained-live-view", processor); + + assertSame(processor, view.get("retained-live-view")); + assertEquals(1, entries.size()); + assertTrue(entries.stream().anyMatch(entry -> + entry.getKey().equals("retained-live-view") && entry.getValue() == processor)); + assertThrows(UnsupportedOperationException.class, view::clear); + } + + @Test + void sharedConfigurationReadWaitsForCompositeRegistrationAcrossProcessors() throws Exception { + String blueId = "shared-composite-registration"; + ContractProcessorRegistry registry = new ContractProcessorRegistry(); + BlockingTypeClassResolver resolver = new BlockingTypeClassResolver(blueId); + DocumentProcessor registeringProcessor = new DocumentProcessor(registry, resolver, null, null); + DocumentProcessor readingProcessor = new DocumentProcessor(registry, resolver, null, null); + SetPropertyContractProcessor contractProcessor = new SetPropertyContractProcessor(); + ExecutorService executor = daemonExecutor(2); + + try { + Future registration = executor.submit( + () -> registeringProcessor.registerContractProcessor(blueId, contractProcessor)); + + assertTrue(resolver.awaitRegistrationPause(5, TimeUnit.SECONDS), + "registration did not reach the registry/resolver boundary"); + assertSame(contractProcessor, registry.processors().get(blueId), + "the registry mutation must precede resolver publication"); + + CountDownLatch readStarted = new CountDownLatch(1); + Future read = executor.submit(() -> { + readStarted.countDown(); + return readingProcessor.isInitialized(new Node()); + }); + assertTrue(readStarted.await(5, TimeUnit.SECONDS), "shared read did not start"); + assertThrows(TimeoutException.class, + () -> read.get(200, TimeUnit.MILLISECONDS), + "a shared read must not observe the half-published configuration"); + + resolver.releaseRegistration(); + registration.get(5, TimeUnit.SECONDS); + assertFalse(read.get(5, TimeUnit.SECONDS)); + assertEquals(SetProperty.class, resolver.resolveClass(blueId)); + } finally { + resolver.releaseRegistration(); + executor.shutdownNow(); + } + } + + @Test + void crossProcessorRegistrationFromSharedReadCallbackFailsInsteadOfDeadlocking() throws Exception { + String existingBlueId = "shared-read-callback"; + String reentrantBlueId = "shared-read-callback-reentrant"; + ContractProcessorRegistry registry = new ContractProcessorRegistry(); + CallbackTypeClassResolver resolver = new CallbackTypeClassResolver(existingBlueId); + DocumentProcessor readingProcessor = new DocumentProcessor(registry, resolver, null, null); + DocumentProcessor registeringProcessor = new DocumentProcessor(registry, resolver, null, null); + SetPropertyContractProcessor contractProcessor = new SetPropertyContractProcessor(); + readingProcessor.registerContractProcessor(existingBlueId, contractProcessor); + resolver.onResolve(() -> registeringProcessor.registerContractProcessor( + reentrantBlueId, new SetPropertyContractProcessor())); + + Node scope = new Node().contracts(new Node().properties("handler", + new Node().type(new Node().blueId(existingBlueId)))); + ExecutorService executor = daemonExecutor(1); + try { + Future result = executor.submit(() -> assertThrows( + IllegalStateException.class, + () -> readingProcessor.markersFor(scope, "/"))); + + IllegalStateException failure = getWithoutDeadlock(result); + assertEquals("Document processor configuration cannot change during active processing", + failure.getMessage()); + assertFalse(registry.processors().containsKey(reentrantBlueId)); + } finally { + executor.shutdownNow(); + } + } + + @Test + void registrationWaitingForSharedWriteDoesNotBlockCrossProcessorClose() throws Exception { + String existingBlueId = "shared-close-callback"; + SignallingRegistry registry = new SignallingRegistry(); + CallbackTypeClassResolver resolver = new CallbackTypeClassResolver(existingBlueId); + DocumentProcessor readingProcessor = new DocumentProcessor(registry, resolver, null, null); + DocumentProcessor closingProcessor = new DocumentProcessor(registry, resolver, null, null); + readingProcessor.registerContractProcessor( + existingBlueId, new SetPropertyContractProcessor()); + CountDownLatch callbackEntered = new CountDownLatch(1); + CountDownLatch allowClose = new CountDownLatch(1); + resolver.onResolve(() -> { + callbackEntered.countDown(); + awaitUnchecked(allowClose); + closingProcessor.close(); + }); + registry.armWriteAttempt(); + + Node scope = new Node().contracts(new Node().properties("handler", + new Node() + .type(new Node().blueId(existingBlueId)) + .properties("channel", new Node().value("absent-channel")))); + ExecutorService executor = daemonExecutor(2); + try { + Future> read = + executor.submit(() -> readingProcessor.markersFor(scope, "/")); + assertTrue(callbackEntered.await(5, TimeUnit.SECONDS)); + Future registration = executor.submit(() -> closingProcessor + .registerContractProcessor("after-close", new SetPropertyContractProcessor())); + assertTrue(registry.awaitWriteAttempt(5, TimeUnit.SECONDS), + "registration did not reach the shared configuration write gate"); + + allowClose.countDown(); + + assertTrue(read.get(5, TimeUnit.SECONDS).isEmpty()); + ExecutionException failure = assertThrows( + ExecutionException.class, + () -> registration.get(5, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertEquals("Document processor is closed", failure.getCause().getMessage()); + assertTrue(closingProcessor.isClosed()); + } finally { + allowClose.countDown(); + executor.shutdownNow(); + } + } + @Test void rejectsEmptyPointerSegments() { Node document = new Node(); @@ -202,6 +353,38 @@ void reservedContractsWithinScopeAreWriteProtected() { assertTrue(fooNode.getContracts() != null); } + @Test + void frozenAndMutableIdenticalContractsReplacementPreserveReservedEmbeddedMarker() { + Node embedded = new Node() + .type(new Node().blueId( + "8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q")) + .properties("paths", new Node().items(new Node().value("/child"))); + Node contracts = new Node().properties("embedded", embedded); + Node source = new Node().properties("scope", new Node().contracts(contracts)); + ContractBundle bundle = ContractBundle.builder().build(); + + ProcessorEngine.Execution mutableExecution = + new ProcessorEngine.Execution(new DocumentProcessor(), source.clone()); + mutableExecution.handlePatch("/scope", bundle, + JsonPatch.replace("/scope/contracts", contracts.clone()), false); + + ProcessorEngine.Execution frozenExecution = + new ProcessorEngine.Execution(new DocumentProcessor(), source.clone()); + frozenExecution.handlePatchInputs("/scope", bundle, + PatchInput.frozenList(Collections.singletonList(FrozenJsonPatch.from( + JsonPatch.replace("/scope/contracts", contracts.clone())))), + false, + null); + + assertFalse(mutableExecution.runtime().isScopeTerminated("/scope")); + assertFalse(frozenExecution.runtime().isScopeTerminated("/scope")); + assertEquals( + BlueIdCalculator.calculateUncheckedBlueId( + mutableExecution.result().document().getAsNode("/scope").getContracts()), + BlueIdCalculator.calculateUncheckedBlueId( + frozenExecution.result().document().getAsNode("/scope").getContracts())); + } + private Node getProperty(Node node, String key) { Map properties = node.getProperties(); assertNotNull(properties, "Expected properties to exist for key '" + key + "'"); @@ -219,4 +402,167 @@ private void expectRunTermination(Runnable action) { ex.getClass().getName()); } } + + private static ExecutorService daemonExecutor(int threads) { + return Executors.newFixedThreadPool(threads, task -> { + Thread thread = new Thread(task, "document-processor-boundary-test"); + thread.setDaemon(true); + return thread; + }); + } + + private static T getWithoutDeadlock(Future future) throws Exception { + try { + return future.get(5, TimeUnit.SECONDS); + } catch (ExecutionException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw ex; + } + } + + private static void awaitUnchecked(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out waiting for test release"); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("test wait interrupted", ex); + } + } + + private static final class SignallingRegistry extends ContractProcessorRegistry { + private volatile CountDownLatch writeAttempt; + + private void armWriteAttempt() { + writeAttempt = new CountDownLatch(1); + } + + private boolean awaitWriteAttempt(long timeout, TimeUnit unit) + throws InterruptedException { + CountDownLatch current = writeAttempt; + return current != null && current.await(timeout, unit); + } + + @Override + Lock configurationWriteLock() { + Lock delegate = super.configurationWriteLock(); + CountDownLatch signal = writeAttempt; + if (signal == null) { + return delegate; + } + return new SignallingLock(delegate, signal); + } + } + + private static final class SignallingLock implements Lock { + private final Lock delegate; + private final CountDownLatch signal; + + private SignallingLock(Lock delegate, CountDownLatch signal) { + this.delegate = delegate; + this.signal = signal; + } + + @Override + public void lock() { + signal.countDown(); + delegate.lock(); + } + + @Override + public void lockInterruptibly() throws InterruptedException { + signal.countDown(); + delegate.lockInterruptibly(); + } + + @Override + public boolean tryLock() { + signal.countDown(); + return delegate.tryLock(); + } + + @Override + public boolean tryLock(long time, TimeUnit unit) throws InterruptedException { + signal.countDown(); + return delegate.tryLock(time, unit); + } + + @Override + public void unlock() { + delegate.unlock(); + } + + @Override + public Condition newCondition() { + return delegate.newCondition(); + } + } + + private static final class BlockingTypeClassResolver extends TypeClassResolver { + private final String blockingBlueId; + private final CountDownLatch registrationPaused = new CountDownLatch(1); + private final CountDownLatch registrationReleased = new CountDownLatch(1); + + private BlockingTypeClassResolver(String blockingBlueId) { + this.blockingBlueId = blockingBlueId; + } + + @Override + public TypeClassResolver register(String blueId, Class clazz) { + if (blockingBlueId.equals(blueId)) { + registrationPaused.countDown(); + awaitRelease(); + } + return super.register(blueId, clazz); + } + + private boolean awaitRegistrationPause(long timeout, TimeUnit unit) throws InterruptedException { + return registrationPaused.await(timeout, unit); + } + + private void releaseRegistration() { + registrationReleased.countDown(); + } + + private void awaitRelease() { + try { + if (!registrationReleased.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out waiting to release registration"); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("registration interrupted", ex); + } + } + } + + private static final class CallbackTypeClassResolver extends TypeClassResolver { + private final String callbackBlueId; + private final AtomicBoolean callbackPending = new AtomicBoolean(); + private volatile Runnable callback; + + private CallbackTypeClassResolver(String callbackBlueId) { + this.callbackBlueId = callbackBlueId; + } + + private void onResolve(Runnable callback) { + this.callback = callback; + callbackPending.set(true); + } + + @Override + public Class resolveClass(String blueId) { + Runnable current = callback; + if (callbackBlueId.equals(blueId) + && current != null + && callbackPending.compareAndSet(true, false)) { + current.run(); + } + return super.resolveClass(blueId); + } + } } diff --git a/src/test/java/blue/language/processor/DocumentProcessorGasTest.java b/src/test/java/blue/language/processor/DocumentProcessorGasTest.java index cfddca5..a004ffb 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorGasTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorGasTest.java @@ -164,7 +164,8 @@ void processDocumentReusesResolvedTypeCacheWithoutChangingGas() { assertProcessedAccount(warm, types); assertEquals(0, warmProvider.fetchCount(types.accountId)); - assertEquals(1, warmProvider.fetchCount(types.moneyId)); + assertEquals(1, warmProvider.fetchCount(types.moneyId), + warmProvider.fetchCountsByBlueId.toString()); assertEquals(1, warmProvider.fetchCount(), warmProvider.fetchCountsByBlueId.toString()); assertTrue(warmBlue.resolvedReferenceCacheSize() >= warmCacheSizeBeforeProcessing); assertEquals(148L, warm.totalGas(), "warm configured-provider processing gas"); @@ -204,7 +205,8 @@ void initializeDocumentReusesResolvedTypeCacheWithoutChangingGas() { assertInitializedAccount(warm, types); assertEquals(0, warmProvider.fetchCount(types.accountId)); - assertEquals(1, warmProvider.fetchCount(types.moneyId)); + assertEquals(1, warmProvider.fetchCount(types.moneyId), + warmProvider.fetchCountsByBlueId.toString()); assertEquals(1, warmProvider.fetchCount(), warmProvider.fetchCountsByBlueId.toString()); assertEquals(cold.totalGas(), warm.totalGas()); } @@ -247,7 +249,8 @@ void processDocumentCachesRepeatedNestedTypeReferencesOnlyOnceWithoutChangingGas assertProcessedPortfolio(warm, types); assertEquals(0, warmProvider.fetchCount(types.portfolioId)); - assertEquals(1, warmProvider.fetchCount(types.accountId)); + assertEquals(1, warmProvider.fetchCount(types.accountId), + warmProvider.fetchCountsByBlueId.toString()); assertEquals(1, warmProvider.fetchCount(types.moneyId)); assertEquals(2, warmProvider.fetchCount(), warmProvider.fetchCountsByBlueId.toString()); assertEquals(cold.totalGas(), warm.totalGas()); diff --git a/src/test/java/blue/language/processor/DocumentUpdateChannelTest.java b/src/test/java/blue/language/processor/DocumentUpdateChannelTest.java index 84a4a2f..13fad52 100644 --- a/src/test/java/blue/language/processor/DocumentUpdateChannelTest.java +++ b/src/test/java/blue/language/processor/DocumentUpdateChannelTest.java @@ -194,7 +194,8 @@ void cascadedUpdatesPropagateThroughEmbeddedScopes() { Node processed = result.document(); Node rootA = processed.getProperties().get("a"); - assertNotNull(rootA); + assertNotNull(rootA, result.status() + ": " + result.failureReason() + + "\n" + blue.nodeToYaml(processed)); assertEquals(new BigInteger("1"), rootA.getValue()); Node x = processed.getProperties().get("x"); diff --git a/src/test/java/blue/language/processor/FrozenJsonPatchApiTest.java b/src/test/java/blue/language/processor/FrozenJsonPatchApiTest.java new file mode 100644 index 0000000..b7c69aa --- /dev/null +++ b/src/test/java/blue/language/processor/FrozenJsonPatchApiTest.java @@ -0,0 +1,428 @@ +package blue.language.processor; + +import blue.language.model.Node; +import blue.language.model.Schema; +import blue.language.processor.model.FrozenJsonPatch; +import blue.language.processor.model.JsonPatch; +import blue.language.processor.util.NodeCanonicalizer; +import blue.language.snapshot.FrozenNode; +import blue.language.snapshot.ResolvedSnapshot; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static blue.language.utils.Properties.TEXT_TYPE_BLUE_ID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FrozenJsonPatchApiTest { + + @Test + void factoriesRetainAuthoredPathsAndImmutableValuesForEveryOperation() { + FrozenNode value = FrozenNode.fromNode(new Node().value("value")); + FrozenJsonPatch add = FrozenJsonPatch.add("/a~1b/~0key", value); + FrozenJsonPatch replace = FrozenJsonPatch.replace("/rows/-", value); + FrozenJsonPatch remove = FrozenJsonPatch.remove("/"); + + assertEquals(JsonPatch.Op.ADD, add.getOp()); + assertEquals("/a~1b/~0key", add.getPath()); + assertEquals(Arrays.asList("a/b", "~key"), add.parsedPath().segments()); + assertSame(value, add.getValue()); + assertSame(value, add.getVal()); + assertEquals(add, FrozenJsonPatch.add("/a~1b/~0key", value)); + assertEquals(add.hashCode(), FrozenJsonPatch.add("/a~1b/~0key", value).hashCode()); + assertEquals(JsonPatch.Op.REPLACE, replace.getOp()); + assertTrue(replace.parsedPath().isAppend()); + assertEquals(JsonPatch.Op.REMOVE, remove.getOp()); + assertTrue(remove.parsedPath().isRoot()); + assertNull(remove.getValue()); + assertThrows(UnsupportedOperationException.class, + () -> add.parsedPath().segments().add("mutation")); + } + + @Test + void equalityDoesNotAliasDistinctAuthoredRepresentationsWithTheSameBlueId() { + Node materialized = new Node().properties("payload", new Node().value("value")); + FrozenNode materializedValue = FrozenNode.fromNode(materialized); + FrozenNode referenceValue = FrozenNode.fromNode( + new Node().blueId(materializedValue.blueId())); + + FrozenJsonPatch materializedPatch = FrozenJsonPatch.add("/slot", materializedValue); + FrozenJsonPatch referencePatch = FrozenJsonPatch.add("/slot", referenceValue); + + assertEquals(materializedValue.blueId(), referenceValue.blueId()); + assertNotEquals(materializedPatch, referencePatch); + } + + @Test + void historicalAndEmptyRootSpellingsShareParsedRootButPreserveAuthoredText() { + FrozenJsonPatch empty = FrozenJsonPatch.remove(""); + FrozenJsonPatch slash = FrozenJsonPatch.remove("/"); + + assertEquals("", empty.getPath()); + assertEquals("/", slash.getPath()); + assertSame(empty.parsedPath(), slash.parsedPath()); + } + + @Test + void atomicRuntimeAcceptsBothSupportedRootSpellings() { + Node slashDocument = new Node().properties("before", new Node().value(true)); + Node emptyDocument = slashDocument.clone(); + FrozenNode replacement = FrozenNode.fromNode( + new Node().properties("after", new Node().value(true))); + + new DocumentProcessingRuntime(slashDocument).applyFrozenPatch( + "/", FrozenJsonPatch.replace("/", replacement)); + new DocumentProcessingRuntime(emptyDocument).applyFrozenPatch( + "/", FrozenJsonPatch.replace("", replacement)); + + assertEquals(Boolean.TRUE, slashDocument.get("/after")); + assertEquals(Boolean.TRUE, emptyDocument.get("/after")); + assertNull(slashDocument.getProperties().get("before")); + assertNull(emptyDocument.getProperties().get("before")); + } + + @Test + void resolvedDocumentViewsAreRejected() { + FrozenNode resolved = FrozenNode.fromResolvedNode(new Node().value("inherited")); + + assertThrows(IllegalArgumentException.class, + () -> FrozenJsonPatch.add("/value", resolved)); + assertThrows(IllegalArgumentException.class, + () -> FrozenJsonPatch.replace("/value", resolved)); + } + + @Test + void historicalPointerNormalizationMatchesMutableBoundary() { + FrozenNode value = FrozenNode.fromNode(new Node().value("value")); + for (String path : Arrays.asList("relative", "/bad~2escape", "/bad~")) { + JsonPatch mutable = JsonPatch.add(path, new Node().value("mutable")); + FrozenJsonPatch frozen = FrozenJsonPatch.add(path, value); + FrozenJsonPatch converted = FrozenJsonPatch.from(mutable); + + assertEquals(path, frozen.getPath()); + assertEquals(frozen.parsedPath(), converted.parsedPath()); + assertEquals(blue.language.utils.ParsedJsonPointer.parse(path), + frozen.parsedPath()); + + Node mutableDocument = new Node(); + Node frozenDocument = new Node(); + new DocumentProcessingRuntime(mutableDocument).applyPatch("/", mutable); + new DocumentProcessingRuntime(frozenDocument).applyFrozenPatch("/", frozen); + assertEquals("mutable", mutableDocument.getNode( + frozen.parsedPath().pointer()).getValue()); + assertEquals("value", frozenDocument.getNode( + frozen.parsedPath().pointer()).getValue()); + } + } + + @Test + void conversionFromMutablePatchIsolatedFromCallerMutation() { + Node authored = new Node().properties("nested", new Node().value("before")); + FrozenJsonPatch patch = FrozenJsonPatch.from(JsonPatch.add("/payload", authored)); + + authored.getProperties().get("nested").value("after"); + + assertEquals("before", patch.getValue().property("nested").getValue()); + } + + @Test + void rawJsonValuesAreDeeplySnapshottedForBothFrozenPatchEntryPoints() { + List convertedItems = new ArrayList<>(); + convertedItems.add("before"); + Map convertedRaw = new LinkedHashMap<>(); + convertedRaw.put("items", convertedItems); + FrozenJsonPatch converted = FrozenJsonPatch.from(JsonPatch.replace( + "/payload", new Node().value(convertedRaw))); + String convertedBlueId = converted.getValue().blueId(); + + List directItems = new ArrayList<>(); + directItems.add("before"); + Map directRaw = new LinkedHashMap<>(); + directRaw.put("items", directItems); + FrozenNode directValue = FrozenNode.fromNode(new Node().value(directRaw)); + FrozenJsonPatch direct = FrozenJsonPatch.add("/payload", directValue); + String directBlueId = direct.getValue().blueId(); + + convertedItems.set(0, "after"); + convertedRaw.put("extra", true); + directItems.set(0, "after"); + directRaw.put("extra", true); + + assertRawJsonSnapshot(converted, convertedBlueId); + assertRawJsonSnapshot(direct, directBlueId); + assertSame(directValue, direct.getValue(), + "the direct frozen handoff must remain allocation-free"); + + Node document = new Node().properties("payload", new Node().value("old")); + new DocumentProcessingRuntime(document).applyFrozenPatch("/", converted); + Map applied = (Map) document.getNode("/payload").getValue(); + assertEquals(Collections.singletonList("before"), applied.get("items")); + assertFalse(applied.containsKey("extra")); + } + + @Test + void runtimeFrozenBatchMatchesMutableBatchAndRemainsAtomic() { + Node mutableDocument = new Node().properties("status", new Node().value("idle")); + Node frozenDocument = mutableDocument.clone(); + List mutable = Arrays.asList( + JsonPatch.replace("/status", new Node().value("active")), + JsonPatch.add("/count", new Node().value(2))); + List frozen = Arrays.asList( + FrozenJsonPatch.from(mutable.get(0)), + FrozenJsonPatch.from(mutable.get(1))); + + new DocumentProcessingRuntime(mutableDocument).applyPatches("/", mutable); + new DocumentProcessingRuntime(frozenDocument).applyFrozenPatches("/", frozen); + + assertEquals(mutableDocument.getAsText("/status"), frozenDocument.getAsText("/status")); + assertEquals(mutableDocument.getAsInteger("/count"), frozenDocument.getAsInteger("/count")); + + Node rollback = new Node().properties("status", new Node().value("idle")); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(rollback); + assertThrows(IllegalStateException.class, () -> runtime.applyFrozenPatches("/", Arrays.asList( + FrozenJsonPatch.replace("/status", FrozenNode.fromNode(new Node().value("active"))), + FrozenJsonPatch.remove("/missing")))); + assertEquals("idle", rollback.getAsText("/status")); + } + + @Test + void escapedObjectPathsAndArrayAppendUseTheCapturedParsedPointer() { + Node document = new Node().properties( + "a/b", new Node().properties("~key", new Node().value("before")), + "rows", new Node().items(new Node().value("first"))); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(document); + + runtime.applyFrozenPatches("/", Arrays.asList( + FrozenJsonPatch.replace("/a~1b/~0key", + FrozenNode.fromNode(new Node().value("after"))), + FrozenJsonPatch.add("/rows/-", + FrozenNode.fromNode(new Node().value("second"))))); + + assertEquals("after", document.getNode("/a~1b/~0key").getValue()); + assertEquals("second", document.getNode("/rows/1").getValue()); + } + + @Test + void workingDocumentUsesFrozenValuesForApplyAndPreview() { + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime( + new Node().properties("status", new Node().value("idle"))); + WorkingDocument working = runtime.workingDocument("/"); + + working.applyFrozenPatch(FrozenJsonPatch.replace( + "/status", FrozenNode.fromNode(new Node().value("active")))); + WorkingDocument.Preview preview = working.previewAndApplyFrozenPatches(Collections.singletonList( + FrozenJsonPatch.add("/count", FrozenNode.fromNode(new Node().value(2))))); + + assertEquals("active", working.resolvedAt("/status").getValue()); + assertEquals(2, ((Number) working.resolvedAt("/count").getValue()).intValue()); + assertEquals(1, preview.size()); + assertEquals("idle", runtime.document().getAsText("/status")); + } + + @Test + void frozenPatchWorksWithStrictAndUncheckedCanonicalSnapshotRoots() { + Node document = new Node().properties("value", new Node().value("before")); + FrozenJsonPatch patch = FrozenJsonPatch.replace( + "/value", FrozenNode.fromNode(new Node().value("after"))); + + ResolvedSnapshot strict = new ResolvedSnapshot( + FrozenNode.fromNode(document), FrozenNode.fromResolvedNode(document)); + ResolvedSnapshot unchecked = new ResolvedSnapshot( + FrozenNode.fromUncheckedCanonicalNode(document), FrozenNode.fromResolvedNode(document)); + WorkingDocument strictWorking = workingDocument(strict); + WorkingDocument uncheckedWorking = workingDocument(unchecked); + + strictWorking.applyFrozenPatch(patch); + uncheckedWorking.applyFrozenPatch(patch); + + assertEquals("after", strictWorking.resolvedAt("/value").getValue()); + assertEquals("after", uncheckedWorking.resolvedAt("/value").getValue()); + assertTrue(strictWorking.canonicalAt("/value").isStrictCanonical()); + assertTrue(uncheckedWorking.canonicalAt("/value").isStrictCanonical()); + assertFalse(uncheckedWorking.canonicalAt("/value").isStrictBlueIdValidation()); + } + + @Test + void referenceTypedAndSchemaAuthoredValuesRemainAcceptedCanonicalValues() { + FrozenJsonPatch reference = FrozenJsonPatch.add("/reference", + FrozenNode.fromNode(new Node().blueId(TEXT_TYPE_BLUE_ID))); + FrozenJsonPatch typed = FrozenJsonPatch.add("/typed", FrozenNode.fromNode(new Node() + .type(new Node().blueId(TEXT_TYPE_BLUE_ID)) + .value("text"))); + FrozenJsonPatch schema = FrozenJsonPatch.add("/schema", FrozenNode.fromNode(new Node() + .schema(new Schema().minLength(1)))); + FrozenJsonPatch cyclicMember = FrozenJsonPatch.add("/cyclic", FrozenNode.fromNode( + new Node().blueId(TEXT_TYPE_BLUE_ID + "#0"))); + + assertTrue(reference.getValue().isReferenceOnly()); + assertEquals(TEXT_TYPE_BLUE_ID, typed.getValue().getType().getReferenceBlueId()); + assertEquals(1, schema.getValue().getSchema().getMinLengthExact().intValue()); + assertEquals(TEXT_TYPE_BLUE_ID + "#0", cyclicMember.getValue().getReferenceBlueId()); + } + + @Test + void authoredValueConstructionModeConversionMatchesLegacyMaterialization() { + Node authored = new Node().properties( + "typed", new Node().type(new Node().blueId(TEXT_TYPE_BLUE_ID)).value("text"), + "schema", new Node().schema(new Schema().minLength(1)), + "reference", new Node().blueId(TEXT_TYPE_BLUE_ID), + "items", new Node().items(new Node().value("first"), new Node().value("second"))); + FrozenNode frozen = FrozenNode.fromNode(authored); + FrozenNode resolvedMode = FrozenNode.authoredValueInModeOf( + frozen, FrozenNode.fromResolvedNode(new Node())); + FrozenNode uncheckedMode = FrozenNode.authoredValueInModeOf( + frozen, FrozenNode.fromUncheckedCanonicalNode(new Node())); + + FrozenNode legacyResolved = FrozenNode.fromResolvedNode(authored); + FrozenNode legacyUnchecked = FrozenNode.fromUncheckedCanonicalNode(authored); + assertEquals(legacyResolved.resolvedStructuralKey(), resolvedMode.resolvedStructuralKey()); + assertEquals(legacyResolved.blueId(), resolvedMode.blueId()); + assertEquals(legacyUnchecked.blueId(), uncheckedMode.blueId()); + assertEquals(NodeCanonicalizer.canonicalSize(authored), + NodeCanonicalizer.canonicalFrozenSize(frozen)); + } + + @Test + void sameFrozenPatchCanBeReusedConcurrentlyAcrossIndependentRuntimes() throws Exception { + final FrozenJsonPatch patch = FrozenJsonPatch.replace( + "/status", FrozenNode.fromNode(new Node().value("after"))); + ExecutorService executor = Executors.newFixedThreadPool(4); + try { + List> tasks = Arrays.asList( + task(patch), task(patch), task(patch), task(patch), + task(patch), task(patch), task(patch), task(patch)); + for (Future result : executor.invokeAll(tasks)) { + assertEquals("after", result.get()); + } + } finally { + executor.shutdownNow(); + } + } + + @Test + void frozenPathDoesNotFreezeOrMaterializePatchValues() { + RecordingMetrics metrics = new RecordingMetrics(); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime( + new Node(), null, null, metrics); + + runtime.applyFrozenPatch("/", FrozenJsonPatch.add( + "/value", FrozenNode.fromNode(new Node().value("already frozen")))); + + assertEquals(1, metrics.frozenAccepted); + assertEquals(0, metrics.mutableFrozen); + assertEquals(0, metrics.frozenMaterialized); + } + + @Test + void frozenProcessorGasChargeMatchesTheLegacyMutableValueCharge() { + Node structured = new Node().properties( + "typed", new Node().type(new Node().blueId(TEXT_TYPE_BLUE_ID)).value("text"), + "rows", new Node().items(new Node().value(1), new Node().value(2))); + Map raw = new LinkedHashMap<>(); + raw.put("items", Arrays.asList("first", 2L, true)); + + for (Node authored : Arrays.asList( + structured, + new Node().value(raw), + new Node().value(Byte.valueOf((byte) 7)))) { + GasMeter mutable = new GasMeter(); + GasMeter frozen = new GasMeter(); + + mutable.chargePatchAddOrReplace(authored); + frozen.chargeFrozenPatchAddOrReplace(FrozenNode.fromNode(authored)); + + assertEquals(mutable.totalGas(), frozen.totalGas()); + } + } + + @Test + void conversionRetainsLegacyGasSizeWhenCanonicalFreezeDropsEmptyFields() { + Node authored = new Node().properties( + "pad", new Node().value("12345"), + "empty", new Node()); + FrozenJsonPatch converted = FrozenJsonPatch.from( + JsonPatch.add("/payload", authored)); + GasMeter mutable = new GasMeter(); + GasMeter frozen = new GasMeter(); + + assertEquals(101L, NodeCanonicalizer.canonicalSize(authored)); + assertEquals(90L, NodeCanonicalizer.canonicalFrozenSize(converted.getValue())); + assertEquals(101L, converted.getAuthoredCanonicalSizeBytes()); + mutable.chargePatchAddOrReplace(authored); + frozen.chargeFrozenPatchAddOrReplace( + converted.getAuthoredCanonicalSizeBytes()); + + assertEquals(22L, mutable.totalGas()); + assertEquals(mutable.totalGas(), frozen.totalGas()); + } + + @SuppressWarnings("unchecked") + private void assertRawJsonSnapshot(FrozenJsonPatch patch, String expectedBlueId) { + Map captured = (Map) patch.getValue().getValue(); + List capturedItems = (List) captured.get("items"); + assertEquals(Collections.singletonList("before"), capturedItems); + assertFalse(captured.containsKey("extra")); + assertEquals(expectedBlueId, patch.getValue().blueId()); + assertThrows(UnsupportedOperationException.class, + () -> captured.put("mutation", true)); + assertThrows(UnsupportedOperationException.class, + () -> capturedItems.set(0, "mutation")); + } + + private Callable task(final FrozenJsonPatch patch) { + return () -> { + Node document = new Node().properties("status", new Node().value("before")); + new DocumentProcessingRuntime(document).applyFrozenPatch("/", patch); + return document.getAsText("/status"); + }; + } + + private WorkingDocument workingDocument(ResolvedSnapshot snapshot) { + return new WorkingDocument("/", + snapshot.frozenCanonicalRoot(), + snapshot.frozenResolvedRoot(), + null, + null, + null, + snapshot, + false, + true, + ProcessingMetricsSink.NOOP); + } + + private static final class RecordingMetrics implements ProcessingMetricsSink { + private int frozenAccepted; + private int mutableFrozen; + private int frozenMaterialized; + + @Override + public void incrementFrozenPatchValuesAccepted() { + frozenAccepted++; + } + + @Override + public void incrementMutablePatchValuesFrozen() { + mutableFrozen++; + } + + @Override + public void incrementFrozenPatchValuesMaterialized() { + frozenMaterialized++; + } + } +} diff --git a/src/test/java/blue/language/processor/ImmutableJsonPatchTest.java b/src/test/java/blue/language/processor/ImmutableJsonPatchTest.java index fcbfba7..49ed394 100644 --- a/src/test/java/blue/language/processor/ImmutableJsonPatchTest.java +++ b/src/test/java/blue/language/processor/ImmutableJsonPatchTest.java @@ -3,9 +3,11 @@ import blue.language.model.Node; import blue.language.processor.model.JsonPatch; import blue.language.snapshot.FrozenNode; +import blue.language.utils.BlueIdCalculator; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -76,6 +78,23 @@ void sequencePointerCacheIsBounded() { assertEquals(256, context.cachedPointerCount()); } + @Test + void semanticIdentityDoesNotAliasDistinctAuthoredRepresentations() { + Node materialized = new Node().properties("payload", new Node().value("value")); + String blueId = BlueIdCalculator.calculateBlueId(materialized); + FrozenNode canonicalRoot = FrozenNode.fromNode(new Node()); + FrozenNode resolvedRoot = FrozenNode.fromResolvedNode(new Node()); + + ImmutableJsonPatch materializedPatch = ImmutableJsonPatch.from( + JsonPatch.add("/slot", materialized), canonicalRoot, resolvedRoot); + ImmutableJsonPatch referencePatch = ImmutableJsonPatch.from( + JsonPatch.add("/slot", new Node().blueId(blueId)), canonicalRoot, resolvedRoot); + + assertEquals(materializedPatch.valueBlueId(), referencePatch.valueBlueId()); + assertFalse(materializedPatch.matches(referencePatch)); + assertFalse(referencePatch.matches(materializedPatch)); + } + private static final class RecordingMetrics implements ProcessingMetricsSink { private long pointerHits; private long pointerMisses; diff --git a/src/test/java/blue/language/processor/PatchImpactIncrementalResolutionTest.java b/src/test/java/blue/language/processor/PatchImpactIncrementalResolutionTest.java new file mode 100644 index 0000000..79ce1f3 --- /dev/null +++ b/src/test/java/blue/language/processor/PatchImpactIncrementalResolutionTest.java @@ -0,0 +1,517 @@ +package blue.language.processor; + +import blue.language.Blue; +import blue.language.NodeProvider; +import blue.language.conformance.ConformanceEngine; +import blue.language.merge.MergingProcessor; +import blue.language.merge.NodeResolver; +import blue.language.model.Node; +import blue.language.model.Schema; +import blue.language.processor.model.JsonPatch; +import blue.language.provider.BasicNodeProvider; +import blue.language.snapshot.FrozenNode; +import blue.language.snapshot.ResolvedSnapshot; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static blue.language.utils.Properties.TEXT_TYPE_BLUE_ID; + +class PatchImpactIncrementalResolutionTest { + + @Test + void dependencyFreeTypedScalarReplacementMatchesFullOracleAfterEveryPatch() { + Fixture fixture = Fixture.withUnrelatedTypeContribution(); + ResolvedSnapshot base = fixture.snapshot(); + FrozenNode unaffected = base.resolvedAt("/inheritedUnrelated"); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + + DocumentProcessor processor = fixture.blue.getDocumentProcessor(); + DocumentProcessingRuntime incremental = new DocumentProcessingRuntime( + base, + processor.conformanceEngine(), + processor.snapshotManager(), + metrics); + FullOracleSnapshotManager oracleManager = new FullOracleSnapshotManager(fixture.blue, false); + DocumentProcessingRuntime oracle = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + oracleManager); + + List patches = Arrays.asList( + JsonPatch.replace("/status", new Node().value("confirmed")), + JsonPatch.replace("/status", new Node().value("fulfilled")), + JsonPatch.replace("/status", new Node().value("settled"))); + for (JsonPatch patch : patches) { + DocumentProcessingRuntime.DocumentUpdateData incrementalUpdate = + incremental.applyPatch("/", patch); + DocumentProcessingRuntime.DocumentUpdateData oracleUpdate = + oracle.applyPatch("/", patch); + + assertSnapshotEquals(fixture.blue, oracle.snapshot(), incremental.snapshot()); + assertEquals(fixture.blue.nodeToJson(oracleUpdate.before()), + fixture.blue.nodeToJson(incrementalUpdate.before())); + assertEquals(fixture.blue.nodeToJson(oracleUpdate.after()), + fixture.blue.nodeToJson(incrementalUpdate.after())); + assertEquals(oracleUpdate.path(), incrementalUpdate.path()); + assertEquals(oracleUpdate.op(), incrementalUpdate.op()); + } + + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(3L, snapshot.counter("patchImpactAnalyses")); + assertEquals(3L, snapshot.counter("patchImpactValueOnly"), snapshot.toString()); + assertEquals(3L, snapshot.counter("incrementalSnapshotResolutions")); + assertEquals(0L, snapshot.counter("fullSnapshotFallbacks")); + assertEquals(0L, snapshot.counter("fullCanonicalRootMaterializations")); + assertEquals(0L, snapshot.counter("fullResolvedRootMaterializations")); + assertEquals(0L, snapshot.counter("conformancePlans")); + assertEquals(3, oracleManager.fullResolutions); + assertSame(unaffected, incremental.snapshot().resolvedAt("/inheritedUnrelated"), + "the incremental splice must retain an unrelated resolved subtree by identity"); + } + + @Test + void basicTypedLeafReplacementPreservesResolvedMetadataAndMatchesFullOracleAfterEveryPatch() { + Fixture fixture = Fixture.withBasicStatusTypeContribution(); + ResolvedSnapshot base = fixture.snapshot(); + FrozenNode unaffected = base.resolvedAt("/inheritedUnrelated"); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + FullOracleSnapshotManager incrementalManager = new FullOracleSnapshotManager(fixture.blue, true); + DocumentProcessingRuntime incremental = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + incrementalManager, + metrics); + FullOracleSnapshotManager oracleManager = new FullOracleSnapshotManager(fixture.blue, false); + DocumentProcessingRuntime oracle = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + oracleManager); + + List patches = Arrays.asList( + JsonPatch.replace("/status", new Node().value("confirmed")), + JsonPatch.replace("/status", new Node().value("fulfilled")), + JsonPatch.replace("/status", new Node().value("settled"))); + for (JsonPatch patch : patches) { + DocumentProcessingRuntime.DocumentUpdateData incrementalUpdate = + incremental.applyPatch("/", patch); + DocumentProcessingRuntime.DocumentUpdateData oracleUpdate = + oracle.applyPatch("/", patch); + + assertSnapshotEquals(fixture.blue, oracle.snapshot(), incremental.snapshot()); + assertEquals(fixture.blue.nodeToJson(oracleUpdate.before()), + fixture.blue.nodeToJson(incrementalUpdate.before())); + assertEquals(fixture.blue.nodeToJson(oracleUpdate.after()), + fixture.blue.nodeToJson(incrementalUpdate.after())); + FrozenNode resolvedStatus = incremental.snapshot().resolvedAt("/status"); + assertEquals(TEXT_TYPE_BLUE_ID, resolvedStatus.getType().getReferenceBlueId()); + assertSame(unaffected, incremental.snapshot().resolvedAt("/inheritedUnrelated")); + } + + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(3L, snapshot.counter("patchImpactValueOnly"), snapshot.toString()); + assertEquals(3L, snapshot.counter("incrementalSnapshotResolutions")); + assertEquals(0L, snapshot.counter("fullSnapshotFallbacks")); + assertEquals(0L, snapshot.counter("fullCanonicalRootMaterializations")); + assertEquals(0L, snapshot.counter("fullResolvedRootMaterializations")); + assertEquals(0L, snapshot.counter("conformancePlans")); + assertEquals(0, incrementalManager.fullResolutions); + assertEquals(3, oracleManager.fullResolutions); + } + + @Test + void nonEmptyProcessorContractsRemainSharedAcrossTypedLeafFastPathPatches() { + Fixture fixture = Fixture.withBasicStatusTypeContribution(); + ResolvedSnapshot base = fixture.snapshotWithNonEmptyContracts(); + FrozenNode unaffectedContract = base.resolvedAt("/contracts/retained"); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + FullOracleSnapshotManager incrementalManager = new FullOracleSnapshotManager(fixture.blue, true); + DocumentProcessingRuntime incremental = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + incrementalManager, + metrics); + FullOracleSnapshotManager oracleManager = new FullOracleSnapshotManager(fixture.blue, false); + DocumentProcessingRuntime oracle = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + oracleManager); + + List patches = Arrays.asList( + JsonPatch.replace("/status", new Node().value("confirmed")), + JsonPatch.replace("/status", new Node().value("fulfilled")), + JsonPatch.replace("/status", new Node().value("settled"))); + for (JsonPatch patch : patches) { + DocumentProcessingRuntime.DocumentUpdateData incrementalUpdate = + incremental.applyPatch("/", patch); + DocumentProcessingRuntime.DocumentUpdateData oracleUpdate = + oracle.applyPatch("/", patch); + + assertSnapshotEquals(fixture.blue, oracle.snapshot(), incremental.snapshot()); + assertEquals(fixture.blue.nodeToJson(oracleUpdate.before()), + fixture.blue.nodeToJson(incrementalUpdate.before())); + assertEquals(fixture.blue.nodeToJson(oracleUpdate.after()), + fixture.blue.nodeToJson(incrementalUpdate.after())); + assertSame(unaffectedContract, + incremental.snapshot().resolvedAt("/contracts/retained")); + } + + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(3L, snapshot.counter("incrementalSnapshotResolutions")); + assertEquals(0L, snapshot.counter("fullSnapshotFallbacks")); + assertEquals(0L, snapshot.counter("fullCanonicalRootMaterializations")); + assertEquals(0L, snapshot.counter("fullResolvedRootMaterializations")); + assertEquals(0, incrementalManager.fullResolutions); + assertEquals(3, oracleManager.fullResolutions); + } + + @Test + void patchUnderContractsUsesNamedFullFallbackAndMatchesOracle() { + Fixture fixture = Fixture.withBasicStatusTypeContribution(); + ResolvedSnapshot base = fixture.snapshotWithNonEmptyContracts(); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + FullOracleSnapshotManager incrementalManager = new FullOracleSnapshotManager(fixture.blue, true); + DocumentProcessingRuntime incremental = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + incrementalManager, + metrics); + FullOracleSnapshotManager oracleManager = new FullOracleSnapshotManager(fixture.blue, false); + DocumentProcessingRuntime oracle = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + oracleManager); + + JsonPatch patch = JsonPatch.replace( + "/contracts/retained/processorState", new Node().value("busy")); + incremental.applyPatch("/", patch); + oracle.applyPatch("/", patch); + + assertSnapshotEquals(fixture.blue, oracle.snapshot(), incremental.snapshot()); + assertEquals(1, incrementalManager.fullResolutions); + assertEquals(1L, metrics.snapshot().counter("fullSnapshotFallbacks")); + assertEquals(1L, metrics.snapshot().counter( + "fullSnapshotFallbackReason.CONTRACTS_CHANGED")); + assertEquals(0L, metrics.snapshot().counter("incrementalSnapshotResolutions")); + } + + @Test + void typeContributionOnChangedPathUsesOneExplicitFullFallbackAndMatchesOracle() { + Fixture fixture = Fixture.withFixedStatusSubtype(); + ResolvedSnapshot base = fixture.snapshot(); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + FullOracleSnapshotManager incrementalManager = new FullOracleSnapshotManager(fixture.blue, true); + DocumentProcessingRuntime incremental = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + incrementalManager, + metrics); + FullOracleSnapshotManager oracleManager = new FullOracleSnapshotManager(fixture.blue, false); + DocumentProcessingRuntime oracle = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + oracleManager); + + JsonPatch patch = JsonPatch.replace("/status", new Node().value("published")); + incremental.applyPatch("/", patch); + oracle.applyPatch("/", patch); + + assertSnapshotEquals(fixture.blue, oracle.snapshot(), incremental.snapshot()); + assertEquals(fixture.parentTypeId, + incremental.snapshot().canonicalRoot().getAsText("/type/blueId")); + assertEquals(1, incrementalManager.fullResolutions); + assertEquals(1L, metrics.snapshot().counter("fullSnapshotFallbacks")); + assertEquals(1L, metrics.snapshot().counter( + "fullSnapshotFallbackReason.TYPE_GRAPH_CHANGED")); + assertEquals(0L, metrics.snapshot().counter("incrementalSnapshotResolutions")); + } + + @Test + void schemaBearingTypedLeafUsesOneExplicitFullFallbackAndMatchesOracle() { + Fixture fixture = Fixture.withSchemaStatusTypeContribution(); + ResolvedSnapshot base = fixture.snapshot(); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + FullOracleSnapshotManager incrementalManager = new FullOracleSnapshotManager(fixture.blue, true); + DocumentProcessingRuntime incremental = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + incrementalManager, + metrics); + FullOracleSnapshotManager oracleManager = new FullOracleSnapshotManager(fixture.blue, false); + DocumentProcessingRuntime oracle = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + oracleManager); + + JsonPatch patch = JsonPatch.replace("/status", new Node().value("published")); + incremental.applyPatch("/", patch); + oracle.applyPatch("/", patch); + + assertSnapshotEquals(fixture.blue, oracle.snapshot(), incremental.snapshot()); + assertEquals(1, incrementalManager.fullResolutions); + assertEquals(1L, metrics.snapshot().counter("fullSnapshotFallbacks")); + assertEquals(1L, metrics.snapshot().counter( + "fullSnapshotFallbackReason.SCHEMA_GRAPH_CHANGED")); + assertEquals(0L, metrics.snapshot().counter("incrementalSnapshotResolutions")); + } + + @Test + void emptyContractsNormalizationPreventsTheTypedLeafFastPathAndMatchesTheFullOracle() { + Fixture fixture = Fixture.withBasicStatusTypeContribution(); + ResolvedSnapshot base = fixture.snapshotWithEmptyContracts(); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + FullOracleSnapshotManager incrementalManager = new FullOracleSnapshotManager(fixture.blue, true); + DocumentProcessingRuntime incremental = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + incrementalManager, + metrics); + FullOracleSnapshotManager oracleManager = new FullOracleSnapshotManager(fixture.blue, false); + DocumentProcessingRuntime oracle = new DocumentProcessingRuntime( + base, + fixture.blue.conformanceEngine(), + oracleManager); + + JsonPatch patch = JsonPatch.replace("/status", new Node().value("published")); + incremental.applyPatch("/", patch); + oracle.applyPatch("/", patch); + + assertSnapshotEquals(fixture.blue, oracle.snapshot(), incremental.snapshot()); + assertEquals(1, incrementalManager.fullResolutions); + assertEquals(1L, metrics.snapshot().counter("fullSnapshotFallbacks")); + assertEquals(1L, metrics.snapshot().counter( + "fullSnapshotFallbackReason.UNBOUNDED_SIBLING_DEPENDENCY")); + assertEquals(0L, metrics.snapshot().counter("incrementalSnapshotResolutions")); + } + + @Test + void customMergingProcessorCannotOptIntoBuiltInIncrementalProof() { + Fixture fixture = Fixture.withBasicStatusTypeContribution(); + MergingProcessor custom = new DelegatingMergingProcessor(fixture.blue.getMergingProcessor()); + ConformanceEngine customEngine = new ConformanceEngine(fixture.blue.getNodeProvider(), custom); + assertFalse(customEngine.supportsIncrementalValueResolution()); + + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + FullOracleSnapshotManager manager = new FullOracleSnapshotManager(fixture.blue, true); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime( + fixture.snapshot(), customEngine, manager, metrics); + + runtime.applyPatch("/", JsonPatch.replace("/status", new Node().value("confirmed"))); + + assertEquals(1, manager.fullResolutions); + assertEquals(1L, metrics.snapshot().counter("fullSnapshotFallbacks")); + assertEquals(1L, metrics.snapshot().counter( + "fullSnapshotFallbackReason.CUSTOM_MERGING_PROCESSOR")); + } + + @Test + void impactModelCarriesTypedBoundaryAndDependencyEvidence() { + Fixture fixture = Fixture.withFixedStatusSubtype(); + ResolvedSnapshot base = fixture.snapshot(); + ImmutableJsonPatch patch = ImmutableJsonPatch.from( + JsonPatch.replace("/status", new Node().value("published")), + base.frozenCanonicalRoot(), + base.frozenResolvedRoot()); + ImmutablePatchPlanner.PatchPlan canonicalPlan = + ImmutablePatchPlanner.forFrozen(base.frozenCanonicalRoot()) + .planWithExactReplacement("/", patch); + ImmutablePatchPlanner.PatchPlan resolvedPlan = + ImmutablePatchPlanner.forFrozen(base.frozenResolvedRoot()) + .planWithExactReplacement("/", patch); + FullOracleSnapshotManager manager = new FullOracleSnapshotManager(fixture.blue, true); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + + PatchImpact impact = new PatchImpactAnalyzer( + fixture.blue.conformanceEngine(), null, manager, metrics) + .analyze(true, + base.frozenCanonicalRoot(), + base.frozenResolvedRoot(), + canonicalPlan, + resolvedPlan, + patch); + + assertEquals(PatchImpact.Kind.VALUE_ONLY, impact.kind()); + assertEquals("/status", impact.path().pointer()); + assertEquals(PatchImpact.Shape.SCALAR, impact.beforeShape()); + assertEquals(PatchImpact.Shape.SCALAR, impact.afterShape()); + assertTrue(impact.ancestorChain().contains("/")); + assertTrue(impact.affectedTypedBoundaries().contains("/")); + assertTrue(impact.typeDependency()); + assertFalse(impact.localResolutionProvenSafe()); + assertEquals(PatchImpact.FallbackReason.TYPE_GRAPH_CHANGED, impact.fallbackReason()); + assertEquals(1L, metrics.snapshot().counter("patchImpactAnalyses")); + assertEquals(1L, metrics.snapshot().counter("patchImpactValueOnly")); + } + + private static void assertSnapshotEquals(Blue blue, + ResolvedSnapshot expected, + ResolvedSnapshot actual) { + assertEquals(blue.nodeToJson(expected.canonicalRoot()), + blue.nodeToJson(actual.canonicalRoot())); + assertEquals(blue.nodeToJson(expected.resolvedRoot()), + blue.nodeToJson(actual.resolvedRoot())); + assertEquals(expected.blueId(), actual.blueId()); + } + + private static Node reference(String blueId) { + return new Node().blueId(blueId); + } + + private static final class Fixture { + private final BasicNodeProvider provider; + private final Blue blue; + private final String documentTypeId; + private final String parentTypeId; + + private Fixture(BasicNodeProvider provider, + Blue blue, + String documentTypeId, + String parentTypeId) { + this.provider = provider; + this.blue = blue; + this.documentTypeId = documentTypeId; + this.parentTypeId = parentTypeId; + } + + private static Fixture withUnrelatedTypeContribution() { + BasicNodeProvider provider = new BasicNodeProvider(); + provider.addSingleNodes(new Node() + .name("Incremental Document") + .properties("inheritedUnrelated", new Node().value("retained"))); + String typeId = provider.getBlueIdByName("Incremental Document"); + return new Fixture(provider, new Blue(provider), typeId, null); + } + + private static Fixture withFixedStatusSubtype() { + BasicNodeProvider provider = new BasicNodeProvider(); + provider.addSingleNodes(new Node().name("Publishable Document")); + String parentId = provider.getBlueIdByName("Publishable Document"); + provider.addSingleNodes(new Node() + .name("Draft Document") + .type(reference(parentId)) + .properties("status", new Node().value("draft"))); + String draftId = provider.getBlueIdByName("Draft Document"); + return new Fixture(provider, new Blue(provider), draftId, parentId); + } + + private static Fixture withBasicStatusTypeContribution() { + BasicNodeProvider provider = new BasicNodeProvider(); + provider.addSingleNodes(new Node() + .name("Basic Typed Status Document") + .properties( + "status", new Node().type(reference(TEXT_TYPE_BLUE_ID)), + "inheritedUnrelated", new Node().value("retained"))); + String typeId = provider.getBlueIdByName("Basic Typed Status Document"); + return new Fixture(provider, new Blue(provider), typeId, null); + } + + private static Fixture withSchemaStatusTypeContribution() { + BasicNodeProvider provider = new BasicNodeProvider(); + provider.addSingleNodes(new Node() + .name("Schema Typed Status Document") + .properties("status", new Node() + .type(reference(TEXT_TYPE_BLUE_ID)) + .schema(new Schema().minLength(1)))); + String typeId = provider.getBlueIdByName("Schema Typed Status Document"); + return new Fixture(provider, new Blue(provider), typeId, null); + } + + private ResolvedSnapshot snapshot() { + return blue.resolveToSnapshot(new Node() + .type(reference(documentTypeId)) + .properties("status", new Node().value("draft"))); + } + + private ResolvedSnapshot snapshotWithEmptyContracts() { + return blue.resolveToSnapshot(new Node() + .type(reference(documentTypeId)) + .properties("status", new Node().value("draft")) + .contracts(new Node())); + } + + private ResolvedSnapshot snapshotWithNonEmptyContracts() { + return blue.resolveToSnapshot(new Node() + .type(reference(documentTypeId)) + .properties("status", new Node().value("draft")) + .contracts(new Node().properties( + "retained", new Node().properties( + "processorState", new Node().value("idle"))))); + } + } + + private static final class FullOracleSnapshotManager implements ProcessingSnapshotManager { + private final Blue blue; + private final boolean incrementalCapability; + private int fullResolutions; + + private FullOracleSnapshotManager(Blue blue, boolean incrementalCapability) { + this.blue = blue; + this.incrementalCapability = incrementalCapability; + } + + @Override + public ResolvedSnapshot fromDocument(Node document) { + return fromDocumentTransient(document); + } + + @Override + public ResolvedSnapshot fromDocumentTransient(Node document) { + fullResolutions++; + return blue.resolveToSnapshot(document); + } + + @Override + public boolean supportsIncrementalValueResolution() { + return incrementalCapability; + } + + @Override + public ResolvedSnapshot applyPatch(ResolvedSnapshot snapshot, JsonPatch patch) { + throw new AssertionError("planning must use the canonical authoritative oracle"); + } + } + + private static final class DelegatingMergingProcessor implements MergingProcessor { + private final MergingProcessor delegate; + + private DelegatingMergingProcessor(MergingProcessor delegate) { + this.delegate = delegate; + } + + @Override + public void process(Node target, + Node source, + NodeProvider nodeProvider, + NodeResolver nodeResolver) { + delegate.process(target, source, nodeProvider, nodeResolver); + } + + @Override + public void postProcess(Node target, + Node source, + NodeProvider nodeProvider, + NodeResolver nodeResolver) { + delegate.postProcess(target, source, nodeProvider, nodeResolver); + } + + @Override + public boolean hasCompletedValidation(Node node) { + return delegate.hasCompletedValidation(node); + } + + @Override + public boolean requiresReferenceMaterialization(Node node) { + return delegate.requiresReferenceMaterialization(node); + } + + @Override + public void validateCompleted(Node node, boolean semanticallyPresent, String path) { + delegate.validateCompleted(node, semanticallyPresent, path); + } + } +} diff --git a/src/test/java/blue/language/processor/PatchSequenceRandomizedDifferentialTest.java b/src/test/java/blue/language/processor/PatchSequenceRandomizedDifferentialTest.java index cb82b6d..ba30867 100644 --- a/src/test/java/blue/language/processor/PatchSequenceRandomizedDifferentialTest.java +++ b/src/test/java/blue/language/processor/PatchSequenceRandomizedDifferentialTest.java @@ -29,7 +29,7 @@ public void recordAfterNodeMaterialization() { @Test void randomizedSequentialCheckpointsUpdatesAndFinalIdsMatchPublicSingletonPatching() { - int[] counts = {1, 8, 16, 32, 64}; + int[] counts = {0, 1, 2, 4, 8, 16, 32, 64, 128}; for (int count : counts) { for (int scenario = 0; scenario < 8; scenario++) { long seed = 0x5E0A11A1L + 1_009L * count + scenario; diff --git a/src/test/java/blue/language/processor/PreparedPatchSequenceTest.java b/src/test/java/blue/language/processor/PreparedPatchSequenceTest.java index 0f1046d..a88588f 100644 --- a/src/test/java/blue/language/processor/PreparedPatchSequenceTest.java +++ b/src/test/java/blue/language/processor/PreparedPatchSequenceTest.java @@ -2,6 +2,7 @@ import blue.language.conformance.ConformancePlan; import blue.language.model.Node; +import blue.language.processor.model.FrozenJsonPatch; import blue.language.processor.model.JsonPatch; import blue.language.processor.util.NodeCanonicalizer; import blue.language.snapshot.CanonicalPatchResult; @@ -12,6 +13,7 @@ import java.math.BigInteger; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -130,6 +132,54 @@ void matchingPreviewCommitsWithoutReplanningAndOnlyFinalStepEntersSharedCache() } } + @Test + void frozenPreviewWithIdentityEquivalentDifferentRepresentationIsReplanned() { + Node materialized = new Node().properties("payload", new Node().value("value")); + FrozenNode materializedValue = FrozenNode.fromNode(materialized); + FrozenNode referenceValue = FrozenNode.fromNode( + new Node().blueId(materializedValue.blueId())); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(new Node()); + List previewPatches = Arrays.asList( + FrozenJsonPatch.add("/slot", materializedValue)); + WorkingDocument.Preview preview = runtime.workingDocument("/") + .previewAndApplyFrozenPatches(previewPatches); + List requested = Arrays.asList( + FrozenJsonPatch.add("/slot", referenceValue)); + + try (DocumentProcessingRuntime.PreparedPatchSequence sequence = + runtime.prepareFrozenPatchSequence("/", requested, preview)) { + sequence.applyNext(0); + } + + Node committed = runtime.document().getNode("/slot"); + assertTrue(committed.isReferenceOnly()); + assertEquals(referenceValue.blueId(), committed.getBlueId()); + assertEquals(1, runtime.sequenceStalePreviewFallbacksForTest()); + } + + @Test + void mutablePreviewWithIdentityEquivalentDifferentRepresentationIsReplanned() { + Node materialized = new Node().properties("payload", new Node().value("value")); + String blueId = FrozenNode.fromNode(materialized).blueId(); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(new Node()); + List previewPatches = Arrays.asList( + JsonPatch.add("/slot", materialized)); + WorkingDocument.Preview preview = runtime.workingDocument("/") + .previewAndApplyPatches(previewPatches); + List requested = Arrays.asList( + JsonPatch.add("/slot", new Node().blueId(blueId))); + + try (DocumentProcessingRuntime.PreparedPatchSequence sequence = + runtime.preparePatchSequence("/", requested, preview)) { + sequence.applyNext(0); + } + + Node committed = runtime.document().getNode("/slot"); + assertTrue(committed.isReferenceOnly()); + assertEquals(blueId, committed.getBlueId()); + assertEquals(1, runtime.sequenceStalePreviewFallbacksForTest()); + } + @Test void mutationBetweenPreparedStepsRebasesSuffixAndUsesActualBeforeState() { CountingSnapshotManager manager = new CountingSnapshotManager(); @@ -270,6 +320,39 @@ void closingPartiallyConsumedPreviewReleasesUnconsumedSuffix() { "closing after an intermediate advance must promote the surviving prefix"); } + @Test + void transientManagerOwnershipReleasesWorkingPreviewAndSequenceScopes() { + ReleasingSnapshotManager manager = new ReleasingSnapshotManager(); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime( + new Node(), null, manager); + List patches = Collections.singletonList( + JsonPatch.add("/value", new Node().value(1))); + + WorkingDocument firstWorking = runtime.workingDocument("/"); + WorkingDocument.Preview discarded = firstWorking.previewAndApplyPatches(patches); + firstWorking.close(); + discarded.close(); + assertEquals(2, manager.releaseCalls); + + WorkingDocument secondWorking = runtime.workingDocument("/"); + WorkingDocument.Preview transferred = secondWorking.previewAndApplyPatches(patches); + secondWorking.close(); + int beforeTransfer = manager.releaseCalls; + try (DocumentProcessingRuntime.PreparedPatchSequence sequence = + runtime.preparePatchSequence("/", patches, transferred)) { + sequence.applyNext(0); + transferred.close(); + assertEquals(beforeTransfer, manager.releaseCalls, + "a transferred preview no longer owns the handoff scope"); + } + + assertEquals(beforeTransfer + 1, manager.releaseCalls, + "the prepared sequence releases the transferred scope"); + assertEquals(manager.openCalls, manager.releaseCalls); + assertThrows(IllegalStateException.class, + () -> secondWorking.applyPatch(JsonPatch.remove("/value"))); + } + @Test void sequenceCopiesEveryAuthoredPatchValueBeforeTheFirstStep() { CountingSnapshotManager manager = new CountingSnapshotManager(); @@ -461,6 +544,65 @@ public ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { } } + private static final class ReleasingSnapshotManager extends CountingSnapshotManager { + private int openCalls; + private int releaseCalls; + + @Override + public ProcessingSnapshotManager transientSequence() { + openCalls++; + return new ReleasingScope(this); + } + } + + private static final class ReleasingScope implements ProcessingSnapshotManager { + private final ReleasingSnapshotManager owner; + private boolean released; + + private ReleasingScope(ReleasingSnapshotManager owner) { + this.owner = owner; + } + + @Override + public ResolvedSnapshot fromDocument(Node document) { + return owner.fromDocument(document); + } + + @Override + public ResolvedSnapshot fromDocumentTransient(Node document) { + return owner.fromDocument(document); + } + + @Override + public ResolvedSnapshot applyPatch(ResolvedSnapshot snapshot, JsonPatch patch) { + return owner.applyPatch(snapshot, patch); + } + + @Override + public ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { + return owner.cacheSnapshot(snapshot); + } + + @Override + public ProcessingSnapshotManager transientSequence() { + return this; + } + + @Override + public ProcessingSnapshotManager forkTransientSequence() { + owner.openCalls++; + return new ReleasingScope(owner); + } + + @Override + public void releaseTransientState() { + if (!released) { + released = true; + owner.releaseCalls++; + } + } + } + private static final class RecordingMetrics implements ProcessingMetricsSink { private long patchSequencesPrepared; private long patchesPrepared; diff --git a/src/test/java/blue/language/processor/ProcessEmbeddedTest.java b/src/test/java/blue/language/processor/ProcessEmbeddedTest.java index 527e23d..0a4d731 100644 --- a/src/test/java/blue/language/processor/ProcessEmbeddedTest.java +++ b/src/test/java/blue/language/processor/ProcessEmbeddedTest.java @@ -484,7 +484,9 @@ void embeddedListUpdatesProcessNewChildDuringExternalEvent() { Node rootTerminated = terminatedMarker(processed, "/"); assertNull(rootTerminated); // Dynamic embedded paths mutation is allowed for the paths field. - assertNotNull(processed.getProperties().get("itShouldHappen")); + assertNotNull(processed.getProperties().get("itShouldHappen"), + processResult.status() + ": " + processResult.failureReason() + + "\n" + blue.nodeToYaml(processed)); assertNull(processed.getProperties().get("mustNotHappen")); } diff --git a/src/test/java/blue/language/processor/ProcessingSnapshotProviderPatchTest.java b/src/test/java/blue/language/processor/ProcessingSnapshotProviderPatchTest.java index a646ec5..2ed3399 100644 --- a/src/test/java/blue/language/processor/ProcessingSnapshotProviderPatchTest.java +++ b/src/test/java/blue/language/processor/ProcessingSnapshotProviderPatchTest.java @@ -601,7 +601,11 @@ void reentrantPatchReusesAndDoesNotPopTheOuterSequenceResolverScope() { runtime.preparePatchSequence("/", patches, null)) { sequence.applyNext(0); assertEquals(1, provider.fetchesFor(typeBlueId)); - runtime.applyPatch("/", JsonPatch.add("/nested", new Node().value("reentrant"))); + try (DocumentProcessingRuntime.PreparedPatchSequence nested = + runtime.preparePatchSequence("/", Collections.singletonList( + JsonPatch.add("/nested", new Node().value("reentrant"))), null)) { + nested.applyNext(0); + } assertEquals(1, provider.fetchesFor(typeBlueId)); sequence.applyNext(1); assertEquals(1, provider.fetchesFor(typeBlueId), diff --git a/src/test/java/blue/language/processor/ProcessorOwnedCacheLifecycleTest.java b/src/test/java/blue/language/processor/ProcessorOwnedCacheLifecycleTest.java new file mode 100644 index 0000000..fa0e95e --- /dev/null +++ b/src/test/java/blue/language/processor/ProcessorOwnedCacheLifecycleTest.java @@ -0,0 +1,171 @@ +package blue.language.processor; + +import blue.language.BlueCachePolicy; +import blue.language.NodeProvider; +import blue.language.mapping.NodeToObjectConverter; +import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; +import blue.language.utils.BlueIdCalculator; +import blue.language.utils.TypeClassResolver; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProcessorOwnedCacheLifecycleTest { + + @Test + void contractBundleCacheUsesDeterministicWeightedLruBounds() { + BlueCachePolicy policy = BlueCachePolicy.builder() + .conformancePlans(3, 8_192L) + .maximumDerivedEntryWeightBytes(8_192L) + .build(); + ContractLoader loader = loader(policy); + RecordingMetrics metrics = new RecordingMetrics(); + + loadEmpty(loader, "/a", metrics); + loadEmpty(loader, "/b", metrics); + loadEmpty(loader, "/c", metrics); + loadEmpty(loader, "/a", metrics); + loadEmpty(loader, "/d", metrics); + loadEmpty(loader, "/a", metrics); + loadEmpty(loader, "/b", metrics); + + assertEquals(2L, metrics.hits); + assertEquals(5L, metrics.misses); + assertEquals(3, loader.cacheSize()); + assertTrue(loader.cacheWeightBytes() <= 8_192L); + + loader.clearCaches(); + + assertEquals(0, loader.cacheSize()); + assertEquals(0L, loader.cacheWeightBytes()); + } + + @Test + void declaredLineageCacheUsesPolicyBoundsAndCanBeCleared() { + BlueCachePolicy policy = BlueCachePolicy.builder() + .conformancePlans(3, 2_048L) + .maximumDerivedEntryWeightBytes(2_048L) + .build(); + String parentId = blueId("parent"); + Map definitions = new HashMap<>(); + definitions.put(parentId, new Node().value("parent definition")); + for (int index = 0; index < 12; index++) { + definitions.put(blueId("child-" + index), + new Node().type(new Node().blueId(parentId))); + } + NodeProvider provider = blueId -> { + Node definition = definitions.get(blueId); + return definition != null + ? Collections.singletonList(definition.clone()) + : Collections.emptyList(); + }; + DeclaredTypeLineageMatcher matcher = new DeclaredTypeLineageMatcher(provider, policy); + + for (String childId : definitions.keySet()) { + if (!childId.equals(parentId)) { + assertTrue(matcher.isSameOrDescendant( + new Node().blueId(childId), new Node().blueId(parentId))); + assertTrue(matcher.cacheSize() <= 3); + assertTrue(matcher.cacheWeightBytes() <= 2_048L); + } + } + + matcher.clearCaches(); + + assertEquals(0, matcher.cacheSize()); + assertEquals(0L, matcher.cacheWeightBytes()); + } + + @Test + void documentProcessorClearCachesCascadesToLoaderAndMatchingService() { + ContractProcessorRegistry registry = + ContractProcessorRegistryBuilder.create().registerDefaults().build(); + TypeClassResolver resolver = new TypeClassResolver("blue.language.processor.model"); + ContractMatchingService matchingService = new ContractMatchingService(); + DocumentProcessor processor = new DocumentProcessor( + registry, resolver, null, null, matchingService, ProcessingMetricsSink.NOOP); + + loadEmpty(processor.contractLoader(), "/cached", ProcessingMetricsSink.NOOP); + FrozenNode value = FrozenNode.fromResolvedNode(new Node().value("match")); + assertTrue(matchingService.matches(value, value)); + assertTrue(processor.contractLoader().cacheSize() > 0); + assertTrue(matchingService.matcherCacheSize() > 0); + + processor.clearCaches(); + + assertEquals(0, processor.contractLoader().cacheSize()); + assertEquals(0, matchingService.matcherCacheSize()); + assertEquals(0, matchingService.declaredTypeLineageCacheSize()); + assertTrue(matchingService.matches(value, value), + "clearing must not disable safe recomputation"); + } + + @Test + void reentrantCloseDuringProcessingDefersDetachmentWithoutDeadlock() { + AtomicReference reference = new AtomicReference<>(); + AtomicBoolean closeOnce = new AtomicBoolean(); + ProcessingMetricsSink metrics = new ProcessingMetricsSink() { + @Override + public void addEventPreprocessNanos(long nanos) { + if (closeOnce.compareAndSet(false, true)) { + reference.get().close(); + } + } + }; + DocumentProcessor processor = DocumentProcessor.builder() + .withProcessingMetricsSink(metrics) + .build(); + reference.set(processor); + + DocumentProcessingResult result = processor.processDocument( + new Node(), new Node().value("event")); + + assertTrue(result != null); + assertTrue(processor.isClosed()); + assertFalse(processor.supportsSnapshotProcessing()); + assertEquals(0, processor.cacheEntryCount()); + assertEquals(0L, processor.cacheWeightBytes()); + } + + private ContractLoader loader(BlueCachePolicy policy) { + ContractProcessorRegistry registry = + ContractProcessorRegistryBuilder.create().registerDefaults().build(); + TypeClassResolver resolver = new TypeClassResolver("blue.language.processor.model"); + return new ContractLoader(registry, new NodeToObjectConverter(resolver), resolver, policy); + } + + private ContractBundle loadEmpty(ContractLoader loader, + String scope, + ProcessingMetricsSink metrics) { + return loader.load((Node) null, (FrozenNode) null, scope, metrics); + } + + private String blueId(String value) { + return BlueIdCalculator.calculateBlueId(new Node().value(value)); + } + + private static final class RecordingMetrics implements ProcessingMetricsSink { + private long hits; + private long misses; + + @Override + public void incrementBundleLoadCacheHits() { + hits++; + } + + @Override + public void incrementBundleLoadCacheMisses() { + misses++; + } + } +} diff --git a/src/test/java/blue/language/processor/ProcessorPreviewOwnershipTest.java b/src/test/java/blue/language/processor/ProcessorPreviewOwnershipTest.java new file mode 100644 index 0000000..621a493 --- /dev/null +++ b/src/test/java/blue/language/processor/ProcessorPreviewOwnershipTest.java @@ -0,0 +1,329 @@ +package blue.language.processor; + +import blue.language.model.Node; +import blue.language.processor.model.JsonPatch; +import blue.language.processor.model.SetProperty; +import blue.language.snapshot.CanonicalPatchResult; +import blue.language.snapshot.FrozenNode; +import blue.language.snapshot.ResolvedSnapshot; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ProcessorPreviewOwnershipTest { + + @Test + void successfulBufferingTransfersAndReleasesPreviewOwnership() { + TrackingSnapshotManager manager = new TrackingSnapshotManager(); + Fixture fixture = fixture(manager); + List patches = Collections.singletonList( + JsonPatch.add("/applied", new Node().value("committed"))); + WorkingDocument.Preview preview = preview(fixture.context, patches); + + fixture.context.applyPreviewedPatches(patches, preview); + fixture.context.applyBufferedEffects(); + + assertEquals("committed", fixture.execution.runtime().document().getAsText("/applied")); + assertNull(preview.patch(0)); + assertEquals(manager.openCalls, manager.releaseCalls); + } + + @Test + void invalidGasReleasesBufferedPreviewBeforeFatalExit() { + TrackingSnapshotManager manager = new TrackingSnapshotManager(); + Fixture fixture = fixture(manager); + List patches = Collections.singletonList( + JsonPatch.add("/notApplied", new Node().value(1))); + WorkingDocument.Preview preview = preview(fixture.context, patches); + + fixture.context.applyPreviewedPatches(patches, preview); + fixture.context.consumeGas(-1L); + + assertThrows(RunTerminationException.class, fixture.context::applyBufferedEffects); + assertNull(preview.patch(0)); + assertEquals(manager.openCalls, manager.releaseCalls); + assertNull(nodeAt(fixture.execution.runtime().document(), "/notApplied")); + } + + @Test + void earlyBatchTerminationReleasesEveryLaterBufferedPreview() { + TrackingSnapshotManager manager = new TrackingSnapshotManager(); + Fixture fixture = fixture(manager); + List reserved = Collections.singletonList( + JsonPatch.add("/contracts/checkpoint", new Node().value("forbidden"))); + List later = Collections.singletonList( + JsonPatch.add("/notApplied", new Node().value(2))); + WorkingDocument.Preview reservedPreview = preview(fixture.context, reserved); + WorkingDocument.Preview laterPreview = preview(fixture.context, later); + + fixture.context.applyPreviewedPatches(reserved, reservedPreview); + fixture.context.applyPreviewedPatches(later, laterPreview); + + assertThrows(RunTerminationException.class, fixture.context::applyBufferedEffects); + assertNull(reservedPreview.patch(0)); + assertNull(laterPreview.patch(0)); + assertEquals(manager.openCalls, manager.releaseCalls); + assertNull(nodeAt(fixture.execution.runtime().document(), "/notApplied")); + } + + @Test + void handlerExceptionReleasesPreviewRetainedByBufferedEffects() { + TrackingSnapshotManager manager = new TrackingSnapshotManager(); + PreviewThenThrowProcessor handler = new PreviewThenThrowProcessor(); + ContractProcessorRegistry registry = ContractProcessorRegistryBuilder.create() + .register(handler) + .build(); + DocumentProcessor owner = DocumentProcessor.builder() + .withRegistry(registry) + .withSnapshotManager(manager) + .build(); + ProcessorEngine.Execution execution = new ProcessorEngine.Execution(owner, new Node()); + SetProperty contract = new SetProperty(); + contract.setChannelKey("events"); + ContractBundle bundle = ContractBundle.builder() + .addHandler("throwing", contract) + .build(); + ChannelRunner runner = new ChannelRunner(owner, + execution, + execution.runtime(), + new CheckpointManager(execution.runtime())); + + assertThrows(RunTerminationException.class, + () -> runner.runHandlers("/", bundle, "events", new Node())); + + assertTrue(handler.preview != null); + assertNull(handler.preview.patch(0)); + assertEquals(manager.openCalls, manager.releaseCalls); + assertNull(nodeAt(execution.runtime().document(), "/notApplied")); + } + + @Test + void failedWorkingPreviewRetainReleasesTheUnreturnedFork() { + TrackingSnapshotManager manager = new TrackingSnapshotManager(); + manager.failNextRetain = true; + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime( + new Node(), null, manager); + + assertThrows(IllegalStateException.class, () -> { + try (WorkingDocument working = runtime.workingDocument("/")) { + working.previewAndApplyPatches(Collections.singletonList( + JsonPatch.add("/value", new Node().value(1)))); + } + }); + + assertEquals(2, manager.openCalls, + "one working scope and one handoff fork must have opened"); + assertEquals(manager.openCalls, manager.releaseCalls, + "both the failed fork and the working scope must be released"); + } + + @Test + void failedFinalPromotionReleasesItsScopeAndSecondCloseRetriesInANewScope() { + TrackingSnapshotManager manager = new TrackingSnapshotManager(); + manager.failNextCacheSnapshot = true; + Node document = new Node(); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime( + document, null, manager); + List patches = java.util.Arrays.asList( + JsonPatch.add("/prefix", new Node().value("committed")), + JsonPatch.add("/suffix", new Node().value("not-consumed"))); + DocumentProcessingRuntime.PreparedPatchSequence sequence = + runtime.preparePatchSequence("/", patches, null); + + sequence.applyNext(0); + assertThrows(IllegalStateException.class, sequence::close); + + assertEquals("committed", document.getAsText("/prefix")); + assertEquals(1, manager.openCalls); + assertEquals(1, manager.releaseCalls); + sequence.close(); + + assertEquals(2, manager.cacheSnapshotAttempts); + assertEquals(2, manager.openCalls, + "retry must open a fresh transient publication scope"); + assertEquals(manager.openCalls, manager.releaseCalls); + assertEquals(1, runtime.sequenceFinalSnapshotCacheInsertsForTest()); + } + + @Test + void closedContextRejectsLatePreviewTransferAndCloseRemainsIdempotent() { + TrackingSnapshotManager manager = new TrackingSnapshotManager(); + Fixture fixture = fixture(manager); + List patches = Collections.singletonList( + JsonPatch.add("/late", new Node().value("not accepted"))); + WorkingDocument.Preview preview = preview(fixture.context, patches); + + fixture.context.close(); + fixture.context.close(); + + assertThrows(IllegalStateException.class, + () -> fixture.context.applyPreviewedPatches(patches, preview)); + assertTrue(preview.patch(0) != null, + "rejected transfer must leave preview ownership with the caller"); + + preview.close(); + assertEquals(manager.openCalls, manager.releaseCalls); + assertNull(nodeAt(fixture.execution.runtime().document(), "/late")); + } + + private Fixture fixture(TrackingSnapshotManager manager) { + DocumentProcessor processor = DocumentProcessor.builder() + .withSnapshotManager(manager) + .build(); + ProcessorEngine.Execution execution = new ProcessorEngine.Execution( + processor, new Node()); + ProcessorExecutionContext context = execution.createContext( + "/", ContractBundle.empty(), new Node(), false); + return new Fixture(execution, context); + } + + private WorkingDocument.Preview preview(ProcessorExecutionContext context, + List patches) { + try (WorkingDocument working = context.newWorkingDocument()) { + return working.previewAndApplyPatches(patches); + } + } + + private static Node nodeAt(Node document, String path) { + try { + return document.getNode(path); + } catch (RuntimeException ex) { + return null; + } + } + + private static final class Fixture { + private final ProcessorEngine.Execution execution; + private final ProcessorExecutionContext context; + + private Fixture(ProcessorEngine.Execution execution, + ProcessorExecutionContext context) { + this.execution = execution; + this.context = context; + } + } + + private static final class PreviewThenThrowProcessor + implements HandlerProcessor { + private WorkingDocument.Preview preview; + + @Override + public Class contractType() { + return SetProperty.class; + } + + @Override + public void execute(SetProperty contract, ProcessorExecutionContext context) { + List patches = Collections.singletonList( + JsonPatch.add("/notApplied", new Node().value(1))); + try (WorkingDocument working = context.newWorkingDocument()) { + preview = working.previewAndApplyPatches(patches); + } + context.applyPreviewedPatches(patches, preview); + throw new IllegalStateException("handler failed after buffering preview"); + } + } + + private static final class TrackingSnapshotManager + implements ProcessingSnapshotManager { + private int openCalls; + private int releaseCalls; + private int cacheSnapshotAttempts; + private boolean failNextRetain; + private boolean failNextCacheSnapshot; + + @Override + public ResolvedSnapshot fromDocument(Node document) { + FrozenNode canonical = FrozenNode.fromUncheckedCanonicalNode(document.clone()); + return new ResolvedSnapshot(canonical, + FrozenNode.fromResolvedNode(document.clone()), + canonical.blueId()); + } + + @Override + public ResolvedSnapshot applyPatch(ResolvedSnapshot snapshot, JsonPatch patch) { + CanonicalPatchResult patched = snapshot.applyCanonicalPatch(patch); + return new ResolvedSnapshot(patched.root(), + FrozenNode.fromResolvedNode(patched.root().toNode()), + patched.blueId()); + } + + @Override + public ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { + cacheSnapshotAttempts++; + if (failNextCacheSnapshot) { + failNextCacheSnapshot = false; + throw new IllegalStateException("simulated final cache failure"); + } + return snapshot; + } + + @Override + public ProcessingSnapshotManager transientSequence() { + openCalls++; + return new TrackingScope(this); + } + } + + private static final class TrackingScope implements ProcessingSnapshotManager { + private final TrackingSnapshotManager owner; + private boolean released; + + private TrackingScope(TrackingSnapshotManager owner) { + this.owner = owner; + } + + @Override + public ResolvedSnapshot fromDocument(Node document) { + return owner.fromDocument(document); + } + + @Override + public ResolvedSnapshot fromDocumentTransient(Node document) { + return owner.fromDocument(document); + } + + @Override + public ResolvedSnapshot applyPatch(ResolvedSnapshot snapshot, JsonPatch patch) { + return owner.applyPatch(snapshot, patch); + } + + @Override + public ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { + return owner.cacheSnapshot(snapshot); + } + + @Override + public ProcessingSnapshotManager transientSequence() { + return this; + } + + @Override + public ProcessingSnapshotManager forkTransientSequence() { + owner.openCalls++; + return new TrackingScope(owner); + } + + @Override + public void retainTransientState(FrozenNode canonicalRoot, FrozenNode resolvedRoot) { + if (owner.failNextRetain) { + owner.failNextRetain = false; + throw new IllegalStateException("simulated retain failure"); + } + } + + @Override + public void releaseTransientState() { + if (!released) { + released = true; + owner.releaseCalls++; + } + } + } +} diff --git a/src/test/java/blue/language/processor/RecordingProcessingMetricsSinkTest.java b/src/test/java/blue/language/processor/RecordingProcessingMetricsSinkTest.java new file mode 100644 index 0000000..48074d5 --- /dev/null +++ b/src/test/java/blue/language/processor/RecordingProcessingMetricsSinkTest.java @@ -0,0 +1,83 @@ +package blue.language.processor; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RecordingProcessingMetricsSinkTest { + + @Test + void snapshotsCountersGaugesAndHighWaterImmutably() { + RecordingProcessingMetricsSink sink = new RecordingProcessingMetricsSink(); + sink.incrementPatchImpactAnalyses(); + sink.incrementPatchImpactAnalyses(); + sink.incrementFullSnapshotFallback("ROOT_REPLACEMENT"); + sink.addProcessDocumentNanos(7L); + sink.addProcessDocumentNanos(5L); + sink.addBundleLoadNanos(11L); + sink.incrementBundleLoadCacheHits(); + sink.addHandlerExecutionNanos(13L); + sink.incrementHandlersExecuted(); + sink.setCacheCurrentWeightBytes("resolvedSnapshots", 100L); + sink.recordCacheHighWaterBytes("resolvedSnapshots", 100L); + sink.setCacheCurrentWeightBytes("resolvedSnapshots", 40L); + sink.recordCacheHighWaterBytes("resolvedSnapshots", 40L); + + ProcessingMetricsSnapshot first = sink.snapshot(); + assertEquals(2L, first.counter("patchImpactAnalyses")); + assertEquals(1L, first.counter("fullSnapshotFallbacks")); + assertEquals(1L, first.counter("fullSnapshotFallbackReason.ROOT_REPLACEMENT")); + assertEquals(12L, first.counter("processDocumentNanos")); + assertEquals(11L, first.counter("bundleLoadNanos")); + assertEquals(1L, first.counter("bundleLoadCacheHits")); + assertEquals(13L, first.counter("handlerExecutionNanos")); + assertEquals(1L, first.counter("handlersExecuted")); + assertEquals(40L, first.gauge("cache.resolvedSnapshots.currentWeightBytes")); + assertEquals(100L, first.gauge("cache.resolvedSnapshots.highWaterBytes")); + assertThrows(UnsupportedOperationException.class, + () -> first.counters().put("other", 1L)); + + sink.incrementPatchImpactAnalyses(); + assertEquals(2L, first.counter("patchImpactAnalyses")); + assertEquals(3L, sink.snapshot().counter("patchImpactAnalyses")); + } + + @Test + void concurrentUpdatesAreNotLost() throws Exception { + RecordingProcessingMetricsSink sink = new RecordingProcessingMetricsSink(); + int threads = 8; + int iterations = 2_000; + CountDownLatch start = new CountDownLatch(1); + List workers = new ArrayList<>(); + for (int index = 0; index < threads; index++) { + Thread worker = new Thread(() -> { + try { + start.await(); + for (int iteration = 0; iteration < iterations; iteration++) { + sink.incrementIncrementalSnapshotResolutions(); + sink.recordCacheHighWaterBytes("plans", iteration); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new AssertionError(exception); + } + }); + workers.add(worker); + worker.start(); + } + start.countDown(); + for (Thread worker : workers) { + worker.join(); + } + + ProcessingMetricsSnapshot snapshot = sink.snapshot(); + assertEquals((long) threads * iterations, + snapshot.counter("incrementalSnapshotResolutions")); + assertEquals(iterations - 1L, snapshot.gauge("cache.plans.highWaterBytes")); + } +} diff --git a/src/test/java/blue/language/snapshot/FrozenCanonicalDigesterTest.java b/src/test/java/blue/language/snapshot/FrozenCanonicalDigesterTest.java new file mode 100644 index 0000000..58677a3 --- /dev/null +++ b/src/test/java/blue/language/snapshot/FrozenCanonicalDigesterTest.java @@ -0,0 +1,453 @@ +package blue.language.snapshot; + +import blue.language.model.Node; +import blue.language.model.Schema; +import blue.language.processor.util.NodeCanonicalizer; +import blue.language.utils.BlueIdCalculator; +import blue.language.utils.NodeToBlueIdInput; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.erdtman.jcs.JsonCanonicalizer; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Array; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.atomic.AtomicInteger; + +import static blue.language.utils.Properties.*; +import static blue.language.utils.UncheckedObjectMapper.JSON_MAPPER; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FrozenCanonicalDigesterTest { + + @Test + void streamingWriterMatchesGenericJcsForRepresentativeFrozenInputs() throws Exception { + List cases = representativeNodes(); + for (int index = 0; index < cases.size(); index++) { + FrozenNode frozen = FrozenNode.fromNode(cases.get(index)); + byte[] json = JSON_MAPPER.writeValueAsBytes(FrozenNodeToBlueIdInput.get(frozen)); + byte[] expected = new JsonCanonicalizer(json).getEncodedUTF8(); + ByteArraySink sink = new ByteArraySink(); + + FrozenCanonicalWriter.write(frozen, sink); + + assertArrayEquals(expected, sink.bytes(), "canonical bytes at case " + index); + } + } + + @Test + void streamingDigesterMatchesGenericOracleForRepresentativeFrozenInputs() { + List cases = representativeNodes(); + for (int index = 0; index < cases.size(); index++) { + FrozenNode frozen = FrozenNode.fromNode(cases.get(index)); + assertEquals(FrozenCanonicalDigester.calculateGenericOracle(frozen), + FrozenCanonicalDigester.calculateBlueId(frozen), + "BlueId at case " + index); + } + } + + @Test + void canonicalScalarWriterMatchesJcsAcrossDeterministicUnicodeAndNumberCorpus() throws Exception { + Random random = new Random(0x4a435346524f5a45L); + for (int index = 0; index < 20_000; index++) { + Object value = scalarValue(random, index); + byte[] json = JSON_MAPPER.writeValueAsBytes(Arrays.asList(value)); + byte[] wrapped = new JsonCanonicalizer(json).getEncodedUTF8(); + byte[] expected = Arrays.copyOfRange(wrapped, 1, wrapped.length - 1); + ByteArraySink sink = new ByteArraySink(); + FrozenCanonicalWriter.writeCanonicalValue(value, sink); + assertArrayEquals(expected, sink.bytes(), "scalar canonical bytes at case " + index); + } + } + + @Test + void streamingDigestMatchesGenericOracleForOneHundredThousandGeneratedFrozenTrees() { + Random random = new Random(0x424c554549444a43L); + AtomicInteger fallbacks = new AtomicInteger(); + FrozenCanonicalDigester.Observer observer = new FrozenCanonicalDigester.Observer() { + @Override + public void genericFallback() { + fallbacks.incrementAndGet(); + } + }; + for (int index = 0; index < 100_000; index++) { + Node generated = generatedNode(random, index); + FrozenNode frozen = FrozenNode.fromNode(generated); + String expected = FrozenCanonicalDigester.calculateGenericOracle(frozen); + String mutableExpected = BlueIdCalculator.calculateBlueId(frozen.toNode()); + String actual = FrozenCanonicalDigester.calculateBlueId(frozen, observer); + assertEquals(mutableExpected, expected, "independent generic oracle case " + index); + assertEquals(expected, actual, "generated identity case " + index); + } + assertEquals(0, fallbacks.get(), "generated supported cases must stay on the streaming path"); + } + + @Test + void officialCanonicalSizeMatchesLegacyGasRepresentationForGeneratedTrees() { + Random random = new Random(0x47415353495a454cL); + for (int index = 0; index < 10_000; index++) { + Node generated = generatedNode(random, index); + FrozenNode frozen = FrozenNode.fromNode(generated); + assertEquals(NodeCanonicalizer.canonicalSize(generated), + FrozenCanonicalWriter.officialCanonicalSize(frozen), + "official canonical size case " + index); + } + } + + @Test + void rawJsonContainersAndNonInferredNumbersKeepCanonicalSizeParity() { + Map raw = new LinkedHashMap<>(); + raw.put("items", Arrays.asList("first", BigInteger.valueOf(2), true)); + raw.put("nested", Collections.singletonMap("key", "value")); + raw.put("float", Float.valueOf(0.1f)); + raw.put("dropped", null); + Map nestedNullMap = new LinkedHashMap<>(); + nestedNullMap.put("kept", "value"); + nestedNullMap.put("dropped", null); + raw.put("mapInList", Collections.singletonList(nestedNullMap)); + List cases = Arrays.asList( + new Node().value(raw), + new Node().value(Byte.valueOf((byte) 7)), + new Node().value(Character.valueOf('x')), + new Node().value(new Character[] {'a', null, '\u20ac'}), + new Node().value(new String[] {"a", "b"}), + new Node().value(new String[] {null}), + new Node().value(new byte[] {1, 2}), + new Node().value(new char[] {'a', 'b'}), + new Node().value(new Object[] { + null, + Collections.emptyMap(), + Collections.singletonMap("kept", "value") + })); + + for (Node authored : cases) { + FrozenNode frozen = FrozenNode.fromNode(authored); + assertEquals(NodeCanonicalizer.canonicalSize(authored), + FrozenCanonicalWriter.officialCanonicalSize(frozen)); + assertEquals(BlueIdCalculator.calculateBlueId(authored), frozen.blueId()); + } + + Map invalidRaw = new LinkedHashMap<>(); + invalidRaw.put("nullsInList", Collections.singletonList(null)); + Node invalid = new Node().value(invalidRaw); + assertSameFailure(invalid); + } + + @Test + void unhandledConcreteContainerArraysMatchMutableCanonicalOracles() throws Exception { + CustomJsonList custom = new CustomJsonList(); + custom.add(Collections.singletonMap("kind", "custom")); + + List singleton = Collections.singletonList( + Collections.singletonMap("kind", "jdk-singleton")); + Object singletonArray = Array.newInstance(singleton.getClass(), 1); + Array.set(singletonArray, 0, singleton); + + for (Object rawArray : Arrays.asList( + new CustomJsonList[] {custom}, singletonArray)) { + Node authored = new Node().value(rawArray); + FrozenNode frozen = FrozenNode.fromNode(authored); + byte[] json = JSON_MAPPER.writeValueAsBytes(NodeToBlueIdInput.get(authored)); + byte[] expectedCanonical = new JsonCanonicalizer(json).getEncodedUTF8(); + ByteArraySink sink = new ByteArraySink(); + + FrozenCanonicalWriter.write(frozen, sink); + + assertArrayEquals(expectedCanonical, sink.bytes()); + assertEquals(BlueIdCalculator.calculateBlueId(authored), frozen.blueId()); + assertEquals(FrozenCanonicalDigester.calculateGenericOracle(frozen), + FrozenCanonicalDigester.calculateBlueId(frozen)); + assertEquals(NodeCanonicalizer.canonicalSize(authored), + FrozenCanonicalWriter.officialCanonicalSize(frozen)); + } + } + + @Test + void enumsPreserveJacksonWireBytesAndUseGenericDigestFallback() throws Exception { + List> values = Arrays.>asList( + DefaultWireEnum.DEFAULT_VALUE, + AnnotatedWireEnum.ANNOTATED_VALUE); + List expectedJson = Arrays.asList( + "\"DEFAULT_VALUE\"", + "\"wire-value\""); + AtomicInteger fallbacks = new AtomicInteger(); + FrozenCanonicalDigester.Observer observer = new FrozenCanonicalDigester.Observer() { + @Override + public void genericFallback() { + fallbacks.incrementAndGet(); + } + }; + + for (int index = 0; index < values.size(); index++) { + Enum value = values.get(index); + assertEquals(expectedJson.get(index), JSON_MAPPER.writeValueAsString(value)); + assertFalse(FrozenCanonicalWriter.supportsCanonicalValue(value)); + + ByteArraySink directSink = new ByteArraySink(); + FrozenCanonicalWriter.writeCanonicalValue(value, directSink); + byte[] wrappedOracle = new JsonCanonicalizer( + JSON_MAPPER.writeValueAsBytes(Collections.singletonList(value))) + .getEncodedUTF8(); + byte[] directOracle = Arrays.copyOfRange( + wrappedOracle, 1, wrappedOracle.length - 1); + assertArrayEquals(directOracle, directSink.bytes()); + + Node authored = new Node().value(value); + FrozenNode frozen = FrozenNode.fromNode(authored); + byte[] canonicalInputJson = JSON_MAPPER.writeValueAsBytes( + NodeToBlueIdInput.get(authored)); + byte[] canonicalInputOracle = new JsonCanonicalizer(canonicalInputJson) + .getEncodedUTF8(); + ByteArraySink nodeSink = new ByteArraySink(); + FrozenCanonicalWriter.write(frozen, nodeSink); + + assertArrayEquals(canonicalInputOracle, nodeSink.bytes()); + assertEquals(BlueIdCalculator.calculateBlueId(authored), frozen.blueId()); + assertEquals(NodeCanonicalizer.canonicalSize(authored), + FrozenCanonicalWriter.officialCanonicalSize(frozen)); + assertEquals(FrozenCanonicalDigester.calculateGenericOracle(frozen), + FrozenCanonicalDigester.calculateBlueId(frozen, observer)); + } + assertEquals(values.size(), fallbacks.get()); + } + + @Test + void invalidInputDiagnosticsRemainCompatibleWithExistingOracles() { + assertSameFailure(new Node().blueId(TEXT_TYPE_BLUE_ID + "#member")); + RuntimeException previousFailure = assertThrows(RuntimeException.class, + () -> FrozenNode.fromNode(new Node().items( + new Node().value("before"), + new Node().previousBlueId(TEXT_TYPE_BLUE_ID)))); + assertEquals("\"$previous\" must appear only as the first list item.", + previousFailure.getMessage(), + "FrozenNode list construction keeps its rc.14 diagnostic"); + Node invalidSchema = new Node().schema(new Schema().minLength( + new Node().value(1).blue(new Node().value("directive")))); + RuntimeException schemaFailure = assertThrows(RuntimeException.class, + () -> FrozenNode.fromNode(invalidSchema)); + assertEquals("\"blue\" is a preprocessing directive and must not be present in BlueId input. " + + "Call preprocess/canonicalize/calculateSemanticBlueId first. Path: /", + schemaFailure.getMessage(), + "rc.11 FrozenNode schema diagnostics use the nested-node root path"); + assertSameFailure(new Node().schema(new Schema().enumValues( + Arrays.asList(new Node())))); + } + + @Test + void genericFallbackPreservesReservedPropertyAndEmptySchemaCleaning() { + List cases = Arrays.asList( + new Node().properties("child", new Node() + .name("discarded") + .properties(OBJECT_NAME, new Node().value("override"))), + new Node().properties("child", new Node() + .type(new Node().blueId(TEXT_TYPE_BLUE_ID)) + .properties(OBJECT_TYPE, new Node().value("override"))), + new Node().properties("child", new Node() + .schema(new Schema().minimum(new Node()))), + new Node().value(Collections.singletonMap( + "empty", Collections.emptyMap())), + new Node().schema(new Schema().enumValues(Collections.singletonList( + new Node().value(Collections.singletonMap( + "key", "value"))))), + new Node().schema(new Schema().minimum(new Node().value( + Collections.singletonMap("key", "value")))), + new Node().schema(new Schema().minLength(new Node().value( + Collections.singletonList(1)))), + new Node().items( + new Node().properties(LIST_CONTROL_PREVIOUS, + new Node().blueId(TEXT_TYPE_BLUE_ID)), + new Node().value("tail"))); + AtomicInteger fallbacks = new AtomicInteger(); + FrozenCanonicalDigester.Observer observer = new FrozenCanonicalDigester.Observer() { + @Override + public void genericFallback() { + fallbacks.incrementAndGet(); + } + }; + + for (int index = 0; index < cases.size(); index++) { + FrozenNode frozen = FrozenNode.fromNode(cases.get(index)); + assertEquals(FrozenCanonicalDigester.calculateGenericOracle(frozen), + FrozenCanonicalDigester.calculateBlueId(frozen, observer), + "fallback identity case " + index); + } + assertTrue(fallbacks.get() >= 3, + "reserved-key representations must stay on the compatibility oracle"); + } + + private static void assertSameFailure(Node input) { + RuntimeException expected = assertThrows(RuntimeException.class, + () -> BlueIdCalculator.calculateBlueId(input)); + RuntimeException actual = assertThrows(RuntimeException.class, + () -> FrozenNode.fromNode(input)); + assertEquals(expected.getClass(), actual.getClass()); + assertEquals(expected.getMessage(), actual.getMessage()); + } + + private static void assertSameIdentityFailure(Node input) { + RuntimeException expected = assertThrows(RuntimeException.class, + () -> BlueIdCalculator.calculateBlueId(input)); + RuntimeException actual = assertThrows(RuntimeException.class, + () -> FrozenNode.fromNode(input).blueId()); + assertEquals(expected.getClass(), actual.getClass()); + assertEquals(expected.getMessage(), actual.getMessage()); + } + + private static List representativeNodes() { + List cases = new ArrayList<>(); + cases.add(new Node()); + cases.add(new Node().value("plain")); + cases.add(new Node().value("controls\u0000\b\t\n\f\r\"\\/")); + cases.add(new Node().value("zażółć-\uD83D\uDE80-\u2028")); + cases.add(new Node().value(BigInteger.valueOf(9007199254740991L))); + cases.add(new Node().value(new BigInteger("9007199254740992"))); + cases.add(new Node().value(new BigDecimal("-0.0000001"))); + cases.add(new Node().type(new Node().blueId(DOUBLE_TYPE_BLUE_ID)).value("-0")); + cases.add(new Node().blueId(TEXT_TYPE_BLUE_ID)); + cases.add(new Node().items(new Node().value("a"), new Node().value(BigInteger.valueOf(2)))); + cases.add(new Node().items(new Node().properties( + LIST_CONTROL_EMPTY, new Node().value(true)), new Node().value("tail"))); + cases.add(new Node().items( + new Node().previousBlueId(TEXT_TYPE_BLUE_ID), new Node().value("tail"))); + cases.add(new Node() + .name("root") + .description("description") + .properties(new LinkedHashMap() {{ + put("z", new Node().value("last")); + put("β", new Node().value(true)); + put("alpha", new Node().items(new Node().value(1), new Node().value(2))); + }})); + Schema schema = new Schema() + .required(true) + .minLength(BigInteger.ZERO) + .maximum(new BigDecimal("10.25")) + .enumValues(Arrays.asList(new Node().value("x"), new Node().value(BigInteger.ONE))); + cases.add(new Node().schema(schema).value("schema-value")); + cases.add(new Node().contracts(new Node().properties( + "handler", new Node().type(new Node().blueId(TEXT_TYPE_BLUE_ID)).value("run")))); + return cases; + } + + private static Object scalarValue(Random random, int index) { + switch (index % 8) { + case 0: + return "text-" + index + "-\u0000-\b\t\n\f\r-\"\\-zażółć-\uD83D\uDE80-" + + (char) random.nextInt(0x10000); + case 1: + return BigInteger.valueOf(random.nextLong() & 0x1fffffffffffffL); + case 2: + return new BigInteger(80, random).multiply(index % 3 == 0 + ? BigInteger.valueOf(-1) : BigInteger.ONE); + case 3: + return BigDecimal.valueOf((random.nextDouble() - 0.5d) * 1_000_000d); + case 4: + return Boolean.TRUE; + case 5: + return Boolean.FALSE; + case 6: + return Double.longBitsToDouble(random.nextLong() & 0x7fefffffffffffffL); + default: + return null; + } + } + + private static Node generatedNode(Random random, int index) { + switch (index % 12) { + case 0: + return new Node().value("s-" + index + "-\u0000-zażółć-\uD83D\uDE80-" + + (char) random.nextInt(0x10000)); + case 1: { + BigInteger integer = new BigInteger(72, random); + return new Node().value(index % 4 == 1 ? integer : integer.negate()); + } + case 2: + return new Node().value(BigDecimal.valueOf( + (random.nextDouble() - 0.5d) * Math.pow(10, index % 20 - 10))); + case 3: + return new Node().value((index & 1) == 0); + case 4: { + Map properties = new LinkedHashMap<>(); + properties.put("z-" + index, new Node().value(index)); + properties.put("a/" + index, new Node().value("v-" + random.nextInt())); + properties.put("β~" + index, new Node().properties( + "nested", new Node().value(index % 7))); + return new Node().properties(properties); + } + case 5: + return new Node().items( + new Node().value("head-" + index), + new Node().items(new Node().value(index), new Node().value(index + 1L)), + new Node().properties("tail", new Node().value(index % 3 == 0))); + case 6: + return new Node().items(); + case 7: + return new Node().type(new Node().blueId(DOUBLE_TYPE_BLUE_ID)) + .value(index % 2 == 0 ? "-0" : String.valueOf(random.nextDouble())); + case 8: + return new Node().blueId(TEXT_TYPE_BLUE_ID); + case 9: + return new Node().items( + new Node().previousBlueId(TEXT_TYPE_BLUE_ID), + new Node().value("append-" + index)); + case 10: + return new Node().items( + new Node().properties(LIST_CONTROL_EMPTY, new Node().value(true)), + new Node().value(index)); + default: { + Schema schema = new Schema() + .required((index & 1) == 0) + .minLength(BigInteger.valueOf(index % 17)) + .minimum(BigDecimal.valueOf(index % 19, index % 5)) + .enumValues(Arrays.asList( + new Node().value("enum-" + index), + new Node().type(new Node().blueId(INTEGER_TYPE_BLUE_ID)).value(index))); + return new Node().schema(schema).contracts(new Node().properties( + "audit-" + index, new Node().value(true))); + } + } + } + + private static final class CustomJsonList extends ArrayList { + private static final long serialVersionUID = 1L; + } + + private enum DefaultWireEnum { + DEFAULT_VALUE + } + + private enum AnnotatedWireEnum { + @JsonProperty("wire-value") + ANNOTATED_VALUE + } + + private static final class ByteArraySink implements FrozenCanonicalWriter.CanonicalByteSink { + private final ByteArrayOutputStream output = new ByteArrayOutputStream(); + + @Override + public void writeByte(int value) { + output.write(value); + } + + @Override + public void write(byte[] bytes, int offset, int length) { + output.write(bytes, offset, length); + } + + private byte[] bytes() { + return output.toByteArray(); + } + } +} diff --git a/src/test/java/blue/language/snapshot/FrozenNodeRetainedWeightTest.java b/src/test/java/blue/language/snapshot/FrozenNodeRetainedWeightTest.java new file mode 100644 index 0000000..2035399 --- /dev/null +++ b/src/test/java/blue/language/snapshot/FrozenNodeRetainedWeightTest.java @@ -0,0 +1,99 @@ +package blue.language.snapshot; + +import blue.language.model.Node; +import blue.language.model.Schema; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.math.BigDecimal; +import java.math.BigInteger; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FrozenNodeRetainedWeightTest { + + @Test + void retainedWeightGrowsWithContentAndIncludesSchemaWithoutComputingIdentity() throws Exception { + FrozenNode small = FrozenNode.fromResolvedNode(new Node().value("x")); + FrozenNode large = FrozenNode.fromResolvedNode(new Node() + .schema(new Schema().minLength(BigInteger.valueOf(12)).enumValues( + java.util.Arrays.asList(new Node().value("alpha"), new Node().value("beta")))) + .properties("left", new Node().value("a-longer-value")) + .properties("right", new Node().items( + new Node().value("one"), new Node().value("two")))); + + assertNull(cachedBlueId(small)); + assertNull(cachedBlueId(large)); + assertTrue(large.approximateRetainedWeightBytes() > small.approximateRetainedWeightBytes()); + assertTrue(large.approximateShallowRetainedWeightBytes() + > small.approximateShallowRetainedWeightBytes()); + assertTrue(large.approximateRetainedWeightBytes() + > large.approximateShallowRetainedWeightBytes()); + assertNull(cachedBlueId(small), "weighting must not compute BlueId"); + assertNull(cachedBlueId(large), "weighting must not compute BlueId"); + } + + @Test + void graphEstimateDeduplicatesSharedFrozenSubtrees() { + FrozenNode child = FrozenNode.fromResolvedNode(new Node().properties( + "payload", new Node().value("shared"))); + FrozenNode left = FrozenNode.fromResolvedNode(new Node().properties( + "child", child.toNode())); + FrozenNode right = left.withProperty("other", child); + + long separate = left.approximateRetainedWeightBytes() + + right.approximateRetainedWeightBytes(); + long combined = FrozenNode.approximateRetainedWeightBytesOf(left, right); + + assertTrue(combined < separate); + } + + @Test + void weightIncludesLargeDecimalMagnitudeAndOwnedSchemaGraph() { + StringBuilder digits = new StringBuilder(20_000); + for (int index = 0; index < 20_000; index++) { + digits.append((char) ('1' + index % 9)); + } + FrozenNode decimal = FrozenNode.fromResolvedNode( + new Node().value(new BigDecimal(new BigInteger(digits.toString()), 100))); + FrozenNode schemaDense = FrozenNode.fromResolvedNode(new Node().schema(new Schema() + .minimum(new Node().value(new BigInteger(digits.toString()))) + .enumValues(java.util.Arrays.asList( + new Node().value(digits.toString()), + new Node().value(digits.reverse().toString()))))); + + assertTrue(decimal.approximateRetainedWeightBytes() > 8_000L, + "large decimal magnitude must participate in cache admission weight"); + assertTrue(schemaDense.approximateShallowRetainedWeightBytes() > 50_000L, + "schema-owned nodes and structural-key schema data must be weighed"); + } + + @Test + void shallowWeightDoesNotRecursivelyChargeDescendantStructuralKeys() { + FrozenNode shortChain = FrozenNode.fromResolvedNode(chain(8)); + FrozenNode deepChain = FrozenNode.fromResolvedNode(chain(256)); + shortChain.resolvedStructuralKey(); + deepChain.resolvedStructuralKey(); + + long shortRootWeight = shortChain.approximateShallowRetainedWeightBytes(); + long deepRootWeight = deepChain.approximateShallowRetainedWeightBytes(); + + assertTrue(deepRootWeight <= shortRootWeight + 64L, + "a shallow entry weight must not walk and re-charge its descendant key graph"); + } + + private static Node chain(int depth) { + Node current = new Node().value("leaf"); + for (int index = 0; index < depth; index++) { + current = new Node().properties("child", current); + } + return current; + } + + private static Object cachedBlueId(FrozenNode node) throws Exception { + Field field = FrozenNode.class.getDeclaredField("blueId"); + field.setAccessible(true); + return field.get(node); + } +} diff --git a/src/test/java/blue/language/snapshot/FrozenNodeTest.java b/src/test/java/blue/language/snapshot/FrozenNodeTest.java index 04042e6..eb1bdf6 100644 --- a/src/test/java/blue/language/snapshot/FrozenNodeTest.java +++ b/src/test/java/blue/language/snapshot/FrozenNodeTest.java @@ -6,15 +6,22 @@ import blue.language.Blue; import blue.language.utils.NodeToBlueIdInput; import blue.language.utils.Nodes; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import org.junit.jupiter.api.Test; import java.io.InputStream; +import java.lang.reflect.Array; import java.math.BigInteger; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; import java.util.List; +import java.util.Map; +import java.util.TreeMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -23,7 +30,10 @@ import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; import static blue.language.utils.Properties.DOUBLE_TYPE_BLUE_ID; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; @@ -355,6 +365,243 @@ void immutableViewsCannotBeMutatedAndToNodeReturnsFreshMutableCopies() { assertEquals(BlueIdCalculator.calculateBlueId(second), frozen.blueId()); } + @Test + @SuppressWarnings("unchecked") + void rawJsonValueContainersAreOwnedImmutableSnapshots() { + List nested = new ArrayList<>(); + nested.add("before"); + Map raw = new LinkedHashMap<>(); + raw.put("nested", nested); + String[] array = new String[] {"first", "second"}; + raw.put("array", array); + FrozenNode frozen = FrozenNode.fromNode(new Node().value(raw)); + String blueId = frozen.blueId(); + + nested.set(0, "after"); + array[0] = "after"; + raw.put("extra", true); + + Map captured = (Map) frozen.getValue(); + List capturedNested = (List) captured.get("nested"); + assertEquals(Collections.singletonList("before"), capturedNested); + assertArrayEquals(new String[] {"first", "second"}, + (String[]) captured.get("array")); + assertFalse(captured.containsKey("extra")); + assertEquals(blueId, frozen.blueId()); + assertThrows(UnsupportedOperationException.class, + () -> captured.put("mutation", true)); + assertThrows(UnsupportedOperationException.class, + () -> capturedNested.set(0, "mutation")); + ((String[]) captured.get("array"))[0] = "caller mutation"; + assertArrayEquals(new String[] {"first", "second"}, + (String[]) ((Map) frozen.getValue()).get("array")); + + Map materialized = (Map) frozen.toNode().getValue(); + ((List) materialized.get("nested")).set(0, "mutable copy"); + materialized.put("new", true); + assertEquals(Collections.singletonList("before"), capturedNested); + assertFalse(captured.containsKey("new")); + } + + @Test + void rawJsonValueContainersRejectNestedNonFiniteNumbers() { + Map nested = new LinkedHashMap<>(); + nested.put("values", Arrays.asList(1, Float.NaN)); + + assertThrows(IllegalArgumentException.class, + () -> FrozenNode.fromNode(new Node().value(nested))); + assertThrows(IllegalArgumentException.class, + () -> FrozenNode.fromNode(new Node().value(Double.POSITIVE_INFINITY))); + assertThrows(IllegalArgumentException.class, + () -> FrozenNode.fromNode(new Node().value(Float.NEGATIVE_INFINITY))); + assertThrows(IllegalArgumentException.class, + () -> FrozenNode.fromNode(new Node().value( + new float[] {1.0f, Float.NaN}))); + assertThrows(IllegalArgumentException.class, + () -> FrozenNode.fromNode(new Node().value(new Object[] { + Collections.singletonMap("values", + new double[] {1.0d, Double.POSITIVE_INFINITY})}))); + } + + @Test + void rawArraysPreserveLegacyTypeBytesAndRemainOwnedAcrossAccessors() { + byte[] source = new byte[] {1, 2}; + FrozenNode frozen = FrozenNode.fromNode(new Node().value(source)); + String expected = BlueIdCalculator.calculateBlueId( + new Node().value(new byte[] {1, 2})); + + source[0] = 9; + byte[] exposed = (byte[]) frozen.getValue(); + exposed[1] = 9; + byte[] materialized = (byte[]) frozen.toNode().getValue(); + materialized[0] = 8; + + assertEquals(expected, frozen.blueId()); + assertArrayEquals(new byte[] {1, 2}, (byte[]) frozen.getValue()); + assertArrayEquals(new byte[] {1, 2}, (byte[]) frozen.toNode().getValue()); + assertEquals(frozen.resolvedStructuralKey(), + FrozenNode.fromNode(new Node().value(new byte[] {1, 2})) + .resolvedStructuralKey()); + assertNotEquals(frozen.resolvedStructuralKey(), + FrozenNode.fromNode(new Node().value(new Byte[] {1, 2})) + .resolvedStructuralKey()); + } + + @Test + void charactersAndCharacterArraysRetainLegacyRepresentationAndRuntimeType() { + List cases = Arrays.asList( + new Node().value(Character.valueOf('x')), + new Node().value(new Character[] {'x', null, '\u20ac'}), + new Node().value(new char[] {'x', '\u20ac'})); + + for (Node authored : cases) { + FrozenNode frozen = FrozenNode.fromNode(authored); + assertEquals(BlueIdCalculator.calculateBlueId(authored), frozen.blueId()); + assertEquals(authored.getValue().getClass(), frozen.getValue().getClass()); + assertEquals(authored.getValue().getClass(), frozen.toNode().getValue().getClass()); + } + assertArrayEquals(new Character[] {'x', null, '\u20ac'}, + (Character[]) FrozenNode.fromNode(cases.get(1)).getValue()); + assertArrayEquals(new char[] {'x', '\u20ac'}, + (char[]) FrozenNode.fromNode(cases.get(2)).getValue()); + } + + @Test + @SuppressWarnings("unchecked") + void enumValuesRemainImmutableAndPreserveLegacyWireIdentity() { + Map raw = new LinkedHashMap<>(); + raw.put("default", DefaultWireEnum.DEFAULT_VALUE); + raw.put("annotated", AnnotatedWireEnum.ANNOTATED_VALUE); + Node authored = new Node().value(raw); + + FrozenNode frozen = FrozenNode.fromNode(authored); + Map captured = (Map) frozen.getValue(); + Map materialized = (Map) frozen.toNode().getRawValue(); + + assertSame(DefaultWireEnum.DEFAULT_VALUE, captured.get("default")); + assertSame(AnnotatedWireEnum.ANNOTATED_VALUE, captured.get("annotated")); + assertSame(DefaultWireEnum.DEFAULT_VALUE, materialized.get("default")); + assertSame(AnnotatedWireEnum.ANNOTATED_VALUE, materialized.get("annotated")); + assertEquals(BlueIdCalculator.calculateBlueId(authored), frozen.blueId()); + assertThrows(UnsupportedOperationException.class, + () -> captured.put("mutation", DefaultWireEnum.DEFAULT_VALUE)); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void concreteAndInterfaceContainerArraysCloneAndFreezeWithoutArrayStore() { + TreeMap tree = new TreeMap<>(); + tree.put("key", "tree"); + List arrays = Arrays.asList( + new ArrayList[] {new ArrayList<>(Collections.singletonList("array-list"))}, + new LinkedList[] {new LinkedList<>(Collections.singletonList("linked-list"))}, + new HashMap[] {new HashMap<>(Collections.singletonMap("key", "hash-map"))}, + new TreeMap[] {tree}, + new List[] {new ArrayList<>(Collections.singletonList("list"))}, + new Map[] {new HashMap<>(Collections.singletonMap("key", "map"))}, + new Object[] { + new ArrayList<>(Collections.singletonList("object-list")), + new HashMap<>(Collections.singletonMap("key", "object-map")) + }); + + for (Object array : arrays) { + Node authored = new Node().value(array); + Node cloned = assertDoesNotThrow(authored::clone); + assertEquals(array.getClass(), cloned.getValue().getClass()); + + FrozenNode frozen = assertDoesNotThrow(() -> FrozenNode.fromNode(authored)); + String identity = frozen.blueId(); + assertEquals(BlueIdCalculator.calculateBlueId(authored), identity); + assertEquals(array.getClass(), frozen.getValue().getClass()); + assertEquals(array.getClass(), frozen.toNode().getValue().getClass()); + + Object exposed = frozen.getValue(); + Object first = Array.get(exposed, 0); + if (first instanceof List) { + ((List) first).add("caller mutation"); + } else if (first instanceof Map) { + ((Map) first).put("caller", "mutation"); + } + assertEquals(identity, frozen.blueId()); + } + } + + @Test + @SuppressWarnings("unchecked") + void unhandledConcreteContainerArraysFallBackToOwnedObjectArrays() { + List customNested = new ArrayList<>(Collections.singletonList("custom-before")); + CustomJsonList custom = new CustomJsonList(); + custom.add(customNested); + Object customArray = new CustomJsonList[] {custom}; + + List singletonNested = new ArrayList<>(Collections.singletonList("singleton-before")); + List singleton = Collections.singletonList(singletonNested); + Object singletonArray = Array.newInstance(singleton.getClass(), 1); + Array.set(singletonArray, 0, singleton); + + for (Object sourceArray : Arrays.asList(customArray, singletonArray)) { + Node authored = new Node().value(sourceArray); + String expectedBlueId = BlueIdCalculator.calculateBlueId(authored); + + Node cloned = assertDoesNotThrow(authored::clone); + FrozenNode frozen = assertDoesNotThrow(() -> FrozenNode.fromNode(authored)); + + assertEquals(Object[].class, cloned.getRawValue().getClass()); + assertEquals(Object[].class, frozen.getValue().getClass()); + assertEquals(Object[].class, frozen.toNode().getRawValue().getClass()); + assertEquals(expectedBlueId, BlueIdCalculator.calculateBlueId(cloned)); + assertEquals(expectedBlueId, frozen.blueId()); + } + + customNested.set(0, "custom-after"); + singletonNested.set(0, "singleton-after"); + + Node customClone = new Node().value(customArray).clone(); + FrozenNode singletonFrozen = FrozenNode.fromNode(new Node().value(singletonArray)); + customNested.set(0, "custom-later"); + singletonNested.set(0, "singleton-later"); + + List clonedCustom = (List) ((List) + ((Object[]) customClone.getRawValue())[0]).get(0); + assertEquals(Collections.singletonList("custom-after"), clonedCustom); + + Object[] exposed = (Object[]) singletonFrozen.getValue(); + List exposedNested = (List) ((List) exposed[0]).get(0); + assertEquals(Collections.singletonList("singleton-after"), exposedNested); + exposedNested.set(0, "caller-mutation"); + assertEquals("singleton-after", ((List) ((List) + ((Object[]) singletonFrozen.getValue())[0]).get(0)).get(0)); + } + + @Test + void frozenNodesRejectNonJsonMutableValueObjectsAndCyclicContainers() { + assertThrows(IllegalArgumentException.class, + () -> FrozenNode.fromResolvedNode(new Node().value(new StringBuilder("mutable")))); + + List cyclic = new ArrayList<>(); + cyclic.add(cyclic); + assertThrows(IllegalArgumentException.class, + () -> FrozenNode.fromResolvedNode(new Node().value(cyclic))); + + Object[] cyclicArray = new Object[1]; + cyclicArray[0] = cyclicArray; + assertThrows(IllegalArgumentException.class, + () -> FrozenNode.fromResolvedNode(new Node().value(cyclicArray))); + } + + private static final class CustomJsonList extends ArrayList { + private static final long serialVersionUID = 1L; + } + + private enum DefaultWireEnum { + DEFAULT_VALUE + } + + private enum AnnotatedWireEnum { + @JsonProperty("wire-value") + ANNOTATED_VALUE + } + @Test void pathIndexAndAtResolveObjectAndListPointersWithoutMaterializingWholeTree() { FrozenNode frozen = FrozenNode.fromNode(YAML_MAPPER.readValue( @@ -495,6 +742,36 @@ void frozenSchemaIsClonedExactlyAtTheImmutableBoundary() { assertEquals(3, cloneCalls.get()); } + @Test + @SuppressWarnings("unchecked") + void frozenSchemaDeeplyOwnsRawJsonValuesAcrossMutableBoundaries() { + Map raw = new LinkedHashMap<>(); + raw.put("label", "before"); + Schema source = new Schema().enumValues(Collections.singletonList( + new Node().value(raw))); + FrozenNode frozen = FrozenNode.fromNode(new Node().schema(source)); + String blueId = frozen.blueId(); + + raw.put("label", "after"); + raw.put("extra", true); + Map returned = (Map) frozen.getSchema() + .getEnum().get(0).getValue(); + assertEquals("before", returned.get("label")); + assertFalse(returned.containsKey("extra")); + + returned.put("label", "caller mutation"); + Map reread = (Map) frozen.getSchema() + .getEnum().get(0).getValue(); + assertEquals("before", reread.get("label")); + assertEquals(blueId, frozen.blueId()); + + Map materialized = (Map) frozen.toNode() + .getSchema().getEnum().get(0).getValue(); + materialized.put("label", "materialized mutation"); + assertEquals("before", ((Map) frozen.getSchema() + .getEnum().get(0).getValue()).get("label")); + } + @Test void rejectsInvalidCanonicalPayloadShapes() { assertThrows(IllegalArgumentException.class, diff --git a/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java b/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java index 6985407..9c303fb 100644 --- a/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java +++ b/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java @@ -14,9 +14,12 @@ import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -148,6 +151,189 @@ void transientChildKeepsLocalFirstWinsIdentityAfterParentPublishesEquivalentCont assertSame(localGraph, child.freezeResolved(new Node().value("same graph"))); } + @Test + void promotionTraversesInheritedCanonicalEntriesToReachLocalDependencies() { + Node nestedContent = new Node().value("nested"); + ResolvedSnapshot nestedSnapshot = new Blue().resolveToSnapshot(nestedContent); + String nestedId = nestedSnapshot.blueId(); + Node holderContent = new Node().properties("nested", reference(nestedId)); + FrozenNode holderCanonical = FrozenNode.fromNode(holderContent); + String holderId = holderCanonical.blueId(); + ResolvedReferenceCache parent = new ResolvedReferenceCache(); + ResolvedReferenceCache child = parent.transientChild(); + + parent.putVerifiedCanonical(holderId, holderCanonical); + child.putVerifiedResolved(nestedSnapshot.verifiedReferenceResolution()); + + child.promoteReferencesReachableFrom(FrozenNode.fromNode(reference(holderId))); + + assertSame(nestedSnapshot.frozenCanonicalRoot(), + parent.getVerifiedCanonical(nestedId).orElseThrow(AssertionError::new)); + assertSame(nestedSnapshot.frozenResolvedRoot(), + parent.getVerifiedResolved(nestedId).orElseThrow(AssertionError::new)); + } + + @Test + void concurrentCanonicalMissesShareOneProviderLoad() throws Exception { + FrozenNode canonical = FrozenNode.fromNode(new Node().value("single-flight")); + String blueId = canonical.blueId(); + ResolvedReferenceCache cache = new ResolvedReferenceCache(); + AtomicInteger loads = new AtomicInteger(); + CountDownLatch loaderEntered = new CountDownLatch(1); + CountDownLatch releaseLoader = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(8); + try { + List> lookups = new ArrayList<>(); + for (int index = 0; index < 8; index++) { + lookups.add(executor.submit(() -> cache.getOrLoadVerifiedCanonical( + blueId, + () -> { + loads.incrementAndGet(); + loaderEntered.countDown(); + awaitUnchecked(releaseLoader); + return canonical; + }))); + } + + assertTrue(loaderEntered.await(5, TimeUnit.SECONDS)); + releaseLoader.countDown(); + + for (Future lookup : lookups) { + assertSame(canonical, lookup.get(5, TimeUnit.SECONDS)); + } + assertEquals(1, loads.get()); + } finally { + releaseLoader.countDown(); + executor.shutdownNow(); + } + } + + @Test + void providerLoadDoesNotHoldTheLegacyCollisionStripe() throws Exception { + FrozenNode[] collision = canonicalNodesWhoseBlueIdsSharedLegacyStripe(); + FrozenNode first = collision[0]; + FrozenNode second = collision[1]; + ResolvedReferenceCache cache = new ResolvedReferenceCache(); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future firstLookup = executor.submit(() -> + cache.getOrLoadVerifiedCanonical(first.blueId(), () -> { + Future nested = executor.submit(() -> + cache.getOrLoadVerifiedCanonical( + second.blueId(), () -> second)); + try { + assertSame(second, nested.get(2, TimeUnit.SECONDS)); + } catch (Exception failure) { + throw new IllegalStateException( + "colliding provider lookup could not complete", failure); + } + return first; + })); + + assertSame(first, firstLookup.get(5, TimeUnit.SECONDS)); + assertSame(second, + cache.getVerifiedCanonical(second.blueId()) + .orElseThrow(AssertionError::new)); + } finally { + executor.shutdownNow(); + } + } + + @Test + void clearStartsANewGenerationLoadWithoutWaitingForTheOldProvider() throws Exception { + FrozenNode canonical = FrozenNode.fromNode(new Node().value("generation-flight")); + String blueId = canonical.blueId(); + ResolvedReferenceCache cache = new ResolvedReferenceCache(); + CountDownLatch oldLoaderEntered = new CountDownLatch(1); + CountDownLatch releaseOldLoader = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future oldLookup = executor.submit(() -> + cache.getOrLoadVerifiedCanonical(blueId, () -> { + oldLoaderEntered.countDown(); + awaitUnchecked(releaseOldLoader); + return canonical; + })); + assertTrue(oldLoaderEntered.await(5, TimeUnit.SECONDS)); + + cache.clear(); + Future newLookup = executor.submit(() -> + cache.getOrLoadVerifiedCanonical(blueId, () -> canonical)); + + assertSame(canonical, newLookup.get(2, TimeUnit.SECONDS)); + releaseOldLoader.countDown(); + assertSame(canonical, oldLookup.get(5, TimeUnit.SECONDS)); + assertSame(canonical, + cache.getVerifiedCanonical(blueId).orElseThrow(AssertionError::new)); + } finally { + releaseOldLoader.countDown(); + executor.shutdownNow(); + } + } + + @Test + void recursiveCanonicalLoadsFailDeterministicallyAndRemainRetryable() { + FrozenNode first = FrozenNode.fromNode(new Node().value("recursive-first")); + FrozenNode second = FrozenNode.fromNode(new Node().value("recursive-second")); + ResolvedReferenceCache cache = new ResolvedReferenceCache(); + + IllegalStateException direct = assertThrows(IllegalStateException.class, + () -> cache.getOrLoadVerifiedCanonical(first.blueId(), () -> + cache.getOrLoadVerifiedCanonical(first.blueId(), () -> first))); + assertEquals("Recursive verified reference load: " + first.blueId(), + direct.getMessage()); + + IllegalStateException indirect = assertThrows(IllegalStateException.class, + () -> cache.getOrLoadVerifiedCanonical(first.blueId(), () -> + cache.getOrLoadVerifiedCanonical(second.blueId(), () -> + cache.getOrLoadVerifiedCanonical( + first.blueId(), () -> first)))); + assertEquals("Recursive verified reference load: " + first.blueId(), + indirect.getMessage()); + + IllegalStateException acrossClear = assertThrows(IllegalStateException.class, + () -> cache.getOrLoadVerifiedCanonical(first.blueId(), () -> { + cache.clear(); + return cache.getOrLoadVerifiedCanonical(first.blueId(), () -> first); + })); + assertEquals("Recursive verified reference load: " + first.blueId(), + acrossClear.getMessage()); + assertSame(first, + cache.getOrLoadVerifiedCanonical(first.blueId(), () -> first)); + } + + @Test + void closeDuringProviderLoadDoesNotDeadlockOrPublishLateContent() throws Exception { + FrozenNode canonical = FrozenNode.fromNode(new Node().value("closing-flight")); + ResolvedReferenceCache cache = new ResolvedReferenceCache(); + CountDownLatch loaderEntered = new CountDownLatch(1); + CountDownLatch releaseLoader = new CountDownLatch(1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future lookup = executor.submit(() -> + cache.getOrLoadVerifiedCanonical(canonical.blueId(), () -> { + loaderEntered.countDown(); + awaitUnchecked(releaseLoader); + return canonical; + })); + assertTrue(loaderEntered.await(5, TimeUnit.SECONDS)); + + cache.close(); + releaseLoader.countDown(); + + ExecutionException failure = assertThrows( + ExecutionException.class, + () -> lookup.get(5, TimeUnit.SECONDS)); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertEquals("Resolved reference cache is closed", + failure.getCause().getMessage()); + assertEquals(0, cache.cacheStats().verifiedEntries()); + } finally { + releaseLoader.countDown(); + executor.shutdownNow(); + } + } + @Test void parentInvalidationClearsAStaleChildAndPreventsOldEvidencePromotion() { ResolvedSnapshot verified = new Blue().resolveToSnapshot(new Node().value("verified")); @@ -177,6 +363,156 @@ void parentInvalidationClearsAStaleChildAndPreventsOldEvidencePromotion() { "evidence retained before invalidation must never be re-promoted"); } + @Test + void closingParentReleasesLeakedTransientChildState() { + ResolvedSnapshot verified = new Blue().resolveToSnapshot(new Node().value("verified")); + ResolvedReferenceCache parent = new ResolvedReferenceCache(); + ResolvedReferenceCache leakedChild = parent.transientChild(); + + leakedChild.putVerifiedResolved(verified.verifiedReferenceResolution()); + leakedChild.putTransientTrustedCanonical( + verified.blueId(), verified.frozenCanonicalRoot()); + leakedChild.freezeResolved(new Node().value("local graph")); + assertTrue(leakedChild.cacheStats().verifiedCurrentWeightBytes() > 0L); + assertTrue(leakedChild.cacheStats().transientTrustedCurrentWeightBytes() > 0L); + assertTrue(leakedChild.cacheStats().structuralCurrentWeightBytes() > 0L); + + parent.close(); + + assertEquals(0, leakedChild.cacheStats().verifiedEntries()); + assertEquals(0, leakedChild.cacheStats().transientTrustedEntries()); + assertEquals(0, leakedChild.cacheStats().structuralEntries()); + assertEquals(0L, leakedChild.cacheStats().verifiedCurrentWeightBytes()); + assertEquals(0L, leakedChild.cacheStats().transientTrustedCurrentWeightBytes()); + assertEquals(0L, leakedChild.cacheStats().structuralCurrentWeightBytes()); + assertThrows(IllegalStateException.class, + () -> leakedChild.getVerifiedCanonical(verified.blueId())); + } + + @Test + void closingTransientChildDoesNotInvalidateParentOrSibling() { + ResolvedReferenceCache parent = new ResolvedReferenceCache(); + ResolvedReferenceCache child = parent.transientChild(); + ResolvedReferenceCache sibling = parent.transientChild(); + child.freezeResolved(new Node().value("child-local")); + + child.close(); + child.close(); + + assertThrows(IllegalStateException.class, + () -> child.freezeResolved(new Node().value("closed"))); + assertEquals(0, child.cacheStats().structuralEntries()); + assertTrue(parent.isCurrentGeneration()); + assertTrue(sibling.isCurrentGeneration()); + parent.freezeResolved(new Node().value("parent-still-open")); + sibling.freezeResolved(new Node().value("sibling-still-open")); + } + + @Test + void closingTransientChildRetainsAggregateLifetimeHighWaterMarks() { + ResolvedSnapshot verified = new Blue().resolveToSnapshot(new Node().value("verified")); + ResolvedReferenceCache parent = new ResolvedReferenceCache(); + ResolvedReferenceCache child = parent.transientChild(); + child.putVerifiedResolved(verified.verifiedReferenceResolution()); + child.putTransientTrustedCanonical( + verified.blueId(), verified.frozenCanonicalRoot()); + child.freezeResolved(new Node().value("local graph")); + + child.close(); + + ResolvedReferenceCache.CacheStats stats = parent.cacheStats(); + assertEquals(0, stats.verifiedEntries()); + assertEquals(0, stats.transientTrustedEntries()); + assertEquals(0, stats.structuralEntries()); + assertTrue(stats.verifiedHighWaterWeightBytes() > 0L); + assertTrue(stats.transientTrustedHighWaterWeightBytes() > 0L); + assertTrue(stats.structuralHighWaterWeightBytes() > 0L); + } + + @Test + void closingIntermediateTransientScopeInvalidatesAndReleasesDescendants() { + ResolvedReferenceCache root = new ResolvedReferenceCache(); + ResolvedReferenceCache child = root.transientChild(); + ResolvedReferenceCache grandchild = child.transientChild(); + grandchild.freezeResolved(new Node().value("local")); + + child.close(); + + assertFalse(child.isCurrentGeneration()); + assertFalse(grandchild.isCurrentGeneration()); + assertEquals(0, grandchild.cacheStats().structuralEntries()); + assertThrows(IllegalStateException.class, + () -> grandchild.freezeResolved(new Node().value("local"))); + assertThrows(IllegalStateException.class, + () -> grandchild.freezeResolved(new Node().value("other"))); + assertThrows(IllegalStateException.class, child::transientChild); + assertThrows(IllegalStateException.class, grandchild::transientChild); + assertTrue(root.isCurrentGeneration()); + } + + @Test + void pinningThroughTransientChildDelegatesOwnershipToRoot() { + ResolvedSnapshot verified = new Blue().resolveToSnapshot(new Node().value("verified")); + ResolvedReferenceCache parent = new ResolvedReferenceCache(); + ResolvedReferenceCache child = parent.transientChild(); + + child.putPinnedVerifiedResolved(verified.verifiedReferenceResolution()); + + assertEquals(1, parent.cacheStats().pinnedVerifiedEntries()); + assertEquals(1, parent.cacheStats().verifiedEntries()); + assertEquals(0, child.cacheStats().pinnedVerifiedEntries()); + assertSame(verified.frozenResolvedRoot(), + parent.getVerifiedResolved(verified.blueId()).orElseThrow(AssertionError::new)); + } + + @Test + void isolatedPinnedCopyExcludesDerivedEntriesAndHasIndependentLifecycle() { + ResolvedSnapshot pinned = new Blue().resolveToSnapshot(new Node().value("pinned")); + ResolvedSnapshot derived = new Blue().resolveToSnapshot(new Node().value("derived")); + ResolvedReferenceCache source = new ResolvedReferenceCache(); + source.putPinnedVerifiedResolved(pinned.verifiedReferenceResolution()); + source.putVerifiedResolved(derived.verifiedReferenceResolution()); + + ResolvedReferenceCache firstCopy = source.isolatedCopyOfPinnedVerifiedEntries(); + assertSame(pinned.frozenResolvedRoot(), + firstCopy.getVerifiedResolved(pinned.blueId()).orElseThrow(AssertionError::new)); + assertFalse(firstCopy.getVerifiedResolved(derived.blueId()).isPresent()); + + firstCopy.close(); + assertSame(pinned.frozenResolvedRoot(), + source.getVerifiedResolved(pinned.blueId()).orElseThrow(AssertionError::new)); + + ResolvedReferenceCache retainedCopy = source.isolatedCopyOfPinnedVerifiedEntries(); + source.close(); + assertSame(pinned.frozenResolvedRoot(), + retainedCopy.getVerifiedResolved(pinned.blueId()).orElseThrow(AssertionError::new)); + retainedCopy.close(); + } + + @Test + void staleOrClosedTransientChildCannotPublishPinnedEvidenceToRoot() { + VerifiedReferenceResolution evidence = new Blue() + .resolveToSnapshot(new Node().value("verified")) + .verifiedReferenceResolution(); + ResolvedReferenceCache root = new ResolvedReferenceCache(); + ResolvedReferenceCache stale = root.transientChild(); + + root.clear(); + + assertFalse(stale.isCurrentGeneration()); + assertThrows(IllegalStateException.class, + () -> stale.putPinnedVerifiedResolved(evidence)); + assertEquals(0, root.cacheStats().verifiedEntries()); + assertEquals(0, root.cacheStats().pinnedVerifiedEntries()); + + ResolvedReferenceCache closed = root.transientChild(); + closed.close(); + assertThrows(IllegalStateException.class, + () -> closed.putPinnedVerifiedResolved(evidence)); + assertEquals(0, root.cacheStats().verifiedEntries()); + assertEquals(0, root.cacheStats().pinnedVerifiedEntries()); + } + @Test void unrelatedResolvedContentCannotBeCertified() throws Exception { assertArbitrarySnapshotCannotCertifyContent(false); @@ -400,6 +736,29 @@ private static Node reference(String blueId) { return new Node().blueId(blueId); } + private static FrozenNode[] canonicalNodesWhoseBlueIdsSharedLegacyStripe() { + Map firstByStripe = new HashMap<>(); + for (int index = 0; index < 1024; index++) { + FrozenNode candidate = FrozenNode.fromNode( + new Node().value("legacy-loading-stripe-" + index)); + int stripe = (candidate.blueId().hashCode() & Integer.MAX_VALUE) % 64; + FrozenNode first = firstByStripe.putIfAbsent(stripe, candidate); + if (first != null && !first.blueId().equals(candidate.blueId())) { + return new FrozenNode[]{first, candidate}; + } + } + throw new AssertionError("could not find colliding canonical BlueIds"); + } + + private static void awaitUnchecked(CountDownLatch latch) { + try { + latch.await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while awaiting test gate", interrupted); + } + } + private void assertSourceConstructorsArePrivate(Class type) { int sourceConstructors = 0; for (java.lang.reflect.Constructor constructor : type.getDeclaredConstructors()) { diff --git a/src/test/java/blue/language/utils/FrozenTypeMatcherCachePolicyTest.java b/src/test/java/blue/language/utils/FrozenTypeMatcherCachePolicyTest.java new file mode 100644 index 0000000..cc218d0 --- /dev/null +++ b/src/test/java/blue/language/utils/FrozenTypeMatcherCachePolicyTest.java @@ -0,0 +1,74 @@ +package blue.language.utils; + +import blue.language.BlueCachePolicy; +import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FrozenTypeMatcherCachePolicyTest { + + @Test + void allMatcherRegionsShareTheConfiguredEntryAndWeightBudget() { + BlueCachePolicy policy = BlueCachePolicy.builder() + .conformancePlans(5, 4_096L) + .maximumDerivedEntryWeightBytes(4_096L) + .build(); + FrozenTypeMatcher matcher = new FrozenTypeMatcher(null, true, policy); + + for (int index = 0; index < 40; index++) { + FrozenNode value = value("value-" + index); + assertTrue(matcher.matchesType(value, value)); + assertTrue(matcher.cacheEntryCount() <= 5); + assertTrue(matcher.cacheWeightBytes() <= 4_096L); + } + + assertTrue(matcher.cacheEntryCount() > 0); + assertTrue(matcher.matchesType(value("value-0"), value("value-0")), + "an evicted plan must remain safely recomputable"); + assertTrue(matcher.cacheEntryCount() <= 5); + assertTrue(matcher.cacheWeightBytes() <= 4_096L); + } + + @Test + void oversizedPlansAreUsedWithoutBeingRetainedAndClearReleasesAcceptedPlans() { + BlueCachePolicy rejectingPolicy = BlueCachePolicy.builder() + .conformancePlans(4, 256L) + .maximumDerivedEntryWeightBytes(256L) + .build(); + FrozenTypeMatcher rejecting = new FrozenTypeMatcher(null, true, rejectingPolicy); + + FrozenNode large = value(repeat('x', 2_048)); + assertTrue(rejecting.matchesType(large, large)); + assertEquals(0, rejecting.cacheEntryCount()); + assertEquals(0L, rejecting.cacheWeightBytes()); + + BlueCachePolicy acceptingPolicy = BlueCachePolicy.builder() + .conformancePlans(4, 8_192L) + .maximumDerivedEntryWeightBytes(8_192L) + .build(); + FrozenTypeMatcher accepting = new FrozenTypeMatcher(null, true, acceptingPolicy); + assertTrue(accepting.matchesType(value("small"), value("small"))); + assertTrue(accepting.cacheEntryCount() > 0); + + accepting.clearCaches(); + + assertEquals(0, accepting.cacheEntryCount()); + assertEquals(0L, accepting.cacheWeightBytes()); + assertTrue(accepting.matchesType(value("small"), value("small"))); + } + + private FrozenNode value(String value) { + return FrozenNode.fromResolvedNode(new Node().value(value)); + } + + private String repeat(char value, int count) { + StringBuilder builder = new StringBuilder(count); + for (int index = 0; index < count; index++) { + builder.append(value); + } + return builder.toString(); + } +} diff --git a/src/test/java/blue/language/utils/NodePathAccessorTest.java b/src/test/java/blue/language/utils/NodePathAccessorTest.java index 1f036c0..576355e 100644 --- a/src/test/java/blue/language/utils/NodePathAccessorTest.java +++ b/src/test/java/blue/language/utils/NodePathAccessorTest.java @@ -80,6 +80,15 @@ void testInvalidPath() { assertThrows(IllegalArgumentException.class, () -> rootNode.get("invalid")); } + @Test + void listIndexesRemainAsciiAndUnicodeDigitsRemainPropertyNames() { + Node node = new Node().properties("\u0660", new Node().value("property")); + + assertEquals("property", NodePathAccessor.get(node, "/\u0660")); + assertThrows(IllegalArgumentException.class, + () -> NodePathAccessor.get(rootNode, "/a/\u0660")); + } + @Test void testValuePrecedence() { Node nodeWithValue = new Node().name("Test").value("TestValue"); diff --git a/src/test/java/blue/language/utils/TypeClassResolverTest.java b/src/test/java/blue/language/utils/TypeClassResolverTest.java new file mode 100644 index 0000000..2d040e1 --- /dev/null +++ b/src/test/java/blue/language/utils/TypeClassResolverTest.java @@ -0,0 +1,29 @@ +package blue.language.utils; + +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TypeClassResolverTest { + + @Test + void blueIdMapViewRemainsLiveAndUnmodifiableAcrossRegistration() { + TypeClassResolver resolver = new TypeClassResolver(); + Map> view = resolver.getBlueIdMap(); + Set>> entries = view.entrySet(); + + resolver.register("retained-live-view", String.class); + + assertSame(String.class, view.get("retained-live-view")); + assertEquals(1, entries.size()); + assertTrue(entries.stream().anyMatch(entry -> + entry.getKey().equals("retained-live-view") && entry.getValue() == String.class)); + assertThrows(UnsupportedOperationException.class, view::clear); + } +} diff --git a/tools/check-binary-api.sh b/tools/check-binary-api.sh new file mode 100755 index 0000000..fff02e3 --- /dev/null +++ b/tools/check-binary-api.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd "$(dirname "$0")" && pwd) +exec python3 "$script_dir/check_binary_api.py" "$@" diff --git a/tools/check_binary_api.py b/tools/check_binary_api.py new file mode 100644 index 0000000..d272a34 --- /dev/null +++ b/tools/check_binary_api.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""Dependency-free JVM classfile API compatibility check for release smoke tests.""" + +import argparse +import pathlib +import struct +import sys +import zipfile + +PUBLIC = 0x0001 +PRIVATE = 0x0002 +PROTECTED = 0x0004 +STATIC = 0x0008 +FINAL = 0x0010 +BRIDGE = 0x0040 +INTERFACE = 0x0200 +ABSTRACT = 0x0400 +SYNTHETIC = 0x1000 + + +class Reader: + def __init__(self, data): + self.data = data + self.offset = 0 + + def take(self, length): + result = self.data[self.offset:self.offset + length] + if len(result) != length: + raise ValueError("truncated class file") + self.offset += length + return result + + def u1(self): + return self.take(1)[0] + + def u2(self): + return struct.unpack(">H", self.take(2))[0] + + def u4(self): + return struct.unpack(">I", self.take(4))[0] + + +def parse_class(data): + reader = Reader(data) + if reader.u4() != 0xCAFEBABE: + raise ValueError("invalid class magic") + minor_version = reader.u2() + major_version = reader.u2() + count = reader.u2() + pool = [None] * count + index = 1 + while index < count: + tag = reader.u1() + if tag == 1: + pool[index] = (tag, reader.take(reader.u2()).decode("utf-8", "replace")) + elif tag in (3, 4): + reader.take(4) + elif tag in (5, 6): + reader.take(8) + index += 1 + elif tag in (7, 8, 16, 19, 20): + pool[index] = (tag, reader.u2()) + elif tag in (9, 10, 11, 12, 17, 18): + pool[index] = (tag, reader.u2(), reader.u2()) + elif tag == 15: + pool[index] = (tag, reader.u1(), reader.u2()) + else: + raise ValueError("unsupported constant-pool tag {}".format(tag)) + index += 1 + + def utf8(cp_index): + item = pool[cp_index] + if not item or item[0] != 1: + raise ValueError("expected UTF-8 constant") + return item[1] + + def class_name(cp_index): + if cp_index == 0: + return None + item = pool[cp_index] + if not item or item[0] != 7: + raise ValueError("expected class constant") + return utf8(item[1]).replace("/", ".") + + access = reader.u2() + name = class_name(reader.u2()) + superclass = class_name(reader.u2()) + interfaces = tuple(class_name(reader.u2()) for _ in range(reader.u2())) + + def members(): + result = {} + for _ in range(reader.u2()): + member_access = reader.u2() + member_name = utf8(reader.u2()) + descriptor = utf8(reader.u2()) + for _ in range(reader.u2()): + reader.u2() + reader.take(reader.u4()) + if (member_access & (PUBLIC | PROTECTED)) and not (member_access & SYNTHETIC): + result[(member_name, descriptor)] = member_access + return result + + fields = members() + methods = members() + return { + "name": name, + "minor_version": minor_version, + "major_version": major_version, + "access": access, + "superclass": superclass, + "interfaces": interfaces, + "fields": fields, + "methods": methods, + } + + +def classes_in(path): + classes = {} + with zipfile.ZipFile(path) as archive: + for entry in archive.infolist(): + name = entry.filename + if (not name.endswith(".class") or name.startswith("META-INF/versions/") + or name.endswith("module-info.class") or name.endswith("package-info.class")): + continue + parsed = parse_class(archive.read(entry)) + classes[parsed["name"]] = parsed + return classes + + +def visibility(access): + if access & PUBLIC: + return 2 + if access & PROTECTED: + return 1 + return 0 + + +def modifiers_changed(kind, baseline, current): + problems = [] + if visibility(current) < visibility(baseline): + problems.append("visibility reduced") + if bool(current & STATIC) != bool(baseline & STATIC): + problems.append("static modifier changed") + if not (baseline & FINAL) and (current & FINAL): + problems.append("final modifier added") + if kind == "method" and not (baseline & ABSTRACT) and (current & ABSTRACT): + problems.append("abstract modifier added") + return problems + + +def compare(baseline, current): + incompatible = [] + additions = [] + baseline_api = externally_reachable_api(baseline) + current_api = externally_reachable_api(current) + + for name in sorted(baseline_api): + old = baseline_api[name] + new = current.get(name) + if new is None: + incompatible.append("class removed: {}".format(name)) + continue + if visibility(new["access"]) < visibility(old["access"]): + incompatible.append("class visibility reduced: {}".format(name)) + if bool(old["access"] & INTERFACE) != bool(new["access"] & INTERFACE): + incompatible.append("class/interface kind changed: {}".format(name)) + if not (old["access"] & FINAL) and (new["access"] & FINAL): + incompatible.append("class made final: {}".format(name)) + if not (old["access"] & ABSTRACT) and (new["access"] & ABSTRACT): + incompatible.append("class made abstract: {}".format(name)) + if old["superclass"] != new["superclass"]: + incompatible.append("superclass changed: {} ({} -> {})".format( + name, old["superclass"], new["superclass"])) + for interface in old["interfaces"]: + if interface not in new["interfaces"]: + incompatible.append("implemented interface removed: {} :: {}".format( + name, interface)) + for member_kind in ("fields", "methods"): + for identity, old_access in sorted(old[member_kind].items()): + new_access = new[member_kind].get(identity) + label = "{} :: {}{}".format(name, identity[0], identity[1]) + if new_access is None: + incompatible.append("{} removed/descriptor changed: {}".format( + member_kind[:-1], label)) + continue + for problem in modifiers_changed(member_kind[:-1], old_access, new_access): + incompatible.append("{} {}: {}".format(member_kind[:-1], problem, label)) + + for name in sorted(set(current_api) - set(baseline_api)): + additions.append("public/protected class added: {}".format(name)) + for name in sorted(set(current_api) & set(baseline_api)): + old = baseline_api[name] + new = current_api[name] + for interface in sorted(set(new["interfaces"]) - set(old["interfaces"])): + additions.append("implemented interface added: {} :: {}".format( + name, interface)) + for member_kind in ("fields", "methods"): + for identity in sorted(set(new[member_kind]) - set(old[member_kind])): + access = new[member_kind][identity] + category = "default interface method" if ( + member_kind == "methods" and new["access"] & INTERFACE + and not access & (ABSTRACT | STATIC)) else member_kind[:-1] + additions.append("{} added: {} :: {}{}".format( + category, name, identity[0], identity[1])) + return baseline_api, current_api, incompatible, additions + + +def externally_reachable_api(classes): + result = {} + for name, value in classes.items(): + if visibility(value["access"]) == 0: + continue + outer = name + reachable = True + while "$" in outer: + outer = outer.rsplit("$", 1)[0] + enclosing = classes.get(outer) + if enclosing is not None and visibility(enclosing["access"]) == 0: + reachable = False + break + if reachable: + result[name] = value + return result + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("baseline_jar") + parser.add_argument("current_jar") + parser.add_argument("report_file", nargs="?", + default="build/reports/binary-api/compatibility.txt") + args = parser.parse_args() + for candidate in (args.baseline_jar, args.current_jar): + if not pathlib.Path(candidate).is_file(): + parser.error("JAR not found: {}".format(candidate)) + + baseline_api, current_api, incompatible, additions = compare( + classes_in(args.baseline_jar), classes_in(args.current_jar)) + current_classes = classes_in(args.current_jar) + incompatible.extend( + "Java 8 bytecode exceeded: {} has class major {}".format( + name, value["major_version"]) + for name, value in sorted(current_classes.items()) + if value["major_version"] > 52 + ) + current_majors = sorted({value["major_version"] for value in current_classes.values()}) + lines = [ + "Blue Language JVM binary API compatibility", + "baseline={}".format(args.baseline_jar), + "current={}".format(args.current_jar), + "baselineApiClasses={}".format(len(baseline_api)), + "currentApiClasses={}".format(len(current_api)), + "currentClassMajorVersions={}".format( + ",".join(str(value) for value in current_majors)), + "incompatibleChanges={}".format(len(incompatible)), + "additiveChanges={}".format(len(additions)), + ] + if incompatible: + lines.extend(["", "Incompatible changes:"] + [" " + item for item in incompatible]) + if additions: + lines.extend(["", "Additive changes:"] + [" " + item for item in additions]) + report = pathlib.Path(args.report_file) + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text("\n".join(lines) + "\n", encoding="utf-8") + if incompatible: + print("FAIL: {} incompatible JVM API change(s).".format(len(incompatible))) + print("Report: {}".format(report)) + return 1 + print("PASS: baseline public/protected JVM classes and descriptors remain compatible.") + print("Additive changes: {}".format(len(additions))) + print("Report: {}".format(report)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/sanitize-jmh-results.js b/tools/sanitize-jmh-results.js new file mode 100644 index 0000000..c24ea83 --- /dev/null +++ b/tools/sanitize-jmh-results.js @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); + +if (process.argv.length < 5) { + throw new Error( + 'Usage: sanitize-jmh-results.js [...]', + ); +} + +const output = process.argv[2]; +const inputs = process.argv.slice(3); +const results = inputs.flatMap((input) => JSON.parse(fs.readFileSync(input, 'utf8'))); + +for (const result of results) { + result.jvm = ''; + result.evidenceStatus = 'NON_FORKED_DIAGNOSTIC_ONLY'; + result.releaseGateEvidence = false; +} + +fs.writeFileSync(output, `${JSON.stringify(results, null, 2)}\n`); From 35553182e6807236b5921d2009c1764d34a3cb3c Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Mon, 20 Jul 2026 20:14:56 +0200 Subject: [PATCH 2/5] feat: add incremental resolution handling and improved patch management Added `IncrementalValueResolutionRequest` and expanded `IncrementalMergingProcessorCapability` to support resolution-aware merging. Introduced `PatchSource` attribution and new patch impact handling for processor-managed states. Enhanced conformance checks and metrics tracking for incremental resolutions. Updated tests to validate snapshot and patch workflows. --- build.gradle | 1 + src/main/java/blue/language/Blue.java | 43 +- .../conformance/ConformanceEngine.java | 7 + ...IncrementalMergingProcessorCapability.java | 9 + .../IncrementalValueResolutionRequest.java | 113 +++++ .../processor/ContractEffectBuffer.java | 4 +- .../processor/DocumentProcessingRuntime.java | 31 +- .../processor/ImmutableJsonPatch.java | 9 +- .../blue/language/processor/PatchImpact.java | 1 + .../processor/PatchImpactAnalyzer.java | 56 ++- .../blue/language/processor/PatchInput.java | 28 +- .../processor/PatchPlanningEngine.java | 3 + .../blue/language/processor/PatchSource.java | 15 + .../processor/ProcessingMetricsSink.java | 90 ++++ .../processor/ProcessingSnapshotManager.java | 6 + .../language/processor/ProcessorEngine.java | 48 +- .../processor/ProcessorExecutionContext.java | 4 +- .../processor/ProcessorMarkerFactory.java | 21 + .../language/processor/ScopeExecutor.java | 37 +- .../language/processor/WorkingDocument.java | 9 +- .../conformance/ScriptedContractsRuntime.java | 8 +- .../language/snapshot/ResolvedSnapshot.java | 12 + ...ngDocumentStateInvariantFailFirstTest.java | 4 +- .../DocumentProcessorInitializationTest.java | 451 +++++++++++++++++- ...umentProcessorSnapshotTransactionTest.java | 22 + .../processor/FrozenJsonPatchApiTest.java | 1 + .../PatchImpactIncrementalResolutionTest.java | 212 ++++++++ .../PublishedSnapshotRoundTripTest.java | 122 +++++ .../RecordingProcessingMetricsSinkTest.java | 18 + .../ResolvedSnapshotPatchTransactionTest.java | 2 +- 30 files changed, 1346 insertions(+), 41 deletions(-) create mode 100644 src/main/java/blue/language/merge/IncrementalValueResolutionRequest.java create mode 100644 src/main/java/blue/language/processor/PatchSource.java create mode 100644 src/main/java/blue/language/processor/ProcessorMarkerFactory.java create mode 100644 src/test/java/blue/language/processor/PublishedSnapshotRoundTripTest.java diff --git a/build.gradle b/build.gradle index 2ac3143..9497fd8 100644 --- a/build.gradle +++ b/build.gradle @@ -174,6 +174,7 @@ jmh { ext.genResourcesDir = file("$buildDir/generated-resources") task generateBuildProperties { ext.buildPropertiesFile = file("$genResourcesDir/blue/language/build.properties") + inputs.property('buildVersion', project.version.toString()) outputs.file(buildPropertiesFile) doLast { buildPropertiesFile.text = """\ diff --git a/src/main/java/blue/language/Blue.java b/src/main/java/blue/language/Blue.java index 0705161..3e3dd05 100644 --- a/src/main/java/blue/language/Blue.java +++ b/src/main/java/blue/language/Blue.java @@ -8,6 +8,7 @@ import blue.language.dictionary.TypeDictionary; import blue.language.merge.Merger; import blue.language.merge.IncrementalMergingProcessorCapability; +import blue.language.merge.IncrementalValueResolutionRequest; import blue.language.merge.MergingProcessor; import blue.language.merge.NodeResolver; import blue.language.merge.processor.*; @@ -1842,6 +1843,13 @@ public boolean supportsIncrementalValueResolution() { .supportsIncrementalValueResolution(); } + @Override + public boolean supportsIncrementalValueResolution(IncrementalValueResolutionRequest request) { + return snapshotMergingProcessor instanceof IncrementalMergingProcessorCapability + && ((IncrementalMergingProcessorCapability) snapshotMergingProcessor) + .supportsIncrementalValueResolution(request); + } + @Override public ConformanceEngine transientConformanceEngine(ConformanceEngine conformanceEngine) { if (conformanceEngine == null) { @@ -2155,11 +2163,11 @@ private Node validatedExternalTypeNode(String blueId, Node canonicalTypeNode) { } private ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { - Objects.requireNonNull(snapshot, "snapshot"); + ResolvedSnapshot publishable = publishableCacheSnapshot(snapshot); CacheSnapshotPublication publication; synchronized (lifecycleLock) { ensureOpen(); - publication = cacheSnapshotLocked(snapshot); + publication = cacheSnapshotLocked(publishable); } publication.emit(); return publication.result; @@ -2167,6 +2175,7 @@ private ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { /** Caller holds lifecycleLock, which linearizes publication with invalidation. */ private CacheSnapshotPublication cacheSnapshotLocked(ResolvedSnapshot snapshot) { + snapshot = publishableCacheSnapshot(snapshot); if (snapshot.verifiedReferenceResolution() != null) { resolvedReferenceCache.putVerifiedResolved(snapshot.verifiedReferenceResolution()); } @@ -2228,6 +2237,7 @@ private ResolvedSnapshot publishProcessingSnapshot( && !transientReferenceCache.isCurrentGeneration()) { return snapshot; } + snapshot = publishableCacheSnapshot(snapshot, metricsSink()); if (transientReferenceCache != null) { transientReferenceCache.promoteReferencesReachableFrom( snapshot.frozenCanonicalRoot()); @@ -2239,7 +2249,7 @@ private ResolvedSnapshot publishProcessingSnapshot( } private void pinSnapshot(ResolvedSnapshot snapshot) { - Objects.requireNonNull(snapshot, "snapshot"); + snapshot = publishableCacheSnapshot(snapshot); ensureOpen(); if (snapshot.verifiedReferenceResolution() != null) { resolvedReferenceCache.putPinnedVerifiedResolved(snapshot.verifiedReferenceResolution()); @@ -2283,6 +2293,33 @@ private void pinSnapshot(ResolvedSnapshot snapshot) { gauges.emit(metrics); } + private ResolvedSnapshot publishableCacheSnapshot(ResolvedSnapshot snapshot) { + return publishableCacheSnapshot(snapshot, null); + } + + private ResolvedSnapshot publishableCacheSnapshot(ResolvedSnapshot snapshot, + ProcessingMetricsSink metrics) { + Objects.requireNonNull(snapshot, "snapshot"); + FrozenNode canonicalRoot = snapshot.frozenCanonicalRoot(); + if (canonicalRoot.isStrictCanonical() + && canonicalRoot.isStrictBlueIdValidation()) { + return snapshot; + } + if (metrics != null) { + metrics.incrementProcessorPublicationCanonicalizations(); + metrics.incrementProcessorPublicationCanonicalMaterializations(); + metrics.incrementProcessorPublicationStrictBlueIdCalculations(); + long canonicalizationStart = System.nanoTime(); + try { + return snapshot.toStrictBlueIdValidatedCanonical(); + } finally { + metrics.addProcessorPublicationCanonicalizationNanos( + Math.max(1L, System.nanoTime() - canonicalizationStart)); + } + } + return snapshot.toStrictBlueIdValidatedCanonical(); + } + private void replacePinnedSnapshot(FrozenNode.ResolvedStructuralKey key, ResolvedSnapshot previous, ResolvedSnapshot replacement) { diff --git a/src/main/java/blue/language/conformance/ConformanceEngine.java b/src/main/java/blue/language/conformance/ConformanceEngine.java index 1a82bcd..eeb61f3 100644 --- a/src/main/java/blue/language/conformance/ConformanceEngine.java +++ b/src/main/java/blue/language/conformance/ConformanceEngine.java @@ -4,6 +4,7 @@ import blue.language.NodeProvider; import blue.language.merge.Merger; import blue.language.merge.IncrementalMergingProcessorCapability; +import blue.language.merge.IncrementalValueResolutionRequest; import blue.language.merge.MergingProcessor; import blue.language.model.Node; import blue.language.snapshot.FrozenNode; @@ -115,6 +116,12 @@ public boolean supportsIncrementalValueResolution() { .supportsIncrementalValueResolution(); } + public boolean supportsIncrementalValueResolution(IncrementalValueResolutionRequest request) { + return mergingProcessor instanceof IncrementalMergingProcessorCapability + && ((IncrementalMergingProcessorCapability) mergingProcessor) + .supportsIncrementalValueResolution(request); + } + public ConformanceResult check(Node node) { if (node == null) { return ConformanceResult.conformant(); diff --git a/src/main/java/blue/language/merge/IncrementalMergingProcessorCapability.java b/src/main/java/blue/language/merge/IncrementalMergingProcessorCapability.java index 5104760..4091b67 100644 --- a/src/main/java/blue/language/merge/IncrementalMergingProcessorCapability.java +++ b/src/main/java/blue/language/merge/IncrementalMergingProcessorCapability.java @@ -14,4 +14,13 @@ public interface IncrementalMergingProcessorCapability { * snapshot resolution. */ boolean supportsIncrementalValueResolution(); + + /** + * Request-aware variant for transparent wrappers. Existing implementations + * keep their historical behavior through this conservative default. + */ + default boolean supportsIncrementalValueResolution( + IncrementalValueResolutionRequest request) { + return supportsIncrementalValueResolution(); + } } diff --git a/src/main/java/blue/language/merge/IncrementalValueResolutionRequest.java b/src/main/java/blue/language/merge/IncrementalValueResolutionRequest.java new file mode 100644 index 0000000..49ebdcf --- /dev/null +++ b/src/main/java/blue/language/merge/IncrementalValueResolutionRequest.java @@ -0,0 +1,113 @@ +package blue.language.merge; + +import blue.language.snapshot.FrozenNode; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Immutable, bounded evidence for one proposed incremental value resolution. + * + *

The request deliberately exposes frozen before/after views and fixed + * structural flags, never mutable {@code Node} graphs or processor-specific + * implementation details.

+ */ +public final class IncrementalValueResolutionRequest { + + private final String originScope; + private final String changedPath; + private final String operation; + private final FrozenNode canonicalBefore; + private final FrozenNode canonicalAfter; + private final FrozenNode resolvedBefore; + private final FrozenNode resolvedAfter; + private final List affectedTypedBoundaries; + private final boolean typeMetadataChange; + private final boolean schemaMetadataChange; + private final boolean referenceChange; + private final boolean listShapeChange; + private final boolean contractsOrProcessingChange; + + public IncrementalValueResolutionRequest(String originScope, + String changedPath, + String operation, + FrozenNode canonicalBefore, + FrozenNode canonicalAfter, + FrozenNode resolvedBefore, + FrozenNode resolvedAfter, + List affectedTypedBoundaries, + boolean typeMetadataChange, + boolean schemaMetadataChange, + boolean referenceChange, + boolean listShapeChange, + boolean contractsOrProcessingChange) { + this.originScope = Objects.requireNonNull(originScope, "originScope"); + this.changedPath = Objects.requireNonNull(changedPath, "changedPath"); + this.operation = Objects.requireNonNull(operation, "operation"); + this.canonicalBefore = canonicalBefore; + this.canonicalAfter = canonicalAfter; + this.resolvedBefore = resolvedBefore; + this.resolvedAfter = resolvedAfter; + this.affectedTypedBoundaries = Collections.unmodifiableList(new ArrayList<>( + Objects.requireNonNull(affectedTypedBoundaries, "affectedTypedBoundaries"))); + this.typeMetadataChange = typeMetadataChange; + this.schemaMetadataChange = schemaMetadataChange; + this.referenceChange = referenceChange; + this.listShapeChange = listShapeChange; + this.contractsOrProcessingChange = contractsOrProcessingChange; + } + + public String originScope() { + return originScope; + } + + public String changedPath() { + return changedPath; + } + + public String operation() { + return operation; + } + + public FrozenNode canonicalBefore() { + return canonicalBefore; + } + + public FrozenNode canonicalAfter() { + return canonicalAfter; + } + + public FrozenNode resolvedBefore() { + return resolvedBefore; + } + + public FrozenNode resolvedAfter() { + return resolvedAfter; + } + + public List affectedTypedBoundaries() { + return affectedTypedBoundaries; + } + + public boolean typeMetadataChange() { + return typeMetadataChange; + } + + public boolean schemaMetadataChange() { + return schemaMetadataChange; + } + + public boolean referenceChange() { + return referenceChange; + } + + public boolean listShapeChange() { + return listShapeChange; + } + + public boolean contractsOrProcessingChange() { + return contractsOrProcessingChange; + } +} diff --git a/src/main/java/blue/language/processor/ContractEffectBuffer.java b/src/main/java/blue/language/processor/ContractEffectBuffer.java index 6e18945..e7eb90b 100644 --- a/src/main/java/blue/language/processor/ContractEffectBuffer.java +++ b/src/main/java/blue/language/processor/ContractEffectBuffer.java @@ -42,11 +42,11 @@ void addPatch(JsonPatch patch) { } void addPatches(List input) { - addPatchInputs(PatchInput.mutableList(input), null); + addPatchInputs(PatchInput.mutableList(input, PatchSource.CUSTOM_PROCESSOR), null); } void addPreviewedPatches(List input, WorkingDocument.Preview preview) { - addPatchInputs(PatchInput.mutableList(input), preview); + addPatchInputs(PatchInput.mutableList(input, PatchSource.CUSTOM_PROCESSOR), preview); } void addFrozenPatches(List input) { diff --git a/src/main/java/blue/language/processor/DocumentProcessingRuntime.java b/src/main/java/blue/language/processor/DocumentProcessingRuntime.java index 7adc9a8..c732869 100644 --- a/src/main/java/blue/language/processor/DocumentProcessingRuntime.java +++ b/src/main/java/blue/language/processor/DocumentProcessingRuntime.java @@ -110,6 +110,7 @@ public DocumentProcessingRuntime(ResolvedSnapshot snapshot, ConformancePlannerOverride conformancePlannerOverride, ProcessingSnapshotManager snapshotManager, ProcessingMetricsSink metrics) { + this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP; ResolvedSnapshot processorSnapshot = processorSnapshot(Objects.requireNonNull(snapshot, "snapshot")); this.materializedView = new MaterializedDocumentView(processorSnapshot.canonicalRoot()); this.emissionRegistry = new EmissionRegistry(); @@ -118,17 +119,17 @@ public DocumentProcessingRuntime(ResolvedSnapshot snapshot, this.conformancePlannerOverride = conformancePlannerOverride; this.snapshotManager = snapshotManager; this.snapshot = processorSnapshot; - this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP; this.lazyMaterializedCommits = true; this.selectedDocumentBacked = false; } private ResolvedSnapshot processorSnapshot(ResolvedSnapshot snapshot) { - if (!snapshot.frozenCanonicalRoot().isStrictBlueIdValidation()) { - return snapshot; + if (snapshot.frozenCanonicalRoot().isStrictBlueIdValidation()) { + metrics.incrementProcessorInputStrictCanonical(); + } else { + metrics.incrementProcessorInputUncheckedCanonical(); } - FrozenNode canonicalRoot = FrozenNode.fromUncheckedCanonicalNode(snapshot.canonicalRoot()); - return new ResolvedSnapshot(canonicalRoot, snapshot.frozenResolvedRoot(), canonicalRoot.blueId()); + return snapshot; } public Node document() { @@ -331,6 +332,10 @@ public FrozenNode canonicalFrozenAt(String path) { } public WorkingDocument workingDocument(String originScopePath) { + return workingDocument(originScopePath, PatchSource.LEGACY_PUBLIC_API); + } + + WorkingDocument workingDocument(String originScopePath, PatchSource mutablePatchSource) { String normalizedScope = PointerUtils.normalizeScope(originScopePath); ResolvedSnapshot current = snapshot; boolean materializedFallback = false; @@ -351,6 +356,7 @@ public WorkingDocument workingDocument(String originScopePath) { current, materializedFallback, !selectedDocumentBacked, + mutablePatchSource, metrics); } @@ -366,6 +372,7 @@ public WorkingDocument workingDocument(String originScopePath) { null, true, false, + mutablePatchSource, metrics); } @@ -535,18 +542,28 @@ private void removeMaterializedPath(Node root, String path) { } public DocumentUpdateData applyPatch(String originScopePath, JsonPatch patch) { + return applyPatch(originScopePath, patch, PatchSource.LEGACY_PUBLIC_API); + } + + public DocumentUpdateData applyPatch(String originScopePath, JsonPatch patch, PatchSource source) { if (patch == null) { return null; } - List updates = applyPatches(originScopePath, Collections.singletonList(patch)); + List updates = applyPatches(originScopePath, Collections.singletonList(patch), source); return updates.isEmpty() ? null : updates.get(0); } public List applyPatches(String originScopePath, List patches) { + return applyPatches(originScopePath, patches, PatchSource.LEGACY_PUBLIC_API); + } + + public List applyPatches(String originScopePath, + List patches, + PatchSource source) { if (patches == null || patches.isEmpty()) { return Collections.emptyList(); } - return applyPatchInputs(originScopePath, PatchInput.mutableList(patches)); + return applyPatchInputs(originScopePath, PatchInput.mutableList(patches, source)); } public DocumentUpdateData applyFrozenPatch(String originScopePath, FrozenJsonPatch patch) { diff --git a/src/main/java/blue/language/processor/ImmutableJsonPatch.java b/src/main/java/blue/language/processor/ImmutableJsonPatch.java index 7e13163..dd68ac4 100644 --- a/src/main/java/blue/language/processor/ImmutableJsonPatch.java +++ b/src/main/java/blue/language/processor/ImmutableJsonPatch.java @@ -172,6 +172,13 @@ private PreparationContext(ProcessingMetricsSink metrics) { ImmutableJsonPatch prepare(JsonPatch patch, FrozenNode canonicalRoot, FrozenNode resolvedRoot) { + return prepare(patch, canonicalRoot, resolvedRoot, PatchSource.LEGACY_PUBLIC_API); + } + + ImmutableJsonPatch prepare(JsonPatch patch, + FrozenNode canonicalRoot, + FrozenNode resolvedRoot, + PatchSource source) { Objects.requireNonNull(patch, "patch"); Objects.requireNonNull(canonicalRoot, "canonicalRoot"); Objects.requireNonNull(resolvedRoot, "resolvedRoot"); @@ -191,7 +198,7 @@ ImmutableJsonPatch prepare(JsonPatch patch, } Node value = Objects.requireNonNull(patch.getVal(), "patch value"); - metrics.incrementMutablePatchValuesFrozen(); + metrics.incrementMutablePatchValuesFrozen(source); FrozenNode canonical = freeze(value, canonicalRoot); FrozenNode resolved; if (sameFreezeMode(canonicalRoot, resolvedRoot)) { diff --git a/src/main/java/blue/language/processor/PatchImpact.java b/src/main/java/blue/language/processor/PatchImpact.java index e8852dd..06892b7 100644 --- a/src/main/java/blue/language/processor/PatchImpact.java +++ b/src/main/java/blue/language/processor/PatchImpact.java @@ -25,6 +25,7 @@ enum Kind { SCHEMA_METADATA, REFERENCE_OR_BLUE_ID, MERGE_POLICY, + PROCESSOR_MANAGED_STATE, CONTRACT_OR_PROCESSING_STRUCTURE, ROOT_REPLACEMENT, UNKNOWN diff --git a/src/main/java/blue/language/processor/PatchImpactAnalyzer.java b/src/main/java/blue/language/processor/PatchImpactAnalyzer.java index 8408464..12cab72 100644 --- a/src/main/java/blue/language/processor/PatchImpactAnalyzer.java +++ b/src/main/java/blue/language/processor/PatchImpactAnalyzer.java @@ -1,7 +1,10 @@ package blue.language.processor; import blue.language.conformance.ConformanceEngine; +import blue.language.merge.IncrementalValueResolutionRequest; import blue.language.processor.model.JsonPatch; +import blue.language.processor.util.ProcessorPointerConstants; +import blue.language.processor.util.PointerUtils; import blue.language.snapshot.FrozenNode; import blue.language.utils.JsonPointer; import blue.language.utils.ParsedJsonPointer; @@ -120,7 +123,10 @@ PatchImpact analyze(boolean exactReplacement, boolean collectionChange = collectionBoundary != null || parentIsList(canonicalRoot, path) || parentIsList(resolvedRoot, path); - boolean contractsChange = containsSegment(path, "contracts"); + boolean processorManagedStateChange = isProcessorManagedStateChange( + canonicalPlan.originScope(), path); + boolean contractsChange = !processorManagedStateChange + && containsSegment(path, "contracts"); boolean typeChange = containsAnySegment(path, "type", "itemType", "keyType", "valueType"); boolean schemaChange = containsSegment(path, "schema"); boolean referenceChange = containsAnySegment(path, "blueId", "blue", "$previous", "$pos"); @@ -140,6 +146,7 @@ PatchImpact analyze(boolean exactReplacement, canonicalPlan, resolvedPlan, collectionChange, + processorManagedStateChange, contractsChange, typeChange, schemaChange, @@ -164,6 +171,8 @@ PatchImpact analyze(boolean exactReplacement, patch, canonicalPlan, resolvedPlan, + canonicalPlan.originScope(), + typedBoundaries, kind, typeDependency, safeBasicTypeDependency, @@ -172,6 +181,7 @@ PatchImpact analyze(boolean exactReplacement, referenceDependency, siblingDependency, collectionChange, + processorManagedStateChange, contractsChange, typeChange, schemaChange, @@ -203,6 +213,8 @@ private Decision decideTypedLocality(ParsedJsonPointer path, ImmutableJsonPatch patch, ImmutablePatchPlanner.PatchPlan canonicalPlan, ImmutablePatchPlanner.PatchPlan resolvedPlan, + String originScope, + List typedBoundaries, PatchImpact.Kind kind, boolean typeDependency, boolean safeBasicTypeDependency, @@ -211,6 +223,7 @@ private Decision decideTypedLocality(ParsedJsonPointer path, boolean referenceDependency, boolean siblingDependency, boolean collectionChange, + boolean processorManagedStateChange, boolean contractsChange, boolean typeChange, boolean schemaChange, @@ -219,6 +232,9 @@ private Decision decideTypedLocality(ParsedJsonPointer path, if (path.isRoot()) { return Decision.fallback(PatchImpact.FallbackReason.ROOT_REPLACEMENT); } + if (processorManagedStateChange) { + return Decision.local(); + } if (contractsChange) { return Decision.fallback(PatchImpact.FallbackReason.CONTRACTS_CHANGED); } @@ -260,12 +276,32 @@ private Decision decideTypedLocality(ParsedJsonPointer path, if (conformancePlannerOverride != null && conformancePlannerOverride.applies()) { return Decision.fallback(PatchImpact.FallbackReason.CUSTOM_CONFORMANCE_PLANNER); } - if (conformanceEngine == null || !conformanceEngine.supportsIncrementalValueResolution()) { + IncrementalValueResolutionRequest request = new IncrementalValueResolutionRequest( + originScope, + path.pointer(), + patch.op().name(), + canonicalPlan.before(), + canonicalPlan.after(), + resolvedPlan.before(), + resolvedPlan.after(), + typedBoundaries, + typeChange, + schemaChange, + referenceChange, + collectionChange, + contractsChange); + metrics.incrementIncrementalMergerCapabilityRequests(); + if (conformanceEngine == null || !conformanceEngine.supportsIncrementalValueResolution(request)) { + metrics.incrementIncrementalMergerCapabilityDenied(); + metrics.incrementIncrementalMergerCapabilityDeniedByConformance(); return Decision.fallback(PatchImpact.FallbackReason.CUSTOM_MERGING_PROCESSOR); } - if (snapshotManager == null || !snapshotManager.supportsIncrementalValueResolution()) { + if (snapshotManager == null || !snapshotManager.supportsIncrementalValueResolution(request)) { + metrics.incrementIncrementalMergerCapabilityDenied(); + metrics.incrementIncrementalMergerCapabilityDeniedBySnapshotManager(); return Decision.fallback(PatchImpact.FallbackReason.UNKNOWN_PROCESSOR_CAPABILITY); } + metrics.incrementIncrementalMergerCapabilityAllowed(); return Decision.local(); } @@ -372,6 +408,7 @@ private PatchImpact.Kind classify(ParsedJsonPointer path, ImmutablePatchPlanner.PatchPlan canonicalPlan, ImmutablePatchPlanner.PatchPlan resolvedPlan, boolean collectionChange, + boolean processorManagedStateChange, boolean contractsChange, boolean typeChange, boolean schemaChange, @@ -380,6 +417,9 @@ private PatchImpact.Kind classify(ParsedJsonPointer path, if (path.isRoot()) { return PatchImpact.Kind.ROOT_REPLACEMENT; } + if (processorManagedStateChange) { + return PatchImpact.Kind.PROCESSOR_MANAGED_STATE; + } if (contractsChange) { return PatchImpact.Kind.CONTRACT_OR_PROCESSING_STRUCTURE; } @@ -437,6 +477,10 @@ private void recordKind(PatchImpact.Kind kind) { case MERGE_POLICY: metrics.incrementPatchImpactMergePolicy(); break; + case PROCESSOR_MANAGED_STATE: + metrics.incrementPatchImpactProcessorManagedState(); + metrics.incrementProcessorManagedMarkerPatches(); + break; case CONTRACT_OR_PROCESSING_STRUCTURE: metrics.incrementPatchImpactContractsOrProcessing(); break; @@ -596,6 +640,12 @@ private boolean containsAnySegment(ParsedJsonPointer path, String... segments) { return false; } + private boolean isProcessorManagedStateChange(String originScope, + ParsedJsonPointer path) { + String relativePath = PointerUtils.relativizePointer(originScope, path.pointer()); + return PointerUtils.descendantOrEqual(relativePath, ProcessorPointerConstants.RELATIVE_INITIALIZED); + } + private static final class Decision { private final boolean local; private final PatchImpact.FallbackReason reason; diff --git a/src/main/java/blue/language/processor/PatchInput.java b/src/main/java/blue/language/processor/PatchInput.java index 1baad44..51a4944 100644 --- a/src/main/java/blue/language/processor/PatchInput.java +++ b/src/main/java/blue/language/processor/PatchInput.java @@ -14,27 +14,41 @@ final class PatchInput { private final JsonPatch mutablePatch; private final FrozenJsonPatch frozenPatch; + private final PatchSource source; - private PatchInput(JsonPatch mutablePatch, FrozenJsonPatch frozenPatch) { + private PatchInput(JsonPatch mutablePatch, FrozenJsonPatch frozenPatch, PatchSource source) { this.mutablePatch = mutablePatch; this.frozenPatch = frozenPatch; + this.source = source != null ? source : PatchSource.UNKNOWN_INTERNAL; } static PatchInput mutable(JsonPatch patch) { - return patch == null ? null : new PatchInput(ImmutableJsonPatch.copy(patch), null); + return mutable(patch, PatchSource.LEGACY_PUBLIC_API); + } + + static PatchInput mutable(JsonPatch patch, PatchSource source) { + return patch == null + ? null + : new PatchInput(ImmutableJsonPatch.copy(patch), null, source); } static PatchInput frozen(FrozenJsonPatch patch) { - return patch == null ? null : new PatchInput(null, patch); + return patch == null + ? null + : new PatchInput(null, patch, PatchSource.UNKNOWN_INTERNAL); } static List mutableList(List patches) { + return mutableList(patches, PatchSource.LEGACY_PUBLIC_API); + } + + static List mutableList(List patches, PatchSource source) { if (patches == null || patches.isEmpty()) { return Collections.emptyList(); } List captured = new ArrayList<>(patches.size()); for (JsonPatch patch : patches) { - captured.add(mutable(patch)); + captured.add(mutable(patch, source)); } return Collections.unmodifiableList(captured); } @@ -62,6 +76,10 @@ boolean isFrozen() { return frozenPatch != null; } + PatchSource source() { + return source; + } + Node mutableValue() { return mutablePatch != null ? mutablePatch.getVal() : null; } @@ -88,7 +106,7 @@ ImmutableJsonPatch prepare(ImmutableJsonPatch.PreparationContext context, FrozenNode canonicalRoot, FrozenNode resolvedRoot) { return mutablePatch != null - ? context.prepare(mutablePatch, canonicalRoot, resolvedRoot) + ? context.prepare(mutablePatch, canonicalRoot, resolvedRoot, source) : context.prepare(frozenPatch, canonicalRoot, resolvedRoot); } } diff --git a/src/main/java/blue/language/processor/PatchPlanningEngine.java b/src/main/java/blue/language/processor/PatchPlanningEngine.java index 96666a8..b89d4c8 100644 --- a/src/main/java/blue/language/processor/PatchPlanningEngine.java +++ b/src/main/java/blue/language/processor/PatchPlanningEngine.java @@ -251,6 +251,9 @@ private BatchPatchResult plan(List patches, for (BatchPatchRecord record : records) { if (record.impact().localResolutionProvenSafe()) { metrics.incrementIncrementalSnapshotResolutions(); + if (record.impact().kind() == PatchImpact.Kind.PROCESSOR_MANAGED_STATE) { + metrics.incrementProcessorManagedMarkerIncrementalResolutions(); + } metrics.addIncrementalBoundaryPathDepth(record.impact().path().depth()); metrics.addIncrementalBoundaryNodeCount(1L); } diff --git a/src/main/java/blue/language/processor/PatchSource.java b/src/main/java/blue/language/processor/PatchSource.java new file mode 100644 index 0000000..1039c7f --- /dev/null +++ b/src/main/java/blue/language/processor/PatchSource.java @@ -0,0 +1,15 @@ +package blue.language.processor; + +/** + * Fixed-cardinality source attribution for mutable patch values that must be + * frozen at the processor boundary. + */ +public enum PatchSource { + LEGACY_PUBLIC_API, + PROCESSOR_INITIALIZATION_MARKER, + PROCESSOR_TERMINATION_MARKER, + PROCESSOR_CHECKPOINT_MARKER, + CONFORMANCE_FIXTURE, + CUSTOM_PROCESSOR, + UNKNOWN_INTERNAL +} diff --git a/src/main/java/blue/language/processor/ProcessingMetricsSink.java b/src/main/java/blue/language/processor/ProcessingMetricsSink.java index 08bd8ae..be13ce5 100644 --- a/src/main/java/blue/language/processor/ProcessingMetricsSink.java +++ b/src/main/java/blue/language/processor/ProcessingMetricsSink.java @@ -460,6 +460,90 @@ default void incrementPatchImpactContractsOrProcessing() { addMetric("patchImpactContractsOrProcessing", 1L); } + default void incrementPatchImpactProcessorManagedState() { + addMetric("patchImpactProcessorManagedState", 1L); + } + + default void incrementProcessorManagedMarkerPatches() { + addMetric("processorManagedMarkerPatches", 1L); + } + + default void incrementProcessorManagedMarkerIncrementalResolutions() { + addMetric("processorManagedMarkerIncrementalResolutions", 1L); + } + + default void incrementInitializationDocumentIdUncheckedCalculations() { + addMetric("initializationDocumentIdUncheckedCalculations", 1L); + } + + default void incrementInitializationDocumentIdNodeMaterializations() { + addMetric("initializationDocumentIdNodeMaterializations", 1L); + } + + default void incrementInitializationDocumentIdFrozenUncheckedCalculations() { + addMetric("initializationDocumentIdFrozenUncheckedCalculations", 1L); + } + + default void incrementProcessorInputStrictCanonical() { + addMetric("processorInputStrictCanonical", 1L); + } + + default void incrementProcessorInputUncheckedCanonical() { + addMetric("processorInputUncheckedCanonical", 1L); + } + + default void incrementProcessorPublishedStrictCanonical() { + addMetric("processorPublishedStrictCanonical", 1L); + } + + default void incrementProcessorPublishedUncheckedCanonical() { + addMetric("processorPublishedUncheckedCanonical", 1L); + } + + default void incrementProcessorPublicationCanonicalizations() { + addMetric("processorPublicationCanonicalizations", 1L); + } + + default void addProcessorPublicationCanonicalizationNanos(long nanos) { + addMetric("processorPublicationCanonicalizationNanos", nanos); + } + + default void incrementProcessorPublicationCanonicalMaterializations() { + addMetric("processorPublicationCanonicalMaterializations", 1L); + } + + default void incrementProcessorPublicationStrictBlueIdCalculations() { + addMetric("processorPublicationStrictBlueIdCalculations", 1L); + } + + default void incrementProcessorPublicationIdentityMismatches() { + addMetric("processorPublicationIdentityMismatches", 1L); + } + + default void incrementProcessorPublicationInvariantChecks() { + addMetric("processorPublicationInvariantChecks", 1L); + } + + default void incrementIncrementalMergerCapabilityRequests() { + addMetric("incrementalMergerCapabilityRequests", 1L); + } + + default void incrementIncrementalMergerCapabilityAllowed() { + addMetric("incrementalMergerCapabilityAllowed", 1L); + } + + default void incrementIncrementalMergerCapabilityDenied() { + addMetric("incrementalMergerCapabilityDenied", 1L); + } + + default void incrementIncrementalMergerCapabilityDeniedByConformance() { + addMetric("incrementalMergerCapabilityDeniedByConformance", 1L); + } + + default void incrementIncrementalMergerCapabilityDeniedBySnapshotManager() { + addMetric("incrementalMergerCapabilityDeniedBySnapshotManager", 1L); + } + default void incrementPatchImpactRootReplacement() { addMetric("patchImpactRootReplacement", 1L); } @@ -598,7 +682,13 @@ default void incrementFrozenPatchValuesAccepted() { } default void incrementMutablePatchValuesFrozen() { + incrementMutablePatchValuesFrozen(PatchSource.LEGACY_PUBLIC_API); + } + + default void incrementMutablePatchValuesFrozen(PatchSource source) { + PatchSource fixedSource = source != null ? source : PatchSource.UNKNOWN_INTERNAL; addMetric("mutablePatchValuesFrozen", 1L); + addMetric("mutablePatchValuesFrozenBySource." + fixedSource.name(), 1L); } default void incrementFrozenPatchValuesMaterialized() { diff --git a/src/main/java/blue/language/processor/ProcessingSnapshotManager.java b/src/main/java/blue/language/processor/ProcessingSnapshotManager.java index bb40429..dc59f57 100644 --- a/src/main/java/blue/language/processor/ProcessingSnapshotManager.java +++ b/src/main/java/blue/language/processor/ProcessingSnapshotManager.java @@ -1,6 +1,7 @@ package blue.language.processor; import blue.language.conformance.ConformanceEngine; +import blue.language.merge.IncrementalValueResolutionRequest; import blue.language.model.Node; import blue.language.processor.model.JsonPatch; import blue.language.snapshot.ResolvedSnapshot; @@ -63,6 +64,11 @@ default boolean supportsIncrementalValueResolution() { return false; } + default boolean supportsIncrementalValueResolution( + IncrementalValueResolutionRequest request) { + return supportsIncrementalValueResolution(); + } + /** * Returns the conformance view that shares this sequence's transient * resolution scope. Cache-aware decorators should delegate this method diff --git a/src/main/java/blue/language/processor/ProcessorEngine.java b/src/main/java/blue/language/processor/ProcessorEngine.java index 00a73a6..caa7fac 100644 --- a/src/main/java/blue/language/processor/ProcessorEngine.java +++ b/src/main/java/blue/language/processor/ProcessorEngine.java @@ -652,8 +652,9 @@ DocumentProcessingResult result() { String reason = fatal != null ? fatal.reason : null; ResolvedSnapshot snapshot = runtime.snapshot(); if (snapshot != null) { + ResolvedSnapshot publishedSnapshot = publishableSnapshot(snapshot, owner.metricsSink()); return DocumentProcessingResult.ofSelected(runtime.selectedDocument(), - snapshot, + publishedSnapshot, runtime.rootEmissions(), runtime.totalGas(), status, @@ -668,6 +669,51 @@ DocumentProcessingResult result() { reason); } + private ResolvedSnapshot publishableSnapshot(ResolvedSnapshot snapshot, + ProcessingMetricsSink metrics) { + ProcessingMetricsSink sink = metrics != null ? metrics : ProcessingMetricsSink.NOOP; + sink.incrementProcessorPublicationInvariantChecks(); + ResolvedSnapshot published = snapshot; + if (!isStrictPublishable(published)) { + sink.incrementProcessorPublicationCanonicalizations(); + sink.incrementProcessorPublicationCanonicalMaterializations(); + sink.incrementProcessorPublicationStrictBlueIdCalculations(); + long canonicalizationStart = System.nanoTime(); + try { + published = published.toStrictBlueIdValidatedCanonical(); + } catch (RuntimeException exception) { + sink.incrementProcessorPublishedUncheckedCanonical(); + sink.incrementProcessorPublicationIdentityMismatches(); + throw exception; + } finally { + sink.addProcessorPublicationCanonicalizationNanos( + Math.max(1L, System.nanoTime() - canonicalizationStart)); + } + } + + if (!isStrictPublishable(published)) { + sink.incrementProcessorPublishedUncheckedCanonical(); + sink.incrementProcessorPublicationIdentityMismatches(); + throw new IllegalStateException( + "Processor result snapshot must be strict canonical with strict BlueId validation."); + } + String snapshotBlueId = published.blueId(); + String canonicalBlueId = published.frozenCanonicalRoot().blueId(); + if (!Objects.equals(snapshotBlueId, canonicalBlueId)) { + sink.incrementProcessorPublicationIdentityMismatches(); + throw new IllegalStateException( + "Processor result snapshot BlueId must match canonical root BlueId."); + } + sink.incrementProcessorPublishedStrictCanonical(); + return published; + } + + private boolean isStrictPublishable(ResolvedSnapshot snapshot) { + FrozenNode canonicalRoot = snapshot.frozenCanonicalRoot(); + return canonicalRoot.isStrictCanonical() + && canonicalRoot.isStrictBlueIdValidation(); + } + DocumentProcessingResult partialResult() { try { return result(); diff --git a/src/main/java/blue/language/processor/ProcessorExecutionContext.java b/src/main/java/blue/language/processor/ProcessorExecutionContext.java index 912e25b..5cb2b81 100644 --- a/src/main/java/blue/language/processor/ProcessorExecutionContext.java +++ b/src/main/java/blue/language/processor/ProcessorExecutionContext.java @@ -307,12 +307,12 @@ public FrozenNode resolvedFrozenAt(String absolutePointer) { public WorkingDocument newWorkingDocument() { ensureOpen(); - return runtime().workingDocument(scopePath); + return runtime().workingDocument(scopePath, PatchSource.CUSTOM_PROCESSOR); } public WorkingDocument newWorkingDocument(String originScope) { ensureOpen(); - return runtime().workingDocument(originScope); + return runtime().workingDocument(originScope, PatchSource.CUSTOM_PROCESSOR); } public boolean documentContains(String absolutePointer) { diff --git a/src/main/java/blue/language/processor/ProcessorMarkerFactory.java b/src/main/java/blue/language/processor/ProcessorMarkerFactory.java new file mode 100644 index 0000000..0f4202b --- /dev/null +++ b/src/main/java/blue/language/processor/ProcessorMarkerFactory.java @@ -0,0 +1,21 @@ +package blue.language.processor; + +import blue.language.model.Node; +import blue.language.processor.registry.RuntimeBlueIds; +import blue.language.snapshot.FrozenNode; + +/** + * Processor-owned marker values authored in canonical frozen form before they + * cross the runtime patch boundary. + */ +final class ProcessorMarkerFactory { + + private ProcessorMarkerFactory() { + } + + static FrozenNode initialized(String documentId) { + return FrozenNode.fromNode(new Node() + .type(new Node().blueId(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER)) + .properties("documentId", new Node().value(documentId))); + } +} diff --git a/src/main/java/blue/language/processor/ScopeExecutor.java b/src/main/java/blue/language/processor/ScopeExecutor.java index 844e571..4b6c8c1 100644 --- a/src/main/java/blue/language/processor/ScopeExecutor.java +++ b/src/main/java/blue/language/processor/ScopeExecutor.java @@ -5,10 +5,10 @@ import blue.language.processor.model.ChannelContract; import blue.language.processor.model.DocumentUpdateChannel; import blue.language.processor.model.EmbeddedNodeChannel; +import blue.language.processor.model.FrozenJsonPatch; import blue.language.processor.model.JsonPatch; import blue.language.processor.model.LifecycleChannel; import blue.language.processor.model.TriggeredEventChannel; -import blue.language.processor.registry.RuntimeBlueIds; import blue.language.processor.util.ProcessorContractConstants; import blue.language.processor.util.ProcessorPointerConstants; import blue.language.processor.util.PointerUtils; @@ -58,7 +58,7 @@ private void initializeScope(String scopePath, boolean chargeScopeEntry, boolean String normalizedScope = ProcessorEngine.normalizeScope(scopePath); Set processedEmbedded = new LinkedHashSet<>(); ContractBundle bundle = null; - Node preInitSnapshot = null; + FrozenNode preInitSnapshot = null; ScopeRuntimeContext scopeContext = runtime.scope(normalizedScope); if ("/".equals(normalizedScope)) { runtime.setScopeEmbeddedDepth(normalizedScope, 0); @@ -97,7 +97,7 @@ private void initializeScope(String scopePath, boolean chargeScopeEntry, boolean if (preInitSnapshot == null) { FrozenNode canonicalScopeNode = runtime.canonicalFrozenAt(normalizedScope); - preInitSnapshot = (canonicalScopeNode != null ? canonicalScopeNode : scopeNode).toNode(); + preInitSnapshot = canonicalScopeNode != null ? canonicalScopeNode : scopeNode; } long loadStart = System.nanoTime(); @@ -157,7 +157,7 @@ private void initializeScope(String scopePath, boolean chargeScopeEntry, boolean } runtime.chargeInitialization(); - String documentId = BlueIdCalculator.calculateUncheckedBlueId(preInitSnapshot != null ? preInitSnapshot : new Node()); + String documentId = initializationDocumentId(preInitSnapshot); Node lifecycleEvent = ProcessorEngine.createLifecycleInitiatedEvent(documentId); ProcessorExecutionContext context = execution.createContext(normalizedScope, bundle, lifecycleEvent, true); deliverLifecycle(normalizedScope, bundle, lifecycleEvent, false); @@ -625,12 +625,30 @@ private String eventKind(Node event) { return value != null ? String.valueOf(value) : null; } + /** + * Compatibility invariant: + * /contracts/initialized/documentId is the historical unchecked identity of + * the selected pre-initialization node. It is not the canonical state BlueId. + * These values can differ for payload-only lists. Do not replace this + * calculation with FrozenNode.blueId(). + */ + private String initializationDocumentId(FrozenNode preInitializationSnapshot) { + ProcessingMetricsSink metrics = owner.metricsSink(); + metrics.incrementInitializationDocumentIdUncheckedCalculations(); + Node materialized; + if (preInitializationSnapshot != null) { + metrics.incrementInitializationDocumentIdNodeMaterializations(); + materialized = preInitializationSnapshot.toNode(); + } else { + materialized = new Node(); + } + return BlueIdCalculator.calculateUncheckedBlueId(materialized); + } + private void addInitializationMarker(ProcessorExecutionContext context, String documentId) { - Node marker = new Node() - .type(new Node().blueId(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER)) - .properties("documentId", new Node().value(documentId)); + FrozenNode marker = ProcessorMarkerFactory.initialized(documentId); String pointer = context.resolvePointer(ProcessorPointerConstants.RELATIVE_INITIALIZED); - context.applyPatch(JsonPatch.add(pointer, marker)); + context.applyFrozenPatch(FrozenJsonPatch.add(pointer, marker)); context.applyBufferedEffects(); } @@ -640,8 +658,7 @@ private void initializeCurrentScopeIfNeeded(String scopePath, ContractBundle bun return; } FrozenNode canonicalScopeNode = runtime.canonicalFrozenAt(normalizedScope); - String documentId = BlueIdCalculator.calculateUncheckedBlueId( - canonicalScopeNode != null ? canonicalScopeNode.toNode() : new Node()); + String documentId = initializationDocumentId(canonicalScopeNode); runtime.chargeInitialization(); Node lifecycleEvent = ProcessorEngine.createLifecycleInitiatedEvent(documentId); ProcessorExecutionContext context = execution.createContext(normalizedScope, bundle, lifecycleEvent, true); diff --git a/src/main/java/blue/language/processor/WorkingDocument.java b/src/main/java/blue/language/processor/WorkingDocument.java index 191a51a..cea5a37 100644 --- a/src/main/java/blue/language/processor/WorkingDocument.java +++ b/src/main/java/blue/language/processor/WorkingDocument.java @@ -50,6 +50,7 @@ public void recordAfterNodeMaterialization() { private final ConformanceEngine conformanceEngine; private final ConformancePlannerOverride conformancePlannerOverride; private final boolean exactReplacement; + private final PatchSource mutablePatchSource; private final ProcessingMetricsSink metrics; private ProcessingSnapshotManager workingSequenceManager; private ResolvedSnapshot snapshot; @@ -64,6 +65,7 @@ public void recordAfterNodeMaterialization() { ResolvedSnapshot snapshot, boolean materializedFallback, boolean exactReplacement, + PatchSource mutablePatchSource, ProcessingMetricsSink metrics) { this.originScope = PointerUtils.normalizeScope(originScope); this.canonicalRoot = Objects.requireNonNull(canonicalRoot, "canonicalRoot"); @@ -74,6 +76,9 @@ public void recordAfterNodeMaterialization() { this.conformanceEngine = conformanceEngine; this.conformancePlannerOverride = conformancePlannerOverride; this.exactReplacement = exactReplacement; + this.mutablePatchSource = mutablePatchSource != null + ? mutablePatchSource + : PatchSource.UNKNOWN_INTERNAL; this.metrics = metrics != null ? metrics : ProcessingMetricsSink.NOOP; this.workingSequenceManager = snapshotManager != null ? snapshotManager.transientSequence() @@ -106,12 +111,12 @@ public WorkingDocument applyPatch(JsonPatch patch) { } public WorkingDocument applyPatches(List patches) { - applyPatchInputs(PatchInput.mutableList(patches), false); + applyPatchInputs(PatchInput.mutableList(patches, mutablePatchSource), false); return this; } public Preview previewAndApplyPatches(List patches) { - return applyPatchInputs(PatchInput.mutableList(patches), true); + return applyPatchInputs(PatchInput.mutableList(patches, mutablePatchSource), true); } public WorkingDocument applyFrozenPatch(FrozenJsonPatch patch) { diff --git a/src/main/java/blue/language/processor/conformance/ScriptedContractsRuntime.java b/src/main/java/blue/language/processor/conformance/ScriptedContractsRuntime.java index 000bd70..1beaeda 100644 --- a/src/main/java/blue/language/processor/conformance/ScriptedContractsRuntime.java +++ b/src/main/java/blue/language/processor/conformance/ScriptedContractsRuntime.java @@ -8,6 +8,7 @@ import blue.language.processor.ConformancePlannerOverride; import blue.language.processor.DocumentProcessingRuntime; import blue.language.processor.HandlerMatchContext; +import blue.language.processor.PatchSource; import blue.language.processor.ProcessorExecutionContext; import blue.language.processor.ProcessorErrorCategory; import blue.language.processor.ProcessorFailureException; @@ -235,12 +236,15 @@ public void afterBridgeEmission(String scopePath, DocumentProcessingRuntime runt JsonPatch patch = runtime.nodeAt(path) == null ? JsonPatch.add(path, channel) : JsonPatch.replace(path, channel); - runtime.applyPatches(scopePath, Collections.singletonList(patch)); + runtime.applyPatches(scopePath, Collections.singletonList(patch), + PatchSource.CONFORMANCE_FIXTURE); } if (mutation.removeChannelKey != null) { String path = contractPath(scopePath, mutation.removeChannelKey); if (runtime.nodeAt(path) != null) { - runtime.applyPatches(scopePath, Collections.singletonList(JsonPatch.remove(path))); + runtime.applyPatches(scopePath, + Collections.singletonList(JsonPatch.remove(path)), + PatchSource.CONFORMANCE_FIXTURE); } } } diff --git a/src/main/java/blue/language/snapshot/ResolvedSnapshot.java b/src/main/java/blue/language/snapshot/ResolvedSnapshot.java index c94dc2f..ef58443 100644 --- a/src/main/java/blue/language/snapshot/ResolvedSnapshot.java +++ b/src/main/java/blue/language/snapshot/ResolvedSnapshot.java @@ -67,6 +67,18 @@ public static ResolvedSnapshot fromResolverResult(SnapshotResolution resolution) resolution.verifiedReferenceResolution()); } + public ResolvedSnapshot toStrictBlueIdValidatedCanonical() { + if (canonicalRoot.isStrictCanonical() + && canonicalRoot.isStrictBlueIdValidation()) { + return this; + } + FrozenNode strictCanonicalRoot = FrozenNode.fromNode(canonicalRoot.toNode()); + return new ResolvedSnapshot(strictCanonicalRoot, + resolvedRoot, + strictCanonicalRoot.blueId(), + verifiedReferenceResolution); + } + public Node canonicalRoot() { return canonicalRoot.toNode(); } diff --git a/src/test/java/blue/language/ProcessingDocumentStateInvariantFailFirstTest.java b/src/test/java/blue/language/ProcessingDocumentStateInvariantFailFirstTest.java index d6bdea2..8ca1903 100644 --- a/src/test/java/blue/language/ProcessingDocumentStateInvariantFailFirstTest.java +++ b/src/test/java/blue/language/ProcessingDocumentStateInvariantFailFirstTest.java @@ -9,6 +9,7 @@ import blue.language.processor.model.JsonPatch; import blue.language.processor.registry.RuntimeBlueIds; import blue.language.snapshot.ResolvedSnapshot; +import blue.language.utils.BlueIdCalculator; import blue.language.utils.MergeReverser; import blue.language.utils.NodeToMapListOrValue; import org.junit.jupiter.api.Test; @@ -187,7 +188,8 @@ private static Observation observe(AuditFixture fixture, private static Node expectedInitializedSelected(AuditFixture fixture, Node selectedBefore) { Node expected = selectedBefore.clone(); Blue identityBlue = fixture.newBlue(new AtomicInteger()); - String preInitializationIdentity = identityBlue.resolveToSnapshot(selectedBefore.clone()).blueId(); + String preInitializationIdentity = BlueIdCalculator.calculateUncheckedBlueId( + identityBlue.resolveToSnapshot(selectedBefore.clone()).frozenCanonicalRoot().toNode()); Node marker = new Node() .type(reference(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER)) .properties("documentId", text(preInitializationIdentity)); diff --git a/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java b/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java index b77d153..1f79207 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java @@ -1,20 +1,33 @@ package blue.language.processor; import blue.language.Blue; -import blue.language.model.Node; +import blue.language.model.TypeBlueId; +import blue.language.provider.BasicNodeProvider; import blue.language.processor.contracts.RemovePropertyContractProcessor; +import blue.language.model.Node; import blue.language.processor.contracts.SetPropertyContractProcessor; +import blue.language.processor.model.FrozenJsonPatch; +import blue.language.processor.model.HandlerContract; import blue.language.processor.registry.RuntimeBlueIds; +import blue.language.snapshot.FrozenNode; +import blue.language.snapshot.ResolvedSnapshot; import blue.language.utils.BlueIdCalculator; +import blue.language.utils.Properties; import org.junit.jupiter.api.Test; import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.*; class DocumentProcessorInitializationTest { + private static final String CAPTURE_LIFECYCLE_DOCUMENT_ID_BLUE_ID = + "n1dTwJjYLh4mvRbrBiQ56fLj8skq8pGo8eyPhmTtBJH"; + @Test void initializeDocumentEmitsRootLifecycleEvent() { Blue blue = ProcessorTestSupport.blue(); @@ -42,6 +55,361 @@ void initializeDocumentEmitsRootLifecycleEvent() { assertEquals(markerDocId.getValue(), lifecycleDocId.getValue()); } + @Test + void initializationMarkerUsesFrozenPatchAndLocalProcessorStateResolution() { + Blue blue = ProcessorTestSupport.blue(); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + blue.getDocumentProcessor().processingMetricsSink(metrics); + Node original = blue.yamlToNode("name: Minimal Doc\n" + + "contracts: {}\n"); + ResolvedSnapshot preInitialization = blue.resolveToSnapshot(original.clone()); + String oldMaterializedDocumentId = BlueIdCalculator.calculateUncheckedBlueId( + preInitialization.frozenCanonicalRoot().toNode()); + + DocumentProcessingResult result = blue.initializeDocument(original); + + assertFalse(result.capabilityFailure(), result.failureReason()); + Node initialized = result.document() + .getContracts() + .getProperties() + .get("initialized"); + assertEquals(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER, + initialized.getType().getBlueId()); + assertEquals(oldMaterializedDocumentId, + initialized.getProperties().get("documentId").getValue()); + assertEquals(lifecycleDocumentId(result.triggeredEvents().get(0)), + initialized.getProperties().get("documentId").getValue()); + + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(0L, snapshot.counter("mutablePatchValuesFrozen"), snapshot.toString()); + assertEquals(0L, snapshot.counter( + "mutablePatchValuesFrozenBySource.PROCESSOR_INITIALIZATION_MARKER"), snapshot.toString()); + assertEquals(1L, snapshot.counter("frozenPatchValuesAccepted"), snapshot.toString()); + assertEquals(1L, snapshot.counter("patchImpactProcessorManagedState"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorManagedMarkerPatches"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorManagedMarkerIncrementalResolutions"), snapshot.toString()); + assertEquals(0L, snapshot.counter("fullSnapshotFallbackReason.CONTRACTS_CHANGED"), snapshot.toString()); + assertEquals(1L, snapshot.counter("initializationDocumentIdUncheckedCalculations"), snapshot.toString()); + assertEquals(1L, snapshot.counter("initializationDocumentIdNodeMaterializations"), snapshot.toString()); + } + + @Test + void snapshotBackedInitializationMarkerUsesIncrementalProcessorStateResolution() { + Blue blue = ProcessorTestSupport.blue(); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + blue.getDocumentProcessor().processingMetricsSink(metrics); + ResolvedSnapshot preInitialization = blue.resolveToSnapshot(blue.yamlToNode( + "name: Snapshot Minimal Doc\n" + + "contracts: {}\n")); + + DocumentProcessingResult result = blue.initializeDocument(preInitialization); + + assertFalse(result.capabilityFailure(), result.failureReason()); + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(0L, snapshot.counter("mutablePatchValuesFrozen"), snapshot.toString()); + assertEquals(1L, snapshot.counter("frozenPatchValuesAccepted"), snapshot.toString()); + assertEquals(1L, snapshot.counter("patchImpactProcessorManagedState"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorManagedMarkerPatches"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorManagedMarkerIncrementalResolutions"), snapshot.toString()); + assertEquals(1L, snapshot.counter("incrementalSnapshotResolutions"), snapshot.toString()); + assertEquals(0L, snapshot.counter("fullSnapshotFallbacks"), snapshot.toString()); + assertEquals(1L, snapshot.counter("initializationDocumentIdUncheckedCalculations"), snapshot.toString()); + assertEquals(1L, snapshot.counter("initializationDocumentIdNodeMaterializations"), snapshot.toString()); + } + + @Test + void initializationDocumentIdUsesUncheckedIdentityWhenCanonicalBlueIdDiffers() { + Blue blue = ProcessorTestSupport.blue(); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + blue.getDocumentProcessor().processingMetricsSink(metrics); + Node original = blue.yamlToNode( + "name: Nested List Divergence\n" + + "bex:\n" + + " do:\n" + + " - - 1\n" + + " - 2\n" + + "contracts: {}\n"); + ResolvedSnapshot preInitialization = blue.resolveToSnapshot(original.clone()); + String canonical = preInitialization.frozenCanonicalRoot().blueId(); + String unchecked = uncheckedInitializationId(preInitialization.frozenCanonicalRoot()); + assertNotEquals(canonical, unchecked, + "canonical=" + canonical + ", unchecked=" + unchecked); + + DocumentProcessingResult result = blue.initializeDocument(original); + + assertFalse(result.capabilityFailure(), result.failureReason()); + String markerDocumentId = markerDocumentId(result.document(), "/"); + assertEquals(unchecked, markerDocumentId, + "canonical=" + canonical + ", unchecked=" + unchecked); + assertEquals(unchecked, lifecycleDocumentId(result.triggeredEvents().get(0))); + assertNotEquals(canonical, markerDocumentId); + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(0L, snapshot.counter("mutablePatchValuesFrozen"), snapshot.toString()); + assertEquals(1L, snapshot.counter("frozenPatchValuesAccepted"), snapshot.toString()); + assertEquals(1L, snapshot.counter("patchImpactProcessorManagedState"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorManagedMarkerPatches"), snapshot.toString()); + assertEquals(0L, snapshot.counter("fullSnapshotFallbackReason.CONTRACTS_CHANGED"), snapshot.toString()); + } + + @Test + void initializationDocumentIdUsesMaterializedUncheckedOracleAcrossIdentityShapes() { + Blue blue = ProcessorTestSupport.blue(); + List fixtures = new ArrayList<>(Arrays.asList( + "name: Simple Object Shape\n" + + "status: draft\n" + + "contracts: {}\n", + "name: Simple Scalar Fields Shape\n" + + "count: 7\n" + + "active: true\n" + + "label: text\n" + + "contracts: {}\n", + "name: Payload Only List Shape\n" + + "payload:\n" + + " - alpha\n" + + " - beta\n" + + "contracts: {}\n", + "name: Nested Payload Only List Shape\n" + + "payload:\n" + + " - - alpha\n" + + " - beta\n" + + " - gamma\n" + + "contracts: {}\n", + "name: Metadata Bearing List Shape\n" + + "payload:\n" + + " name: Metadata Bearing List\n" + + " type:\n" + + " blueId: " + Properties.LIST_TYPE_BLUE_ID + "\n" + + " items:\n" + + " - alpha\n" + + " - beta\n" + + "contracts: {}\n", + "name: Object Elements List Shape\n" + + "rows:\n" + + " - id: one\n" + + " amount: 1\n" + + " - id: two\n" + + " amount: 2\n" + + "contracts: {}\n", + "name: Scalar Elements List Shape\n" + + "scalars: [one, 2, true]\n" + + "contracts: {}\n", + "name: Typed Scalar Elements List Shape\n" + + "typedScalars:\n" + + " items:\n" + + " - type: Integer\n" + + " value: 1\n" + + " - type: Text\n" + + " value: two\n" + + "contracts: {}\n", + "name: Empty List Control Shape\n" + + "emptyControl:\n" + + " items:\n" + + " - $empty: true\n" + + " - value: tail\n" + + "contracts: {}\n", + "name: BEX Operator Map Shape\n" + + "bex:\n" + + " do:\n" + + " - \"$get\": [/invoice/status]\n" + + " - \"$literal\":\n" + + " - [accepted, pending]\n" + + "contracts: {}\n", + "name: Contracts Containing Lists Shape\n" + + "contracts:\n" + + " lifecycleWithList:\n" + + " type:\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + + " values:\n" + + " - [a, b]\n" + + " - {kind: c}\n", + "name: Embedded Documents Containing Lists Shape\n" + + "child:\n" + + " name: Embedded List Child\n" + + " values:\n" + + " - [a, b]\n" + + " contracts: {}\n" + + "contracts:\n" + + " embedded:\n" + + " type:\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + + " paths:\n" + + " - /child\n")); + + for (String yaml : fixtures) { + assertInitializationUsesUncheckedIdentityAndReloads(blue, yaml); + } + + BasicNodeProvider provider = new BasicNodeProvider(); + Blue previousBlue = ProcessorTestSupport.blue(provider); + Node previous = previousBlue.yamlToNode( + "items:\n" + + " - previous-a\n" + + " - previous-b\n"); + String previousBlueId = BlueIdCalculator.calculateBlueId(previous.getItems()); + provider.addListAndItsItems(previous.getItems()); + assertInitializationUsesUncheckedIdentityAndReloads(previousBlue, + "name: Previous List Control Shape\n" + + "history:\n" + + " type:\n" + + " blueId: " + Properties.LIST_TYPE_BLUE_ID + "\n" + + " mergePolicy: append-only\n" + + " items:\n" + + " - $previous:\n" + + " blueId: " + previousBlueId + "\n" + + " - after\n" + + "contracts: {}\n"); + } + + @Test + void bexShapedNestedListsUseHistoricalUncheckedInitializationIdentity() { + Blue blue = ProcessorTestSupport.blue(); + List fixtures = Arrays.asList( + "name: Compute Do Payload List\n" + + "compute:\n" + + " do:\n" + + " - - 1\n" + + " - 2\n" + + "contracts: {}\n", + "name: Nested Operand Lists\n" + + "compute:\n" + + " expr:\n" + + " - \"$add\":\n" + + " - [1, 2]\n" + + " - [3, [4, 5]]\n" + + "contracts: {}\n", + "name: Operation Maps In List\n" + + "compute:\n" + + " do:\n" + + " - \"$set\": [/status, confirmed]\n" + + " - \"$emit\": [{kind: done}]\n" + + "contracts: {}\n", + "name: List Containing Payload List\n" + + "operands:\n" + + " - before\n" + + " - - nested\n" + + " - [operand]\n" + + "contracts: {}\n", + "name: Mixed Operands\n" + + "operands:\n" + + " - 1\n" + + " - {kind: object}\n" + + " - [a, {b: c}, [d]]\n" + + "contracts: {}\n", + "name: Empty Operand List\n" + + "operands: []\n" + + "contracts: {}\n", + "name: Single Element List\n" + + "operands:\n" + + " - [only]\n" + + "contracts: {}\n", + "name: Multiple Nested Levels\n" + + "operands:\n" + + " - - - - deep\n" + + "contracts: {}\n"); + + for (String yaml : fixtures) { + assertInitializationUsesUncheckedIdentityAndReloads(blue, yaml); + } + } + + @Test + void embeddedScopeInitializationDocumentIdsUseTheirOwnUncheckedPreInitializationIdentity() { + Blue blue = ProcessorTestSupport.blue(); + blue.registerExternalContractType(CAPTURE_LIFECYCLE_DOCUMENT_ID_BLUE_ID, + new Node().name("CaptureLifecycleDocumentId"), + new CaptureLifecycleDocumentIdProcessor()); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + blue.getDocumentProcessor().processingMetricsSink(metrics); + Node original = blue.yamlToNode( + "name: Embedded Nested List\n" + + "child:\n" + + " name: Child Nested List\n" + + " payload:\n" + + " do:\n" + + " - - 1\n" + + " - [2, 3]\n" + + " contracts:\n" + + " lifecycle:\n" + + " type:\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + + " captureChildId:\n" + + " channel: lifecycle\n" + + " type:\n" + + " blueId: " + CAPTURE_LIFECYCLE_DOCUMENT_ID_BLUE_ID + "\n" + + " propertyKey: /childLifecycleDocumentId\n" + + "contracts:\n" + + " embedded:\n" + + " type:\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + + " paths:\n" + + " - /child\n" + + " lifecycle:\n" + + " type:\n" + + " blueId: 2DXGQUiQBQ6CT89jwAsTAXaEPhLgiSXhKCGh9Q7Hv3MQ\n" + + " captureRootId:\n" + + " channel: lifecycle\n" + + " type:\n" + + " blueId: " + CAPTURE_LIFECYCLE_DOCUMENT_ID_BLUE_ID + "\n" + + " propertyKey: /rootLifecycleDocumentId\n"); + ResolvedSnapshot preInitialization = blue.getDocumentProcessor() + .snapshotManager() + .fromDocument(original.clone()); + FrozenNode rootBefore = preInitialization.frozenCanonicalRoot(); + FrozenNode childBefore = rootBefore.property("child"); + String rootUnchecked = uncheckedInitializationId(rootBefore); + String childUnchecked = uncheckedInitializationId(childBefore); + assertNotEquals(childBefore.blueId(), childUnchecked, + "canonical=" + childBefore.blueId() + ", unchecked=" + childUnchecked); + + DocumentProcessingResult result = blue.initializeDocument(original); + + assertFalse(result.capabilityFailure(), result.failureReason()); + Node initialized = result.document(); + assertEquals(rootUnchecked, markerDocumentId(initialized, "/")); + assertEquals(childUnchecked, markerDocumentId(initialized, "/child")); + assertEquals(rootUnchecked, initialized.getAsText("/rootLifecycleDocumentId")); + assertEquals(childUnchecked, initialized.getAsText("/child/childLifecycleDocumentId")); + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(0L, snapshot.counter("mutablePatchValuesFrozen"), snapshot.toString()); + assertEquals(2L, snapshot.counter("patchImpactProcessorManagedState"), snapshot.toString()); + assertEquals(2L, snapshot.counter("processorManagedMarkerPatches"), snapshot.toString()); + assertEquals(0L, snapshot.counter("fullSnapshotFallbackReason.CONTRACTS_CHANGED"), snapshot.toString()); + assertEquals(2L, snapshot.counter("initializationDocumentIdUncheckedCalculations"), snapshot.toString()); + assertEquals(2L, snapshot.counter("initializationDocumentIdNodeMaterializations"), snapshot.toString()); + } + + @Test + void initializeCurrentScopeIfNeededUsesUncheckedInitializationIdentityBeforeFatalBoundaryStop() { + Blue blue = ProcessorTestSupport.blue(); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + blue.getDocumentProcessor().processingMetricsSink(metrics); + Node original = blue.yamlToNode( + "name: Non Object Embedded Child\n" + + "payload:\n" + + " - - 1\n" + + " - 2\n" + + "child: scalar\n" + + "contracts:\n" + + " embedded:\n" + + " type:\n" + + " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + + " paths:\n" + + " - /child\n"); + ResolvedSnapshot preInitialization = blue.resolveToSnapshot(original.clone()); + String unchecked = uncheckedInitializationId(preInitialization.frozenCanonicalRoot()); + + DocumentProcessingResult result = blue.initializeDocument(original); + + assertFalse(result.capabilityFailure(), result.failureReason()); + assertNotNull(result.failureReason()); + assertEquals(unchecked, markerDocumentId(result.document(), "/")); + assertEquals(unchecked, lifecycleDocumentId(result.triggeredEvents().get(0))); + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(0L, snapshot.counter("mutablePatchValuesFrozen"), snapshot.toString()); + assertEquals(1L, snapshot.counter("patchImpactProcessorManagedState"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorManagedMarkerPatches"), snapshot.toString()); + } + @Test void initializesDocumentAndExecutesHandlersInOrder() { String yaml = "name: Sample Doc\n" + @@ -425,4 +793,85 @@ void childLifecycleIsBridgedToParent() { assertNotNull(childLifecycle, "Parent should observe child lifecycle through Embedded Node channel"); assertEquals(new BigInteger("1"), childLifecycle.getValue()); } + + private static void assertInitializationUsesUncheckedIdentityAndReloads(Blue blue, String yaml) { + Node original = blue.yamlToNode(yaml); + ResolvedSnapshot preInitialization = blue.resolveToSnapshot(original.clone()); + String unchecked = uncheckedInitializationId(preInitialization.frozenCanonicalRoot()); + + DocumentProcessingResult result = blue.initializeDocument(original); + + assertFalse(result.capabilityFailure(), result.failureReason()); + assertEquals(unchecked, markerDocumentId(result.document(), "/"), yaml); + assertTrue(hasLifecycleDocumentId(result, unchecked), yaml); + ResolvedSnapshot finalSnapshot = blue.resolveToSnapshot(result.document().clone()); + ResolvedSnapshot reloaded = blue.resolveToSnapshot( + blue.jsonToNode(blue.nodeToJson(result.document()))); + assertEquals(finalSnapshot.blueId(), reloaded.blueId(), yaml); + assertEquals(blue.nodeToJson(finalSnapshot.canonicalRoot()), + blue.nodeToJson(reloaded.canonicalRoot()), yaml); + assertEquals(blue.nodeToJson(finalSnapshot.resolvedRoot()), + blue.nodeToJson(reloaded.resolvedRoot()), yaml); + } + + private static String uncheckedInitializationId(FrozenNode node) { + return BlueIdCalculator.calculateUncheckedBlueId(node.toNode()); + } + + private static String markerDocumentId(Node document, String scope) { + String prefix = "/".equals(scope) ? "" : scope; + return document.getAsText(prefix + "/contracts/initialized/documentId"); + } + + private static String lifecycleDocumentId(Node event) { + Node documentId = event != null && event.getProperties() != null + ? event.getProperties().get("documentId") + : null; + Object value = documentId != null ? documentId.getValue() : null; + return value != null ? String.valueOf(value) : null; + } + + private static boolean hasLifecycleDocumentId(DocumentProcessingResult result, String documentId) { + for (Node event : result.triggeredEvents()) { + if (documentId.equals(lifecycleDocumentId(event))) { + return true; + } + } + return false; + } + + @TypeBlueId(CAPTURE_LIFECYCLE_DOCUMENT_ID_BLUE_ID) + public static final class CaptureLifecycleDocumentId extends HandlerContract { + private String propertyKey; + + public String getPropertyKey() { + return propertyKey; + } + + public void setPropertyKey(String propertyKey) { + this.propertyKey = propertyKey; + } + } + + private static final class CaptureLifecycleDocumentIdProcessor + implements HandlerProcessor { + @Override + public Class contractType() { + return CaptureLifecycleDocumentId.class; + } + + @Override + public void execute(CaptureLifecycleDocumentId contract, ProcessorExecutionContext context) { + String documentId = lifecycleDocumentId(context.event()); + if (documentId == null) { + throw new IllegalStateException("Lifecycle event missing documentId"); + } + String propertyKey = contract.getPropertyKey() != null + ? contract.getPropertyKey() + : "/capturedDocumentId"; + context.applyFrozenPatch(FrozenJsonPatch.add( + context.resolvePointer(propertyKey), + FrozenNode.fromNode(new Node().value(documentId)))); + } + } } diff --git a/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java b/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java index 1a17b66..95d027c 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorSnapshotTransactionTest.java @@ -63,6 +63,28 @@ void workingDocumentAppliesPatchWithoutMutatingRuntime() { assertSnapshotConsistent(working.snapshot()); } + @Test + void workingDocumentMutablePatchAttributionUsesFixedCallerSource() { + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + Node document = YAML_MAPPER.readValue("x: 1\nother: keep", Node.class); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime(document, null, null, metrics); + + try (WorkingDocument externalWorking = runtime.workingDocument("/")) { + externalWorking.applyPatch(JsonPatch.replace("/x", new Node().value(2))); + } + try (WorkingDocument processorWorking = + runtime.workingDocument("/", PatchSource.CUSTOM_PROCESSOR)) { + processorWorking.applyPatch(JsonPatch.replace("/x", new Node().value(3))); + } + + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(2L, snapshot.counter("mutablePatchValuesFrozen"), snapshot.toString()); + assertEquals(1L, snapshot.counter( + "mutablePatchValuesFrozenBySource.LEGACY_PUBLIC_API"), snapshot.toString()); + assertEquals(1L, snapshot.counter( + "mutablePatchValuesFrozenBySource.CUSTOM_PROCESSOR"), snapshot.toString()); + } + @Test void precomputedWorkingDocumentPreviewCommitsWithoutReplanning() { CountingSnapshotManager manager = new CountingSnapshotManager(); diff --git a/src/test/java/blue/language/processor/FrozenJsonPatchApiTest.java b/src/test/java/blue/language/processor/FrozenJsonPatchApiTest.java index b7c69aa..6386a04 100644 --- a/src/test/java/blue/language/processor/FrozenJsonPatchApiTest.java +++ b/src/test/java/blue/language/processor/FrozenJsonPatchApiTest.java @@ -402,6 +402,7 @@ private WorkingDocument workingDocument(ResolvedSnapshot snapshot) { snapshot, false, true, + PatchSource.LEGACY_PUBLIC_API, ProcessingMetricsSink.NOOP); } diff --git a/src/test/java/blue/language/processor/PatchImpactIncrementalResolutionTest.java b/src/test/java/blue/language/processor/PatchImpactIncrementalResolutionTest.java index 79ce1f3..5b85c32 100644 --- a/src/test/java/blue/language/processor/PatchImpactIncrementalResolutionTest.java +++ b/src/test/java/blue/language/processor/PatchImpactIncrementalResolutionTest.java @@ -3,6 +3,8 @@ import blue.language.Blue; import blue.language.NodeProvider; import blue.language.conformance.ConformanceEngine; +import blue.language.merge.IncrementalMergingProcessorCapability; +import blue.language.merge.IncrementalValueResolutionRequest; import blue.language.merge.MergingProcessor; import blue.language.merge.NodeResolver; import blue.language.model.Node; @@ -18,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static blue.language.utils.Properties.TEXT_TYPE_BLUE_ID; @@ -309,6 +312,96 @@ void customMergingProcessorCannotOptIntoBuiltInIncrementalProof() { "fullSnapshotFallbackReason.CUSTOM_MERGING_PROCESSOR")); } + @Test + void requestAwareTransparentWrapperAllowsIncrementalResolution() { + Fixture fixture = Fixture.withBasicStatusTypeContribution(); + RequestAwareWrapper wrapper = new RequestAwareWrapper( + fixture.blue.getMergingProcessor(), null); + Blue wrappedBlue = new Blue(fixture.provider, wrapper); + ResolvedSnapshot base = snapshot(wrappedBlue, fixture); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime( + base, + wrappedBlue.conformanceEngine(), + wrappedBlue.getDocumentProcessor().snapshotManager(), + metrics); + FullOracleSnapshotManager oracleManager = new FullOracleSnapshotManager(wrappedBlue, false); + DocumentProcessingRuntime oracle = new DocumentProcessingRuntime( + base, + wrappedBlue.conformanceEngine(), + oracleManager); + + JsonPatch patch = JsonPatch.replace("/status", new Node().value("confirmed")); + runtime.applyPatch("/", patch); + oracle.applyPatch("/", patch); + + assertSnapshotEquals(wrappedBlue, oracle.snapshot(), runtime.snapshot()); + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(1L, snapshot.counter("incrementalSnapshotResolutions"), snapshot.toString()); + assertEquals(0L, snapshot.counter("fullSnapshotFallbacks"), snapshot.toString()); + assertEquals(1L, snapshot.counter("incrementalMergerCapabilityRequests"), snapshot.toString()); + assertEquals(1L, snapshot.counter("incrementalMergerCapabilityAllowed"), snapshot.toString()); + assertTrue(wrapper.requestCalls >= 2, + "both conformance and snapshot manager should consult the same request-aware capability"); + } + + @Test + void requestAwareGuardedWrapperDeniesProtectedRegion() { + Fixture fixture = Fixture.withBasicStatusTypeContribution(); + RequestAwareWrapper wrapper = new RequestAwareWrapper( + fixture.blue.getMergingProcessor(), "/status"); + Blue wrappedBlue = new Blue(fixture.provider, wrapper); + ResolvedSnapshot base = snapshot(wrappedBlue, fixture); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + FullOracleSnapshotManager manager = new FullOracleSnapshotManager(wrappedBlue, true); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime( + base, + new ConformanceEngine(wrappedBlue.getNodeProvider(), wrapper), + manager, + metrics); + + runtime.applyPatch("/", JsonPatch.replace("/status", new Node().value("confirmed"))); + + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(1L, snapshot.counter("fullSnapshotFallbacks"), snapshot.toString()); + assertEquals(1L, snapshot.counter("fullSnapshotFallbackReason.CUSTOM_MERGING_PROCESSOR"), snapshot.toString()); + assertEquals(1L, snapshot.counter("incrementalMergerCapabilityRequests"), snapshot.toString()); + assertEquals(1L, snapshot.counter("incrementalMergerCapabilityDenied"), snapshot.toString()); + assertEquals(1L, snapshot.counter("incrementalMergerCapabilityDeniedByConformance"), snapshot.toString()); + assertEquals(0L, snapshot.counter("incrementalMergerCapabilityDeniedBySnapshotManager"), snapshot.toString()); + assertEquals(1, manager.fullResolutions); + } + + @Test + void dishonestCapabilityDemonstratesTruthfulWrapperContract() { + Fixture fixture = Fixture.withBasicStatusTypeContribution(); + DishonestWrapper wrapper = new DishonestWrapper(fixture.blue.getMergingProcessor()); + Blue wrappedBlue = new Blue(fixture.provider, wrapper); + ResolvedSnapshot base = snapshot(wrappedBlue, fixture); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + DocumentProcessingRuntime incremental = new DocumentProcessingRuntime( + base, + wrappedBlue.conformanceEngine(), + wrappedBlue.getDocumentProcessor().snapshotManager(), + metrics); + FullOracleSnapshotManager oracleManager = new FullOracleSnapshotManager(wrappedBlue, false); + DocumentProcessingRuntime oracle = new DocumentProcessingRuntime( + base, + wrappedBlue.conformanceEngine(), + oracleManager); + + JsonPatch patch = JsonPatch.replace("/status", new Node().value("confirmed")); + incremental.applyPatch("/", patch); + oracle.applyPatch("/", patch); + + assertEquals(1L, metrics.snapshot().counter("incrementalSnapshotResolutions")); + assertEquals(0L, metrics.snapshot().counter("fullSnapshotFallbacks")); + assertNotEquals(wrappedBlue.nodeToJson(oracle.snapshot().resolvedRoot()), + wrappedBlue.nodeToJson(incremental.snapshot().resolvedRoot()), + "Language trusts request-aware capabilities and does not run an expensive dishonesty oracle"); + } + + @Test void impactModelCarriesTypedBoundaryAndDependencyEvidence() { Fixture fixture = Fixture.withFixedStatusSubtype(); @@ -362,6 +455,12 @@ private static Node reference(String blueId) { return new Node().blueId(blueId); } + private static ResolvedSnapshot snapshot(Blue blue, Fixture fixture) { + return blue.resolveToSnapshot(new Node() + .type(reference(fixture.documentTypeId)) + .properties("status", new Node().value("draft"))); + } + private static final class Fixture { private final BasicNodeProvider provider; private final Blue blue; @@ -514,4 +613,117 @@ public void validateCompleted(Node node, boolean semanticallyPresent, String pat delegate.validateCompleted(node, semanticallyPresent, path); } } + + private static final class RequestAwareWrapper implements MergingProcessor, IncrementalMergingProcessorCapability { + private final MergingProcessor delegate; + private final String deniedPath; + private int requestCalls; + + private RequestAwareWrapper(MergingProcessor delegate, String deniedPath) { + this.delegate = delegate; + this.deniedPath = deniedPath; + } + + @Override + public void process(Node target, + Node source, + NodeProvider nodeProvider, + NodeResolver nodeResolver) { + delegate.process(target, source, nodeProvider, nodeResolver); + } + + @Override + public void postProcess(Node target, + Node source, + NodeProvider nodeProvider, + NodeResolver nodeResolver) { + delegate.postProcess(target, source, nodeProvider, nodeResolver); + } + + @Override + public boolean hasCompletedValidation(Node node) { + return delegate.hasCompletedValidation(node); + } + + @Override + public boolean requiresReferenceMaterialization(Node node) { + return delegate.requiresReferenceMaterialization(node); + } + + @Override + public void validateCompleted(Node node, boolean semanticallyPresent, String path) { + delegate.validateCompleted(node, semanticallyPresent, path); + } + + @Override + public boolean supportsIncrementalValueResolution() { + return false; + } + + @Override + public boolean supportsIncrementalValueResolution(IncrementalValueResolutionRequest request) { + requestCalls++; + if (deniedPath != null && deniedPath.equals(request.changedPath())) { + return false; + } + return delegate instanceof IncrementalMergingProcessorCapability + && ((IncrementalMergingProcessorCapability) delegate) + .supportsIncrementalValueResolution(request); + } + } + + private static final class DishonestWrapper implements MergingProcessor, IncrementalMergingProcessorCapability { + private final MergingProcessor delegate; + + private DishonestWrapper(MergingProcessor delegate) { + this.delegate = delegate; + } + + @Override + public void process(Node target, + Node source, + NodeProvider nodeProvider, + NodeResolver nodeResolver) { + delegate.process(target, source, nodeProvider, nodeResolver); + } + + @Override + public void postProcess(Node target, + Node source, + NodeProvider nodeProvider, + NodeResolver nodeResolver) { + delegate.postProcess(target, source, nodeProvider, nodeResolver); + if (target.getProperties() != null + && target.getProperties().get("status") != null + && target.getProperties().get("status").getValue() != null) { + target.properties("wrapperObservedStatus", + new Node().value(String.valueOf(target.getProperties().get("status").getValue()))); + } + } + + @Override + public boolean hasCompletedValidation(Node node) { + return delegate.hasCompletedValidation(node); + } + + @Override + public boolean requiresReferenceMaterialization(Node node) { + return delegate.requiresReferenceMaterialization(node); + } + + @Override + public void validateCompleted(Node node, boolean semanticallyPresent, String path) { + delegate.validateCompleted(node, semanticallyPresent, path); + } + + @Override + public boolean supportsIncrementalValueResolution() { + return true; + } + + @Override + public boolean supportsIncrementalValueResolution(IncrementalValueResolutionRequest request) { + return true; + } + } } diff --git a/src/test/java/blue/language/processor/PublishedSnapshotRoundTripTest.java b/src/test/java/blue/language/processor/PublishedSnapshotRoundTripTest.java new file mode 100644 index 0000000..740a886 --- /dev/null +++ b/src/test/java/blue/language/processor/PublishedSnapshotRoundTripTest.java @@ -0,0 +1,122 @@ +package blue.language.processor; + +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; +import blue.language.snapshot.ResolvedSnapshot; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PublishedSnapshotRoundTripTest { + + @Test + void snapshotInitializationPublishesStrictDurableCanonicalSnapshot() { + Blue blue = ProcessorTestSupport.blue(); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + blue.getDocumentProcessor().processingMetricsSink(metrics); + ResolvedSnapshot input = blue.resolveToSnapshot(blue.yamlToNode( + "name: Published Snapshot Initialization\n" + + "bex:\n" + + " do:\n" + + " - - 1\n" + + " - 2\n" + + "contracts: {}\n")); + + DocumentProcessingResult result = blue.initializeDocument(input); + + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertPublishableRoundTrip(blue, result); + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(1L, snapshot.counter("processorInputStrictCanonical"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorInputUncheckedCanonical"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorPublishedStrictCanonical"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublishedUncheckedCanonical"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublicationCanonicalizations"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublicationCanonicalMaterializations"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublicationStrictBlueIdCalculations"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublicationCanonicalizationNanos"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublicationIdentityMismatches"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorPublicationInvariantChecks"), snapshot.toString()); + } + + @Test + void snapshotProcessingPublishesStrictDurableCanonicalSnapshot() { + Blue blue = ProcessorTestSupport.blue(); + Node document = blue.yamlToNode( + "name: Published Snapshot Processing\n" + + "bex:\n" + + " do:\n" + + " - - 1\n" + + " - 2\n" + + "contracts: {}\n"); + DocumentProcessingResult initialized = blue.initializeDocument(document); + ResolvedSnapshot strictInitialized = blue.loadSnapshot(initialized.snapshot().canonicalRoot()); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + blue.getDocumentProcessor().processingMetricsSink(metrics); + + DocumentProcessingResult result = blue.processDocument(strictInitialized, + new Node().name("Ignored Published Snapshot Event")); + + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertPublishableRoundTrip(blue, result); + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(1L, snapshot.counter("processorInputStrictCanonical"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorInputUncheckedCanonical"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorPublishedStrictCanonical"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublishedUncheckedCanonical"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublicationCanonicalizations"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublicationCanonicalMaterializations"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublicationStrictBlueIdCalculations"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublicationCanonicalizationNanos"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublicationIdentityMismatches"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorPublicationInvariantChecks"), snapshot.toString()); + } + + @Test + void uncheckedSnapshotInputIsCanonicalizedBeforePublication() { + Blue blue = ProcessorTestSupport.blue(); + RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); + blue.getDocumentProcessor().processingMetricsSink(metrics); + Node document = blue.yamlToNode( + "name: Unchecked Published Snapshot Input\n" + + "bex:\n" + + " do:\n" + + " - - 1\n" + + " - 2\n" + + "contracts: {}\n"); + FrozenNode canonicalRoot = FrozenNode.fromUncheckedCanonicalNode(document); + ResolvedSnapshot input = new ResolvedSnapshot(canonicalRoot, + FrozenNode.fromResolvedNode(document), + canonicalRoot.blueId()); + + DocumentProcessingResult result = blue.initializeDocument(input); + + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertPublishableRoundTrip(blue, result); + ProcessingMetricsSnapshot snapshot = metrics.snapshot(); + assertEquals(0L, snapshot.counter("processorInputStrictCanonical"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorInputUncheckedCanonical"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorPublishedStrictCanonical"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublishedUncheckedCanonical"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorPublicationCanonicalizations"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorPublicationCanonicalMaterializations"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorPublicationStrictBlueIdCalculations"), snapshot.toString()); + assertTrue(snapshot.counter("processorPublicationCanonicalizationNanos") > 0L, snapshot.toString()); + assertEquals(0L, snapshot.counter("processorPublicationIdentityMismatches"), snapshot.toString()); + assertEquals(1L, snapshot.counter("processorPublicationInvariantChecks"), snapshot.toString()); + } + + private static void assertPublishableRoundTrip(Blue blue, DocumentProcessingResult result) { + assertNotNull(result.snapshot()); + assertEquals(result.blueId(), result.snapshot().blueId()); + assertEquals(result.snapshot().blueId(), result.snapshot().frozenCanonicalRoot().blueId()); + assertTrue(result.snapshot().frozenCanonicalRoot().isStrictCanonical()); + assertTrue(result.snapshot().frozenCanonicalRoot().isStrictBlueIdValidation()); + Node parsed = blue.jsonToNode(blue.nodeToJson(result.snapshot().canonicalRoot())); + ResolvedSnapshot reloaded = blue.loadSnapshot(parsed); + assertEquals(result.blueId(), reloaded.blueId()); + } +} diff --git a/src/test/java/blue/language/processor/RecordingProcessingMetricsSinkTest.java b/src/test/java/blue/language/processor/RecordingProcessingMetricsSinkTest.java index 48074d5..5bd89c0 100644 --- a/src/test/java/blue/language/processor/RecordingProcessingMetricsSinkTest.java +++ b/src/test/java/blue/language/processor/RecordingProcessingMetricsSinkTest.java @@ -80,4 +80,22 @@ void concurrentUpdatesAreNotLost() throws Exception { snapshot.counter("incrementalSnapshotResolutions")); assertEquals(iterations - 1L, snapshot.gauge("cache.plans.highWaterBytes")); } + + @Test + void mutablePatchAttributionUsesFixedSourceNames() { + RecordingProcessingMetricsSink sink = new RecordingProcessingMetricsSink(); + + sink.incrementMutablePatchValuesFrozen(PatchSource.PROCESSOR_INITIALIZATION_MARKER); + sink.incrementMutablePatchValuesFrozen(PatchSource.CONFORMANCE_FIXTURE); + sink.incrementMutablePatchValuesFrozen(null); + + ProcessingMetricsSnapshot snapshot = sink.snapshot(); + assertEquals(3L, snapshot.counter("mutablePatchValuesFrozen")); + assertEquals(1L, snapshot.counter( + "mutablePatchValuesFrozenBySource.PROCESSOR_INITIALIZATION_MARKER")); + assertEquals(1L, snapshot.counter( + "mutablePatchValuesFrozenBySource.CONFORMANCE_FIXTURE")); + assertEquals(1L, snapshot.counter( + "mutablePatchValuesFrozenBySource.UNKNOWN_INTERNAL")); + } } diff --git a/src/test/java/blue/language/processor/ResolvedSnapshotPatchTransactionTest.java b/src/test/java/blue/language/processor/ResolvedSnapshotPatchTransactionTest.java index f60dac5..7fa2f89 100644 --- a/src/test/java/blue/language/processor/ResolvedSnapshotPatchTransactionTest.java +++ b/src/test/java/blue/language/processor/ResolvedSnapshotPatchTransactionTest.java @@ -274,7 +274,7 @@ private static void assertMissing(Node node, String path) { private static void assertPlainPathViewsEqual(Blue blue, ResolvedSnapshot snapshot, String path) { assertEquals(blue.nodeToJson(ImmutablePatchPlanner.readNode(snapshot.canonicalRoot(), path)), blue.nodeToJson(ImmutablePatchPlanner.readNode(snapshot.resolvedRoot(), path))); - assertEquals(BlueIdCalculator.calculateUncheckedBlueId(snapshot.canonicalRoot()), snapshot.blueId()); + assertEquals(snapshot.frozenCanonicalRoot().blueId(), snapshot.blueId()); } private static Node listDocument(int... values) { From 3f8cb2a5a2e1e0925a6992c5389680fe799a355d Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 21 Jul 2026 10:43:15 +0200 Subject: [PATCH 3/5] feat: add `canonicalBlueIdAt` method to `ResolvedSnapshot` Introduced a helper method to fetch the `blueId` of a canonicalized node by pointer, enhancing readability and utility of snapshot queries. --- src/main/java/blue/language/snapshot/ResolvedSnapshot.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/blue/language/snapshot/ResolvedSnapshot.java b/src/main/java/blue/language/snapshot/ResolvedSnapshot.java index ef58443..6e03182 100644 --- a/src/main/java/blue/language/snapshot/ResolvedSnapshot.java +++ b/src/main/java/blue/language/snapshot/ResolvedSnapshot.java @@ -99,6 +99,11 @@ public FrozenNode canonicalAt(String pointer) { return canonicalIndex().get(JsonPointer.canonicalize(pointer)); } + public String canonicalBlueIdAt(String pointer) { + FrozenNode node = canonicalAt(pointer); + return node != null ? node.blueId() : null; + } + public FrozenNode resolvedAt(String pointer) { return resolvedIndex().get(JsonPointer.canonicalize(pointer)); } From 4b7da1d3a99e2614b66ef865a3215c994b3bb517 Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 21 Jul 2026 12:38:59 +0200 Subject: [PATCH 4/5] feat: introduce configurable cache policies and profiles Added configurable cache policies with predefined profiles (`lowMemoryDefaults`, `highThroughputDefaults`, `boundedDefaults`, `disabled`) to tailor runtime caching behavior. Enhanced stats reporting for eviction and oversized rejections. Updated tests and documentation to reflect these changes. --- CHANGELOG.md | 5 + README.md | 12 ++ build.gradle | 80 +++++++- src/main/java/blue/language/Blue.java | 6 +- .../java/blue/language/BlueCachePolicy.java | 191 ++++++++++++++---- .../java/blue/language/WeightedLruCache.java | 9 +- .../snapshot/ResolvedReferenceCache.java | 48 ++++- .../blue/language/BlueCacheLifecycleTest.java | 19 ++ .../blue/language/BlueCachePolicyTest.java | 39 ++++ .../blue/language/WeightedLruCacheTest.java | 13 ++ .../ResolvedReferenceCacheContractTest.java | 35 ++++ 11 files changed, 413 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d138c31..b038a73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - add immutable `FrozenJsonPatch` APIs for direct frozen patch-value handoff - add configurable per-runtime cache policy, cache statistics, and idempotent runtime close +- add explicit low-memory, high-throughput, and disabled cache policy profiles - add production-path processing metrics snapshots and conservative patch-impact classification ### Performance @@ -13,6 +14,8 @@ - reuse resolved metadata for dependency-proven basic scalar typed-leaf replacements - bound reloadable derived snapshots, aliases, recent-processing state, shared verified-reference acceleration, and structural interning while preserving authoritative and active pinned evidence +- make conservative per-runtime cache bounds the default profile and keep the previous larger + bounds behind an explicit high-throughput profile - replace hot implicit decimal/index regex compilation with exact ASCII scans ### Compatibility @@ -21,6 +24,7 @@ - preserve full-resolution fallbacks for schema, fixed-value type, reference, collection, contracts-changing, custom-merger, and unknown-capability cases - add reproducible source-release archives and JVM descriptor compatibility reporting +- honor `SOURCE_DATE_EPOCH` for reproducible build metadata timestamps ### Fix @@ -32,6 +36,7 @@ - drain work admitted through `Blue` runtime APIs during close and reject new or re-entrant cache-sensitive work - isolate retained conformance views from refreshed cache generations +- bound transient trusted reference retention and report its real eviction/rejection counters ## v2.0.0 (2026-05-13) diff --git a/README.md b/README.md index 64b63df..6cdb5fc 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,18 @@ blue.cacheResolvedSnapshot(first); Cache hits improve performance but do not change document identity or processor gas accounting. +Cache bounds are selected per `Blue` runtime: + +```java +Blue serviceRuntime = Blue.withCachePolicy(BlueCachePolicy.lowMemoryDefaults()); +Blue batchRuntime = Blue.withCachePolicy(BlueCachePolicy.highThroughputDefaults()); +Blue noReloadableCaches = Blue.withCachePolicy(BlueCachePolicy.disabled()); +``` + +`boundedDefaults()` is the conservative production default. `disabled()` turns +off reloadable acceleration caches while preserving snapshots explicitly pinned +with `cacheResolvedSnapshot(...)`. + ## Dictionary-Aware Export A dictionary is a named collection of known Blue type definitions. When you send diff --git a/build.gradle b/build.gradle index 9497fd8..dd18700 100644 --- a/build.gradle +++ b/build.gradle @@ -172,14 +172,31 @@ jmh { } ext.genResourcesDir = file("$buildDir/generated-resources") +def sourceDateEpoch = providers.environmentVariable('SOURCE_DATE_EPOCH') task generateBuildProperties { ext.buildPropertiesFile = file("$genResourcesDir/blue/language/build.properties") inputs.property('buildVersion', project.version.toString()) + inputs.property('sourceDateEpoch', sourceDateEpoch.orNull ?: '') outputs.file(buildPropertiesFile) doLast { + def epoch = sourceDateEpoch.orNull + def buildInstant + if (epoch != null && !epoch.trim().isEmpty()) { + try { + buildInstant = java.time.Instant.ofEpochSecond(Long.parseLong(epoch.trim())) + } catch (NumberFormatException exception) { + throw new GradleException("SOURCE_DATE_EPOCH must be a Unix epoch second", exception) + } + } else { + buildInstant = java.time.Instant.now() + } + def buildTimestamp = java.time.format.DateTimeFormatter + .ofPattern("yyyy-MM-dd'T'HH:mm:ssZ") + .withZone(java.time.ZoneOffset.UTC) + .format(buildInstant) buildPropertiesFile.text = """\ |blue-language-java.build.version=$project.version - |blue-language-java.build.timestamp=${new Date().format("yyyy-MM-dd'T'HH:mm:ssZ")} + |blue-language-java.build.timestamp=${buildTimestamp} """.stripMargin().trim() } } @@ -280,6 +297,67 @@ tasks.register('sourceReleaseArchive', Zip) { } } +def sourceReleaseArchiveTask = tasks.named('sourceReleaseArchive', Zip) +tasks.register('verifySourceReleaseArchive') { + group = 'verification' + description = 'Checks the source release archive for required files and local-only debris.' + dependsOn sourceReleaseArchiveTask + inputs.file(sourceReleaseArchiveTask.flatMap { it.archiveFile }) + doLast { + def archive = sourceReleaseArchiveTask.get().archiveFile.get().asFile + def names = [] + archive.withInputStream { input -> + def zip = new java.util.zip.ZipInputStream(input) + try { + def entry = zip.getNextEntry() + while (entry != null) { + names.add(entry.name) + zip.closeEntry() + entry = zip.getNextEntry() + } + } finally { + zip.close() + } + } + def forbidden = names.findAll { name -> + name.contains('__MACOSX/') + || name.endsWith('/.DS_Store') + || name.contains('/._') + || name.contains('/build/') + || name.contains('/docs/performance/') + || name.endsWith('/Archive.zip') + } + if (!forbidden.isEmpty()) { + throw new GradleException("Source release archive contains forbidden entries: " + + forbidden.take(20)) + } + def root = "blue-language-java-${project.version}/".toString() + def required = [ + root + '.cz.toml', + root + 'build.gradle', + root + 'settings.gradle.kts', + root + 'README.md', + root + 'src/main/java/blue/language/Blue.java' + ] + def missing = required.findAll { requiredName -> !names.contains(requiredName) } + if (!missing.isEmpty()) { + throw new GradleException("Source release archive is missing required entries: " + + missing) + } + } +} + +tasks.register('rcVerify') { + group = 'verification' + description = 'Runs the local blue-language-java release-candidate verification gates.' + dependsOn tasks.named('check') + dependsOn tasks.named('identityDifferentialTest') + dependsOn tasks.named('patchSequenceDifferentialTest') + dependsOn tasks.named('memoryIntegrationTest') + dependsOn tasks.named('cacheLifecycleTest') + dependsOn tasks.named('verifySourceReleaseArchive') +} + publishing { publications { maven(MavenPublication) { diff --git a/src/main/java/blue/language/Blue.java b/src/main/java/blue/language/Blue.java index 3e3dd05..8c2143f 100644 --- a/src/main/java/blue/language/Blue.java +++ b/src/main/java/blue/language/Blue.java @@ -609,9 +609,9 @@ public BlueCacheStats cacheStats() { reference.transientTrustedHighWaterWeightBytes(), 0L, 0L, - 0L, - 0L, - true)); + reference.transientTrustedEvictions(), + reference.transientTrustedOversizedRejections(), + false)); regions.put(STRUCTURAL_INTERNER_CACHE, new BlueCacheStats.Region( reference.structuralEntries(), reference.structuralCurrentWeightBytes(), diff --git a/src/main/java/blue/language/BlueCachePolicy.java b/src/main/java/blue/language/BlueCachePolicy.java index bcefe84..0d5c75f 100644 --- a/src/main/java/blue/language/BlueCachePolicy.java +++ b/src/main/java/blue/language/BlueCachePolicy.java @@ -2,13 +2,50 @@ /** * Immutable bounds for reloadable acceleration data owned by one {@link Blue} - * runtime. Explicitly registered authoritative snapshots are not evicted by - * these limits; they remain pinned until clear or close. + * runtime. These limits are not process-wide budgets. Explicitly registered + * authoritative snapshots are not evicted by these limits; they remain pinned + * until clear or close. */ public final class BlueCachePolicy { private static final long MIB = 1024L * 1024L; + private static final int DEFAULT_DERIVED_SNAPSHOT_ENTRIES = 128; + private static final long DEFAULT_DERIVED_SNAPSHOT_WEIGHT = 64L * MIB; + private static final int DEFAULT_CANONICAL_ALIAS_ENTRIES = 256; + private static final long DEFAULT_CANONICAL_ALIAS_WEIGHT = 16L * MIB; + private static final int DEFAULT_RESOLVED_STRUCTURAL_ENTRIES = 8_192; + private static final long DEFAULT_RESOLVED_STRUCTURAL_WEIGHT = 64L * MIB; + private static final int DEFAULT_TRANSIENT_REFERENCE_ENTRIES = 2_048; + private static final long DEFAULT_TRANSIENT_REFERENCE_WEIGHT = 32L * MIB; + private static final int DEFAULT_CONFORMANCE_PLAN_ENTRIES = 4_096; + private static final long DEFAULT_CONFORMANCE_PLAN_WEIGHT = 32L * MIB; + private static final long DEFAULT_MAXIMUM_DERIVED_ENTRY_WEIGHT = 16L * MIB; + + private static final int LOW_MEMORY_DERIVED_SNAPSHOT_ENTRIES = 64; + private static final long LOW_MEMORY_DERIVED_SNAPSHOT_WEIGHT = 32L * MIB; + private static final int LOW_MEMORY_CANONICAL_ALIAS_ENTRIES = 128; + private static final long LOW_MEMORY_CANONICAL_ALIAS_WEIGHT = 8L * MIB; + private static final int LOW_MEMORY_RESOLVED_STRUCTURAL_ENTRIES = 4_096; + private static final long LOW_MEMORY_RESOLVED_STRUCTURAL_WEIGHT = 32L * MIB; + private static final int LOW_MEMORY_TRANSIENT_REFERENCE_ENTRIES = 1_024; + private static final long LOW_MEMORY_TRANSIENT_REFERENCE_WEIGHT = 16L * MIB; + private static final int LOW_MEMORY_CONFORMANCE_PLAN_ENTRIES = 1_024; + private static final long LOW_MEMORY_CONFORMANCE_PLAN_WEIGHT = 16L * MIB; + private static final long LOW_MEMORY_MAXIMUM_DERIVED_ENTRY_WEIGHT = 8L * MIB; + + private static final int HIGH_THROUGHPUT_DERIVED_SNAPSHOT_ENTRIES = 256; + private static final long HIGH_THROUGHPUT_DERIVED_SNAPSHOT_WEIGHT = 256L * MIB; + private static final int HIGH_THROUGHPUT_CANONICAL_ALIAS_ENTRIES = 512; + private static final long HIGH_THROUGHPUT_CANONICAL_ALIAS_WEIGHT = 64L * MIB; + private static final int HIGH_THROUGHPUT_RESOLVED_STRUCTURAL_ENTRIES = 16_384; + private static final long HIGH_THROUGHPUT_RESOLVED_STRUCTURAL_WEIGHT = 256L * MIB; + private static final int HIGH_THROUGHPUT_TRANSIENT_REFERENCE_ENTRIES = 4_096; + private static final long HIGH_THROUGHPUT_TRANSIENT_REFERENCE_WEIGHT = 128L * MIB; + private static final int HIGH_THROUGHPUT_CONFORMANCE_PLAN_ENTRIES = 4_096; + private static final long HIGH_THROUGHPUT_CONFORMANCE_PLAN_WEIGHT = 128L * MIB; + private static final long HIGH_THROUGHPUT_MAXIMUM_DERIVED_ENTRY_WEIGHT = 64L * MIB; + private final int derivedSnapshotMaxEntries; private final long derivedSnapshotMaxWeightBytes; private final int canonicalAliasMaxEntries; @@ -22,34 +59,104 @@ public final class BlueCachePolicy { private final long maximumDerivedEntryWeightBytes; private BlueCachePolicy(Builder builder) { - this.derivedSnapshotMaxEntries = positive(builder.derivedSnapshotMaxEntries, - "derivedSnapshotMaxEntries"); - this.derivedSnapshotMaxWeightBytes = positive(builder.derivedSnapshotMaxWeightBytes, - "derivedSnapshotMaxWeightBytes"); - this.canonicalAliasMaxEntries = positive(builder.canonicalAliasMaxEntries, - "canonicalAliasMaxEntries"); - this.canonicalAliasMaxWeightBytes = positive(builder.canonicalAliasMaxWeightBytes, - "canonicalAliasMaxWeightBytes"); - this.resolvedStructuralMaxEntries = positive(builder.resolvedStructuralMaxEntries, - "resolvedStructuralMaxEntries"); - this.resolvedStructuralMaxWeightBytes = positive(builder.resolvedStructuralMaxWeightBytes, - "resolvedStructuralMaxWeightBytes"); - this.transientReferenceMaxEntries = positive(builder.transientReferenceMaxEntries, - "transientReferenceMaxEntries"); - this.transientReferenceMaxWeightBytes = positive(builder.transientReferenceMaxWeightBytes, - "transientReferenceMaxWeightBytes"); - this.conformancePlanMaxEntries = positive(builder.conformancePlanMaxEntries, - "conformancePlanMaxEntries"); - this.conformancePlanMaxWeightBytes = positive(builder.conformancePlanMaxWeightBytes, - "conformancePlanMaxWeightBytes"); - this.maximumDerivedEntryWeightBytes = positive(builder.maximumDerivedEntryWeightBytes, - "maximumDerivedEntryWeightBytes"); + this( + positive(builder.derivedSnapshotMaxEntries, "derivedSnapshotMaxEntries"), + positive(builder.derivedSnapshotMaxWeightBytes, "derivedSnapshotMaxWeightBytes"), + positive(builder.canonicalAliasMaxEntries, "canonicalAliasMaxEntries"), + positive(builder.canonicalAliasMaxWeightBytes, "canonicalAliasMaxWeightBytes"), + positive(builder.resolvedStructuralMaxEntries, "resolvedStructuralMaxEntries"), + positive(builder.resolvedStructuralMaxWeightBytes, "resolvedStructuralMaxWeightBytes"), + positive(builder.transientReferenceMaxEntries, "transientReferenceMaxEntries"), + positive(builder.transientReferenceMaxWeightBytes, "transientReferenceMaxWeightBytes"), + positive(builder.conformancePlanMaxEntries, "conformancePlanMaxEntries"), + positive(builder.conformancePlanMaxWeightBytes, "conformancePlanMaxWeightBytes"), + positive(builder.maximumDerivedEntryWeightBytes, "maximumDerivedEntryWeightBytes")); + } + + private BlueCachePolicy(int derivedSnapshotMaxEntries, + long derivedSnapshotMaxWeightBytes, + int canonicalAliasMaxEntries, + long canonicalAliasMaxWeightBytes, + int resolvedStructuralMaxEntries, + long resolvedStructuralMaxWeightBytes, + int transientReferenceMaxEntries, + long transientReferenceMaxWeightBytes, + int conformancePlanMaxEntries, + long conformancePlanMaxWeightBytes, + long maximumDerivedEntryWeightBytes) { + this.derivedSnapshotMaxEntries = nonNegative( + derivedSnapshotMaxEntries, "derivedSnapshotMaxEntries"); + this.derivedSnapshotMaxWeightBytes = nonNegative( + derivedSnapshotMaxWeightBytes, "derivedSnapshotMaxWeightBytes"); + this.canonicalAliasMaxEntries = nonNegative( + canonicalAliasMaxEntries, "canonicalAliasMaxEntries"); + this.canonicalAliasMaxWeightBytes = nonNegative( + canonicalAliasMaxWeightBytes, "canonicalAliasMaxWeightBytes"); + this.resolvedStructuralMaxEntries = nonNegative( + resolvedStructuralMaxEntries, "resolvedStructuralMaxEntries"); + this.resolvedStructuralMaxWeightBytes = nonNegative( + resolvedStructuralMaxWeightBytes, "resolvedStructuralMaxWeightBytes"); + this.transientReferenceMaxEntries = nonNegative( + transientReferenceMaxEntries, "transientReferenceMaxEntries"); + this.transientReferenceMaxWeightBytes = nonNegative( + transientReferenceMaxWeightBytes, "transientReferenceMaxWeightBytes"); + this.conformancePlanMaxEntries = nonNegative( + conformancePlanMaxEntries, "conformancePlanMaxEntries"); + this.conformancePlanMaxWeightBytes = nonNegative( + conformancePlanMaxWeightBytes, "conformancePlanMaxWeightBytes"); + this.maximumDerivedEntryWeightBytes = nonNegative( + maximumDerivedEntryWeightBytes, "maximumDerivedEntryWeightBytes"); } + /** + * Conservative production default for one runtime. Use + * {@link #highThroughputDefaults()} only when the host has made an explicit + * memory/throughput tradeoff. + */ public static BlueCachePolicy boundedDefaults() { return builder().build(); } + /** Smaller per-runtime bounds intended for low-memory service profiles. */ + public static BlueCachePolicy lowMemoryDefaults() { + return new BlueCachePolicy( + LOW_MEMORY_DERIVED_SNAPSHOT_ENTRIES, + LOW_MEMORY_DERIVED_SNAPSHOT_WEIGHT, + LOW_MEMORY_CANONICAL_ALIAS_ENTRIES, + LOW_MEMORY_CANONICAL_ALIAS_WEIGHT, + LOW_MEMORY_RESOLVED_STRUCTURAL_ENTRIES, + LOW_MEMORY_RESOLVED_STRUCTURAL_WEIGHT, + LOW_MEMORY_TRANSIENT_REFERENCE_ENTRIES, + LOW_MEMORY_TRANSIENT_REFERENCE_WEIGHT, + LOW_MEMORY_CONFORMANCE_PLAN_ENTRIES, + LOW_MEMORY_CONFORMANCE_PLAN_WEIGHT, + LOW_MEMORY_MAXIMUM_DERIVED_ENTRY_WEIGHT); + } + + /** Previous high-memory defaults for hosts that need throughput over footprint. */ + public static BlueCachePolicy highThroughputDefaults() { + return new BlueCachePolicy( + HIGH_THROUGHPUT_DERIVED_SNAPSHOT_ENTRIES, + HIGH_THROUGHPUT_DERIVED_SNAPSHOT_WEIGHT, + HIGH_THROUGHPUT_CANONICAL_ALIAS_ENTRIES, + HIGH_THROUGHPUT_CANONICAL_ALIAS_WEIGHT, + HIGH_THROUGHPUT_RESOLVED_STRUCTURAL_ENTRIES, + HIGH_THROUGHPUT_RESOLVED_STRUCTURAL_WEIGHT, + HIGH_THROUGHPUT_TRANSIENT_REFERENCE_ENTRIES, + HIGH_THROUGHPUT_TRANSIENT_REFERENCE_WEIGHT, + HIGH_THROUGHPUT_CONFORMANCE_PLAN_ENTRIES, + HIGH_THROUGHPUT_CONFORMANCE_PLAN_WEIGHT, + HIGH_THROUGHPUT_MAXIMUM_DERIVED_ENTRY_WEIGHT); + } + + /** + * Disables retention of reloadable acceleration data. Authoritative + * snapshots explicitly cached by the caller remain pinned. + */ + public static BlueCachePolicy disabled() { + return new BlueCachePolicy(0, 0L, 0, 0L, 0, 0L, 0, 0L, 0, 0L, 0L); + } + public static Builder builder() { return new Builder(); } @@ -112,18 +219,32 @@ private static long positive(long value, String name) { return value; } + private static int nonNegative(int value, String name) { + if (value < 0) { + throw new IllegalArgumentException(name + " must not be negative"); + } + return value; + } + + private static long nonNegative(long value, String name) { + if (value < 0L) { + throw new IllegalArgumentException(name + " must not be negative"); + } + return value; + } + public static final class Builder { - private int derivedSnapshotMaxEntries = 256; - private long derivedSnapshotMaxWeightBytes = 256L * MIB; - private int canonicalAliasMaxEntries = 512; - private long canonicalAliasMaxWeightBytes = 64L * MIB; - private int resolvedStructuralMaxEntries = 16_384; - private long resolvedStructuralMaxWeightBytes = 256L * MIB; - private int transientReferenceMaxEntries = 4_096; - private long transientReferenceMaxWeightBytes = 128L * MIB; - private int conformancePlanMaxEntries = 4_096; - private long conformancePlanMaxWeightBytes = 128L * MIB; - private long maximumDerivedEntryWeightBytes = 64L * MIB; + private int derivedSnapshotMaxEntries = DEFAULT_DERIVED_SNAPSHOT_ENTRIES; + private long derivedSnapshotMaxWeightBytes = DEFAULT_DERIVED_SNAPSHOT_WEIGHT; + private int canonicalAliasMaxEntries = DEFAULT_CANONICAL_ALIAS_ENTRIES; + private long canonicalAliasMaxWeightBytes = DEFAULT_CANONICAL_ALIAS_WEIGHT; + private int resolvedStructuralMaxEntries = DEFAULT_RESOLVED_STRUCTURAL_ENTRIES; + private long resolvedStructuralMaxWeightBytes = DEFAULT_RESOLVED_STRUCTURAL_WEIGHT; + private int transientReferenceMaxEntries = DEFAULT_TRANSIENT_REFERENCE_ENTRIES; + private long transientReferenceMaxWeightBytes = DEFAULT_TRANSIENT_REFERENCE_WEIGHT; + private int conformancePlanMaxEntries = DEFAULT_CONFORMANCE_PLAN_ENTRIES; + private long conformancePlanMaxWeightBytes = DEFAULT_CONFORMANCE_PLAN_WEIGHT; + private long maximumDerivedEntryWeightBytes = DEFAULT_MAXIMUM_DERIVED_ENTRY_WEIGHT; private Builder() { } diff --git a/src/main/java/blue/language/WeightedLruCache.java b/src/main/java/blue/language/WeightedLruCache.java index aead5f3..da3f263 100644 --- a/src/main/java/blue/language/WeightedLruCache.java +++ b/src/main/java/blue/language/WeightedLruCache.java @@ -27,8 +27,8 @@ public WeightedLruCache(int maximumEntries, long maximumWeight, long maximumEntryWeight, Weigher weigher) { - if (maximumEntries <= 0 || maximumWeight <= 0L || maximumEntryWeight <= 0L) { - throw new IllegalArgumentException("Cache bounds must be positive"); + if (maximumEntries < 0 || maximumWeight < 0L || maximumEntryWeight < 0L) { + throw new IllegalArgumentException("Cache bounds must not be negative"); } if (weigher == null) { throw new IllegalArgumentException("weigher must not be null"); @@ -59,6 +59,11 @@ public synchronized V put(K key, V value) { if (key == null || value == null) { throw new IllegalArgumentException("Cache keys and values must not be null"); } + if (maximumEntries == 0 || maximumWeight == 0L || maximumEntryWeight == 0L) { + oversizedRejections++; + Entry previous = entries.get(key); + return previous != null ? previous.value : null; + } long weight = Math.max(1L, weigher.weightOf(value)); if (weight > maximumEntryWeight || weight > maximumWeight) { oversizedRejections++; diff --git a/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java b/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java index 2cc172b..fa1d43c 100644 --- a/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java +++ b/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java @@ -57,6 +57,8 @@ public final class ResolvedReferenceCache implements AutoCloseable { private long verifiedOversizedRejections; private long trustedCurrentWeight; private long trustedHighWaterWeight; + private long trustedEvictions; + private long trustedOversizedRejections; private long structuralCurrentWeight; private long structuralHighWaterWeight; private long structuralEvictions; @@ -728,12 +730,31 @@ private void evictVerifiedToBounds() { } private void recordTrustedInsertion(String blueId, FrozenNode node) { + long weight = trustedWeight(blueId, node); + if (cachePolicy.transientReferenceMaxEntries() <= 0 + || weight > cachePolicy.maximumDerivedEntryWeightBytes() + || weight > cachePolicy.transientReferenceMaxWeightBytes()) { + transientTrustedCanonicalByBlueId.remove(blueId, node); + trustedOversizedRejections++; + return; + } trustedInsertionOrder.remove(blueId); trustedInsertionOrder.add(blueId); - trustedCurrentWeight = saturatedAdd( - trustedCurrentWeight, - trustedWeight(blueId, node)); + trustedCurrentWeight = saturatedAdd(trustedCurrentWeight, weight); trustedHighWaterWeight = Math.max(trustedHighWaterWeight, trustedCurrentWeight); + evictTrustedToBounds(); + } + + private void evictTrustedToBounds() { + while (transientTrustedCanonicalByBlueId.size() > cachePolicy.transientReferenceMaxEntries() + || trustedCurrentWeight > cachePolicy.transientReferenceMaxWeightBytes()) { + if (trustedInsertionOrder.isEmpty()) { + return; + } + String victim = trustedInsertionOrder.iterator().next(); + removeTrustedEntry(victim); + trustedEvictions++; + } } private void recordStructuralInsertion(FrozenNode.ResolvedStructuralKey key, @@ -877,6 +898,8 @@ public CacheStats cacheStats() { int transientTrustedEntries = 0; long transientTrustedCurrentWeightBytes = 0L; long transientTrustedHighWaterWeightBytes = 0L; + long transientTrustedEvictions = 0L; + long transientTrustedOversizedRejections = 0L; int structuralEntries = 0; long structuralCurrentWeightBytes = 0L; long structuralHighWaterWeightBytes = 0L; @@ -902,6 +925,11 @@ public CacheStats cacheStats() { transientTrustedHighWaterWeightBytes = saturatedAdd( transientTrustedHighWaterWeightBytes, local.transientTrustedHighWaterWeightBytes()); + transientTrustedEvictions = saturatedAdd( + transientTrustedEvictions, local.transientTrustedEvictions()); + transientTrustedOversizedRejections = saturatedAdd( + transientTrustedOversizedRejections, + local.transientTrustedOversizedRejections()); structuralEntries = saturatedAdd(structuralEntries, local.structuralEntries()); structuralCurrentWeightBytes = saturatedAdd( structuralCurrentWeightBytes, local.structuralCurrentWeightBytes()); @@ -928,6 +956,8 @@ public CacheStats cacheStats() { transientTrustedEntries, transientTrustedCurrentWeightBytes, cacheGeneration.trustedHighWaterWeight, + transientTrustedEvictions, + transientTrustedOversizedRejections, structuralEntries, structuralCurrentWeightBytes, cacheGeneration.structuralHighWaterWeight, @@ -947,6 +977,8 @@ private CacheStats localCacheStats() { transientTrustedCanonicalByBlueId.size(), trustedCurrentWeight, trustedHighWaterWeight, + trustedEvictions, + trustedOversizedRejections, resolvedGraphNodesByStructure.size(), structuralCurrentWeight, structuralHighWaterWeight, @@ -1202,6 +1234,8 @@ public static final class CacheStats { private final int transientTrustedEntries; private final long transientTrustedCurrentWeightBytes; private final long transientTrustedHighWaterWeightBytes; + private final long transientTrustedEvictions; + private final long transientTrustedOversizedRejections; private final int structuralEntries; private final long structuralCurrentWeightBytes; private final long structuralHighWaterWeightBytes; @@ -1217,6 +1251,8 @@ private CacheStats(int verifiedEntries, int transientTrustedEntries, long transientTrustedCurrentWeightBytes, long transientTrustedHighWaterWeightBytes, + long transientTrustedEvictions, + long transientTrustedOversizedRejections, int structuralEntries, long structuralCurrentWeightBytes, long structuralHighWaterWeightBytes, @@ -1231,6 +1267,8 @@ private CacheStats(int verifiedEntries, this.transientTrustedEntries = transientTrustedEntries; this.transientTrustedCurrentWeightBytes = transientTrustedCurrentWeightBytes; this.transientTrustedHighWaterWeightBytes = transientTrustedHighWaterWeightBytes; + this.transientTrustedEvictions = transientTrustedEvictions; + this.transientTrustedOversizedRejections = transientTrustedOversizedRejections; this.structuralEntries = structuralEntries; this.structuralCurrentWeightBytes = structuralCurrentWeightBytes; this.structuralHighWaterWeightBytes = structuralHighWaterWeightBytes; @@ -1256,6 +1294,10 @@ private CacheStats(int verifiedEntries, public long transientTrustedHighWaterWeightBytes() { return transientTrustedHighWaterWeightBytes; } + public long transientTrustedEvictions() { return transientTrustedEvictions; } + + public long transientTrustedOversizedRejections() { return transientTrustedOversizedRejections; } + public int structuralEntries() { return structuralEntries; } public long structuralCurrentWeightBytes() { return structuralCurrentWeightBytes; } diff --git a/src/test/java/blue/language/BlueCacheLifecycleTest.java b/src/test/java/blue/language/BlueCacheLifecycleTest.java index 4e19ddd..a28236d 100644 --- a/src/test/java/blue/language/BlueCacheLifecycleTest.java +++ b/src/test/java/blue/language/BlueCacheLifecycleTest.java @@ -88,6 +88,25 @@ void publicAuthoritativeSnapshotRegistrationRemainsPinnedAcrossDerivedEviction() assertTrue(blue.cacheStats().region("derivedResolvedSnapshots").entries() <= 1); } + @Test + void disabledPolicySkipsReloadableRetentionButKeepsExplicitPins() { + Blue blue = Blue.withCachePolicy(BlueCachePolicy.disabled()); + ResolvedSnapshot snapshot = blue.resolveToSnapshot(document(20)); + + assertEquals(0, blue.cacheStats().region("derivedResolvedSnapshots").entries()); + assertEquals(0, blue.cacheStats().region("canonicalAliases").entries()); + assertEquals(0, blue.cacheStats().region("recentProcessingSnapshots").entries()); + assertEquals(0, blue.cacheStats().region("verifiedReferences").entries()); + + blue.cacheResolvedSnapshot(snapshot); + + assertSame(snapshot, blue.cachedResolvedSnapshot(snapshot.blueId()) + .orElseThrow(AssertionError::new)); + assertEquals(1, blue.cacheStats().region("pinnedAuthoritativeSnapshots").entries()); + assertTrue(blue.cacheStats().region("derivedResolvedSnapshots") + .oversizedRejections() > 0L); + } + @Test void configurationRefreshPreservesCallerPinnedAuthoritativeContent() { Blue blue = new Blue(node -> null); diff --git a/src/test/java/blue/language/BlueCachePolicyTest.java b/src/test/java/blue/language/BlueCachePolicyTest.java index fefb43c..2843241 100644 --- a/src/test/java/blue/language/BlueCachePolicyTest.java +++ b/src/test/java/blue/language/BlueCachePolicyTest.java @@ -4,9 +4,48 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class BlueCachePolicyTest { + private static final long MIB = 1024L * 1024L; + + @Test + void boundedDefaultsAreConservativePerRuntimeBounds() { + BlueCachePolicy policy = BlueCachePolicy.boundedDefaults(); + + assertEquals(128, policy.derivedSnapshotMaxEntries()); + assertEquals(64L * MIB, policy.derivedSnapshotMaxWeightBytes()); + assertEquals(256, policy.canonicalAliasMaxEntries()); + assertEquals(16L * MIB, policy.canonicalAliasMaxWeightBytes()); + assertEquals(8_192, policy.resolvedStructuralMaxEntries()); + assertEquals(64L * MIB, policy.resolvedStructuralMaxWeightBytes()); + assertEquals(2_048, policy.transientReferenceMaxEntries()); + assertEquals(32L * MIB, policy.transientReferenceMaxWeightBytes()); + assertEquals(4_096, policy.conformancePlanMaxEntries()); + assertEquals(32L * MIB, policy.conformancePlanMaxWeightBytes()); + assertEquals(16L * MIB, policy.maximumDerivedEntryWeightBytes()); + } + + @Test + void namedProfilesCoverLowMemoryHighThroughputAndDisabledModes() { + BlueCachePolicy lowMemory = BlueCachePolicy.lowMemoryDefaults(); + BlueCachePolicy defaults = BlueCachePolicy.boundedDefaults(); + BlueCachePolicy highThroughput = BlueCachePolicy.highThroughputDefaults(); + BlueCachePolicy disabled = BlueCachePolicy.disabled(); + + assertTrue(lowMemory.derivedSnapshotMaxWeightBytes() + < defaults.derivedSnapshotMaxWeightBytes()); + assertTrue(defaults.derivedSnapshotMaxWeightBytes() + < highThroughput.derivedSnapshotMaxWeightBytes()); + assertEquals(256L * MIB, highThroughput.derivedSnapshotMaxWeightBytes()); + assertEquals(128L * MIB, highThroughput.transientReferenceMaxWeightBytes()); + assertEquals(0, disabled.derivedSnapshotMaxEntries()); + assertEquals(0L, disabled.derivedSnapshotMaxWeightBytes()); + assertEquals(0, disabled.transientReferenceMaxEntries()); + assertEquals(0L, disabled.maximumDerivedEntryWeightBytes()); + } + @Test void builderProducesImmutableExplicitBounds() { BlueCachePolicy policy = BlueCachePolicy.builder() diff --git a/src/test/java/blue/language/WeightedLruCacheTest.java b/src/test/java/blue/language/WeightedLruCacheTest.java index f735af1..c9f8ef7 100644 --- a/src/test/java/blue/language/WeightedLruCacheTest.java +++ b/src/test/java/blue/language/WeightedLruCacheTest.java @@ -33,6 +33,19 @@ void rejectsOversizedEntriesWithoutDroppingAnExistingValue() { assertEquals(1L, cache.oversizedRejections()); } + @Test + void zeroBoundsDisableRetentionWithoutThrowing() { + WeightedLruCache cache = new WeightedLruCache<>(0, 0L, 0L, + value -> value.length()); + + assertNull(cache.put("a", "value")); + + assertNull(cache.get("a")); + assertEquals(0, cache.size()); + assertEquals(0L, cache.currentWeight()); + assertEquals(1L, cache.oversizedRejections()); + } + @Test void clearReportsReleasedWeight() { WeightedLruCache cache = new WeightedLruCache<>(4, 100L, 100L, diff --git a/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java b/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java index 9c303fb..16b350f 100644 --- a/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java +++ b/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java @@ -1,6 +1,7 @@ package blue.language.snapshot; import blue.language.Blue; +import blue.language.BlueCachePolicy; import blue.language.NodeProvider; import blue.language.merge.Merger; import blue.language.merge.Merger.SnapshotResolution; @@ -429,6 +430,40 @@ void closingTransientChildRetainsAggregateLifetimeHighWaterMarks() { assertTrue(stats.structuralHighWaterWeightBytes() > 0L); } + @Test + void transientTrustedReferencesRespectPolicyBoundsAndDisabledMode() { + Blue blue = new Blue(); + ResolvedSnapshot first = blue.resolveToSnapshot(new Node().value("trusted-1")); + ResolvedSnapshot second = blue.resolveToSnapshot(new Node().value("trusted-2")); + BlueCachePolicy oneEntryPolicy = BlueCachePolicy.builder() + .transientReferences(1, 1024L * 1024L) + .maximumDerivedEntryWeightBytes(1024L * 1024L) + .build(); + ResolvedReferenceCache parent = new ResolvedReferenceCache(oneEntryPolicy); + ResolvedReferenceCache child = parent.transientChild(); + + child.putTransientTrustedCanonical(first.blueId(), first.frozenCanonicalRoot()); + child.putTransientTrustedCanonical(second.blueId(), second.frozenCanonicalRoot()); + + ResolvedReferenceCache.CacheStats boundedStats = child.cacheStats(); + assertEquals(1, boundedStats.transientTrustedEntries()); + assertEquals(1L, boundedStats.transientTrustedEvictions()); + assertFalse(child.getTransientTrustedCanonical(first.blueId()).isPresent()); + assertTrue(child.getTransientTrustedCanonical(second.blueId()).isPresent()); + + ResolvedReferenceCache disabledParent = + new ResolvedReferenceCache(BlueCachePolicy.disabled()); + ResolvedReferenceCache disabledChild = disabledParent.transientChild(); + assertSame(first.frozenCanonicalRoot(), disabledChild.putTransientTrustedCanonical( + first.blueId(), first.frozenCanonicalRoot())); + + ResolvedReferenceCache.CacheStats disabledStats = disabledChild.cacheStats(); + assertEquals(0, disabledStats.transientTrustedEntries()); + assertEquals(0L, disabledStats.transientTrustedCurrentWeightBytes()); + assertEquals(1L, disabledStats.transientTrustedOversizedRejections()); + assertFalse(disabledChild.getTransientTrustedCanonical(first.blueId()).isPresent()); + } + @Test void closingIntermediateTransientScopeInvalidatesAndReleasesDescendants() { ResolvedReferenceCache root = new ResolvedReferenceCache(); From 8810301b32157ab373e280b9391a261e954e6a2c Mon Sep 17 00:00:00 2001 From: piotr-blue Date: Tue, 21 Jul 2026 19:43:00 +0200 Subject: [PATCH 5/5] feat: introduce new classes for scope identity management and snapshot handling Added `RegisteredContractScopeIdentitySnapshotManager`, `ScopeIdentityErrorMapper`, and `ScopeSourceProjection` to enhance processing and snapshot management for Blue language's scope identity operations. Included corresponding tests to validate functionality. --- src/main/java/blue/language/Blue.java | 8 +- .../BlueContractsConformanceReport.java | 2 +- .../BlueContractsConformanceSuiteRunner.java | 67 +- .../processor/ContractProcessorRegistry.java | 133 +++- .../ContractProcessorRegistryBuilder.java | 20 + .../processor/DocumentProcessingRuntime.java | 160 +++- .../language/processor/DocumentProcessor.java | 130 ++++ .../processor/ImmutableJsonPatch.java | 17 + .../processor/PatchPlanningEngine.java | 24 +- .../processor/ProcessingMetricsSink.java | 8 + .../processor/ProcessingSnapshotManager.java | 59 ++ .../language/processor/ProcessorEngine.java | 8 + .../processor/ProcessorErrorCategory.java | 2 + ...dContractScopeIdentitySnapshotManager.java | 66 ++ .../language/processor/ScopeExecutor.java | 114 +-- .../processor/ScopeIdentityErrorMapper.java | 28 + .../processor/ScopeSourceProjection.java | 508 ++++++++++++ .../blue/language/snapshot/FrozenNode.java | 27 +- .../snapshot/ResolvedReferenceCache.java | 80 +- .../blue/language/utils/MergeReverser.java | 21 +- .../registry/blue-contracts-1.0/manifest.yaml | 2 +- .../java/blue/language/MergeReverserTest.java | 28 + ...ngDocumentStateInvariantFailFirstTest.java | 6 +- ...cessingSnapshotProviderProvenanceTest.java | 3 +- .../ResolvedInstanceSchemaValidationTest.java | 22 +- .../processor/ContractBundleCacheTest.java | 13 +- .../DocumentProcessorInitializationTest.java | 123 +-- .../processor/ProcessEmbeddedTest.java | 42 + .../ProcessorProcessEventContextTest.java | 6 + ...egisteredContractProviderEvidenceTest.java | 272 +++++++ .../ScopeIdentityErrorMapperTest.java | 40 + .../processor/ScopeSourceProjectionTest.java | 695 +++++++++++++++++ ...lectedScopeContentBlueIdFailFirstTest.java | 734 ++++++++++++++++++ .../ExternalContractIntegrationTest.java | 60 +- .../FrozenNodeStructuralInternerTest.java | 24 +- .../ResolvedReferenceCacheContractTest.java | 103 +++ ...mpt_gas_for_rejected_candidates_exact.yaml | 2 +- ...BlueIdComputedBeforeInitializedMarker.yaml | 3 +- .../blue-contracts-1.0/fixtures/manifest.yaml | 2 +- 39 files changed, 3428 insertions(+), 234 deletions(-) create mode 100644 src/main/java/blue/language/processor/RegisteredContractScopeIdentitySnapshotManager.java create mode 100644 src/main/java/blue/language/processor/ScopeIdentityErrorMapper.java create mode 100644 src/main/java/blue/language/processor/ScopeSourceProjection.java create mode 100644 src/test/java/blue/language/processor/RegisteredContractProviderEvidenceTest.java create mode 100644 src/test/java/blue/language/processor/ScopeIdentityErrorMapperTest.java create mode 100644 src/test/java/blue/language/processor/ScopeSourceProjectionTest.java create mode 100644 src/test/java/blue/language/processor/SelectedScopeContentBlueIdFailFirstTest.java diff --git a/src/main/java/blue/language/Blue.java b/src/main/java/blue/language/Blue.java index 8c2143f..b8e9d53 100644 --- a/src/main/java/blue/language/Blue.java +++ b/src/main/java/blue/language/Blue.java @@ -975,6 +975,11 @@ public Blue registerContractProcessor(ContractProcessor proc return this; } + /** + * Registers a processor mapping for {@code blueId} without supplying type + * content. The configured provider must already be able to return verified + * content for that BlueId; no Java class-name node is synthesized. + */ public Blue registerContractProcessor(String blueId, ContractProcessor processor) { ensureOpen(); if (processor == null) { @@ -1009,7 +1014,8 @@ public Blue registerExternalContractType(String blueId, ProcessingMetricsSink metrics; CacheGaugeSnapshot gauges; try { - target.registerContractProcessor(blueId, processor); + target.registerContractProcessor( + blueId, validatedCanonicalType, processor); synchronized (lifecycleLock) { externalContractTypeNodes.put(blueId, validatedCanonicalType); // The extension provider is consulted by snapshot resolution. Any diff --git a/src/main/java/blue/language/BlueContractsConformanceReport.java b/src/main/java/blue/language/BlueContractsConformanceReport.java index 760bf82..795ebb8 100644 --- a/src/main/java/blue/language/BlueContractsConformanceReport.java +++ b/src/main/java/blue/language/BlueContractsConformanceReport.java @@ -20,7 +20,7 @@ public final class BlueContractsConformanceReport { public static final String FIXTURE_MANIFEST_RESOURCE = "blue-contracts-1.0/fixtures/manifest.yaml"; public static final String BLUE_CONTRACTS_1_0_FIXTURE_PACKAGE_IDENTITY = - "sha256:22713df4d50a38b91762aea1e1a360019c1d16c2584ca1bac022305edb4c66d1"; + "sha256:e6d4895fa007837aa1b54a92d1e4cb3133e2e7daa5cc5bb22e7443b6009a244a"; private final String specVersion; private final String fixturePackageIdentity; diff --git a/src/main/java/blue/language/BlueContractsConformanceSuiteRunner.java b/src/main/java/blue/language/BlueContractsConformanceSuiteRunner.java index f858bf8..d8b2735 100644 --- a/src/main/java/blue/language/BlueContractsConformanceSuiteRunner.java +++ b/src/main/java/blue/language/BlueContractsConformanceSuiteRunner.java @@ -298,14 +298,12 @@ private static void runProcessFixture(JsonNode spec) { ScriptedContractsRuntime scriptedRuntime = new ScriptedContractsRuntime(spec.get("mockRuntime"), spec.get("typeGraph")); MockExternalChannelProcessor channelProcessor = new MockExternalChannelProcessor(scriptedRuntime); MockHandlerProcessor handlerProcessor = new MockHandlerProcessor(scriptedRuntime); - Blue fixtureBlue = !scriptedTypes.externalTypeNodesByBlueId.isEmpty() - ? new Blue(mockTypeProvider(scriptedTypes)) - : null; + Blue fixtureBlue = new Blue(mockTypeProvider(scriptedTypes)); DocumentProcessor.Builder processorBuilder = DocumentProcessor.builder() - .withMatchingService(new ContractMatchingService(new Blue())) + .withMatchingService(new ContractMatchingService(fixtureBlue)) .registerContractProcessor(channelProcessor) .registerContractProcessor(handlerProcessor); - if (fixtureBlue != null) { + if (!scriptedTypes.externalTypeNodesByBlueId.isEmpty()) { processorBuilder.withConformanceEngine(fixtureBlue.conformanceEngine()); } if (scriptedRuntime.hasFixtureTypeGraph()) { @@ -335,9 +333,6 @@ private static void runProcessFixture(JsonNode spec) { } private static ProcessingSnapshotManager fixtureSnapshotManager(Blue fixtureBlue) { - if (fixtureBlue == null) { - throw new IllegalArgumentException("Fixture type graph requires a fixture Blue instance"); - } return new ProcessingSnapshotManager() { @Override public ResolvedSnapshot fromDocument(Node document) { @@ -533,7 +528,7 @@ private static void assertProcessResult(JsonNode spec, assertCheckpointLastEvents(spec, result.document()); assertStoredObjectKeys(spec, result.document()); assertPointerReadsAndWrites(spec, result.document()); - assertInitializationContentBlueIdInput(spec, originalDocument, result.document()); + assertInitializationContentBlueIdInput(spec, result); assertRuntimeInsertionNormalizedValues(spec, result); assertGasByteView(spec, result); assertProcessorEventTypes(spec, result); @@ -622,7 +617,8 @@ private static void assertDocumentPaths(JsonNode spec, Node document) { for (JsonNode path : exists) { Node actual = nodeAt(document, path.asText()); if (actual == null) { - throw new AssertionError("Expected document path to exist: " + path.asText()); + throw new AssertionError("Expected document path to exist: " + path.asText() + + " in " + nodeDebug(document)); } } } @@ -768,21 +764,46 @@ private static void assertPointerListAddressesExistingNodes(String field, JsonNo } } - private static void assertInitializationContentBlueIdInput(JsonNode spec, Node originalDocument, Node document) { + private static void assertInitializationContentBlueIdInput(JsonNode spec, + DocumentProcessingResult result) { if (!spec.has("expectedInitializationContentBlueIdInput")) { return; } JsonNode assertion = spec.get("expectedInitializationContentBlueIdInput"); - String excludesPath = text(assertion, "excludesPath", null); - if (excludesPath != null) { - assertTrue(nodeAt(originalDocument, excludesPath) == null, - "Initialization Content BlueId input unexpectedly included " + excludesPath); - } - Node documentId = nodeAt(document, "/contracts/initialized/documentId"); + String scope = text(assertion, "scope", "/"); + JsonNode expectedNode = requireNonNull(assertion, "expectedContentBlueId"); + assertTrue(expectedNode.isTextual() && !expectedNode.asText().isEmpty(), + "expectedInitializationContentBlueIdInput.expectedContentBlueId must be a non-empty string"); + String expectedContentBlueId = expectedNode.asText(); + Node documentId = nodeAt(result.document(), initializedMarkerPath(scope) + "/documentId"); assertTrue(documentId != null && documentId.getValue() != null, "Initialized marker documentId is missing"); - assertEquals(blueId(originalDocument), String.valueOf(documentId.getValue()), - "Initialization documentId"); + assertEquals(expectedContentBlueId, + String.valueOf(documentId.getValue()), + "Initialized marker documentId at " + scope); + + boolean lifecycleMatched = false; + for (Node event : result.triggeredEvents()) { + Node type = event != null ? event.getType() : null; + Node eventDocumentId = event != null && event.getProperties() != null + ? event.getProperties().get("documentId") + : null; + if (type != null + && RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED.equals(type.getBlueId()) + && eventDocumentId != null + && expectedContentBlueId.equals(String.valueOf(eventDocumentId.getValue()))) { + lifecycleMatched = true; + break; + } + } + assertTrue(lifecycleMatched, + "Document Processing Initiated event did not carry the published Content BlueId at " + scope); + } + + private static String initializedMarkerPath(String scope) { + return "/".equals(scope) + ? "/contracts/initialized" + : scope + "/contracts/initialized"; } private static void assertRuntimeInsertionNormalizedValues(JsonNode spec, DocumentProcessingResult result) { @@ -1291,6 +1312,14 @@ private static NodeProvider mockTypeProvider(ScriptedFixtureTypes fixtureTypes) } private static Node mockTypeNode(String name, String blueId) { + if (MockTypeBlueIds.LEGACY_MOCK_HANDLER.equals(blueId) + || MockTypeBlueIds.LEGACY_MOCK_EXTERNAL_CHANNEL.equals(blueId)) { + // The legacy fixture identifiers are the exact Content BlueIds of + // these standalone name-only type documents. Return valid provider + // content so strict scope identity and later patch re-resolution do + // not have to accept a node containing both blueId and siblings. + return new Node().name(name); + } return new Node().blueId(blueId).name(name); } diff --git a/src/main/java/blue/language/processor/ContractProcessorRegistry.java b/src/main/java/blue/language/processor/ContractProcessorRegistry.java index bbe5fdc..b448399 100644 --- a/src/main/java/blue/language/processor/ContractProcessorRegistry.java +++ b/src/main/java/blue/language/processor/ContractProcessorRegistry.java @@ -1,10 +1,12 @@ package blue.language.processor; +import blue.language.model.Node; import blue.language.model.TypeBlueId; import blue.language.processor.model.ChannelContract; import blue.language.processor.model.Contract; import blue.language.processor.model.HandlerContract; import blue.language.processor.model.MarkerContract; +import blue.language.utils.BlueIdCalculator; import java.util.AbstractMap; import java.util.AbstractSet; @@ -24,6 +26,7 @@ public class ContractProcessorRegistry { private final Map> processorsByBlueId = new LinkedHashMap<>(); + private final Map canonicalTypeNodesByBlueId = new LinkedHashMap<>(); private final Map, HandlerProcessor> handlerProcessors = new LinkedHashMap<>(); private final Map, ChannelProcessor> channelProcessors = new LinkedHashMap<>(); private final Map, ContractProcessor> markerProcessors = new LinkedHashMap<>(); @@ -117,6 +120,15 @@ public void register(ContractProcessor processor) { mutateConfiguration(() -> registerInternal(processor)); } + /** + * Registers a processor mapping for an explicit BlueId without supplying + * provider content for that BlueId. + * + *

A standalone processor cannot calculate initialization Content BlueIds + * from this registration alone. It must also have a verified provider-backed + * snapshot manager/Blue runtime or exact canonical registration evidence; + * otherwise initialization fails explicitly with {@code ProviderUnavailable}.

+ */ public void register(String blueId, ContractProcessor processor) { mutateConfiguration(() -> { Objects.requireNonNull(processor, "processor"); @@ -128,6 +140,27 @@ public void register(String blueId, ContractProcessor proces }); } + /** + * Registers both the Java processor mapping and the exact canonical Blue + * type content needed to resolve that mapping outside a configured + * Language runtime. + * + *

The legacy {@link #register(String, ContractProcessor)} overload does + * not imply any type content. In particular, a Java class name is never + * interpreted as the canonical node for the supplied BlueId.

+ */ + public void register(String blueId, + Node canonicalTypeNode, + ContractProcessor processor) { + mutateConfiguration(() -> { + Objects.requireNonNull(processor, "processor"); + Node canonical = validatedCanonicalTypeNode(blueId, canonicalTypeNode); + registerBlueId(blueId, processor); + registerClassLookup(processor); + canonicalTypeNodesByBlueId.put(blueId, canonical); + }); + } + private void registerInternal(ContractProcessor processor) { Objects.requireNonNull(processor, "processor"); if (processor instanceof HandlerProcessor) { @@ -240,6 +273,23 @@ public synchronized Map> processor return processorsView; } + synchronized Node canonicalTypeNode(String blueId) { + Node canonical = canonicalTypeNodesByBlueId.get(blueId); + return canonical != null ? canonical.clone() : null; + } + + synchronized Map> registeredContractTypes() { + Map> registered = new LinkedHashMap<>(); + for (Map.Entry> entry + : processorsByBlueId.entrySet()) { + Class contractType = entry.getValue().contractType(); + if (contractType != null) { + registered.put(entry.getKey(), contractType); + } + } + return Collections.unmodifiableMap(registered); + } + synchronized long version() { return version; } @@ -265,26 +315,99 @@ private void registerBlueIds(Class contractType, Contrac } } + private Node validatedCanonicalTypeNode(String blueId, Node canonicalTypeNode) { + if (blueId == null || blueId.isEmpty()) { + throw new IllegalArgumentException("blueId must not be empty"); + } + Objects.requireNonNull(canonicalTypeNode, "canonicalTypeNode"); + Node canonical = canonicalTypeNode.clone(); + String suppliedRootBlueId = canonical.getBlueId(); + if (canonical.isReferenceOnly()) { + throw new IllegalArgumentException( + "Missing provider content for registered contract BlueId " + blueId); + } + if (suppliedRootBlueId != null) { + if (!blueId.equals(suppliedRootBlueId)) { + throw providerBlueIdMismatch(blueId, suppliedRootBlueId); + } + canonical.blueId(null); + } + String calculatedBlueId = BlueIdCalculator.calculateBlueId(canonical); + if (!blueId.equals(calculatedBlueId)) { + throw providerBlueIdMismatch(blueId, calculatedBlueId); + } + return canonical; + } + + private IllegalArgumentException providerBlueIdMismatch(String requestedBlueId, + String actualBlueId) { + return new IllegalArgumentException("Provider returned content with BlueId " + actualBlueId + + " for requested BlueId " + requestedBlueId + "."); + } + private void registerBlueId(String blueId, ContractProcessor processor) { + if (blueId == null || blueId.isEmpty()) { + throw new IllegalArgumentException("blueId must not be empty"); + } + ProcessorKind kind = requireSupportedProcessor(processor); + ContractProcessor existing = processorsByBlueId.get(blueId); + if (existing != null + && !Objects.equals(existing.contractType(), processor.contractType())) { + throw new IllegalStateException("Duplicate BlueId value: " + blueId); + } processorsByBlueId.put(blueId, processor); version++; - if (processor instanceof HandlerProcessor) { + if (kind == ProcessorKind.HANDLER) { @SuppressWarnings("unchecked") HandlerProcessor handler = (HandlerProcessor) processor; handlerProcessorsByBlueId.put(blueId, handler); - } else if (processor instanceof ChannelProcessor) { + } else if (kind == ProcessorKind.CHANNEL) { @SuppressWarnings("unchecked") ChannelProcessor channel = (ChannelProcessor) processor; channelProcessorsByBlueId.put(blueId, channel); - } else if (processor.contractType() != null && MarkerContract.class.isAssignableFrom(processor.contractType())) { + } else { @SuppressWarnings("unchecked") ContractProcessor marker = (ContractProcessor) processor; markerProcessorsByBlueId.put(blueId, marker); - } else { - throw new IllegalArgumentException("Unsupported processor type: " + processor.getClass().getName()); } } + private ProcessorKind requireSupportedProcessor( + ContractProcessor processor) { + Objects.requireNonNull(processor, "processor"); + Class contractType = processor.contractType(); + if (processor instanceof HandlerProcessor) { + if (contractType != null + && HandlerContract.class.isAssignableFrom(contractType)) { + return ProcessorKind.HANDLER; + } + throw unsupportedProcessor(processor); + } + if (processor instanceof ChannelProcessor) { + if (contractType != null + && ChannelContract.class.isAssignableFrom(contractType)) { + return ProcessorKind.CHANNEL; + } + throw unsupportedProcessor(processor); + } + if (contractType != null && MarkerContract.class.isAssignableFrom(contractType)) { + return ProcessorKind.MARKER; + } + throw unsupportedProcessor(processor); + } + + private IllegalArgumentException unsupportedProcessor( + ContractProcessor processor) { + return new IllegalArgumentException( + "Unsupported processor type: " + processor.getClass().getName()); + } + + private enum ProcessorKind { + HANDLER, + CHANNEL, + MARKER + } + private void registerClassLookup(ContractProcessor processor) { Class type = processor.contractType(); if (type == null) { diff --git a/src/main/java/blue/language/processor/ContractProcessorRegistryBuilder.java b/src/main/java/blue/language/processor/ContractProcessorRegistryBuilder.java index 7cfe7a9..cc3ea35 100644 --- a/src/main/java/blue/language/processor/ContractProcessorRegistryBuilder.java +++ b/src/main/java/blue/language/processor/ContractProcessorRegistryBuilder.java @@ -1,5 +1,6 @@ package blue.language.processor; +import blue.language.model.Node; import blue.language.processor.model.Contract; import java.util.Objects; @@ -29,11 +30,30 @@ public ContractProcessorRegistryBuilder register(ContractProcessor processor) { registry.register(blueId, processor); return this; } + /** + * Registers exact, verified canonical provider evidence together with the + * Java processor mapping. A {@link DocumentProcessor} constructed from the + * resulting registry imports the registered BlueId-to-contract-class + * mappings into its resolver. + */ + public ContractProcessorRegistryBuilder register( + String blueId, + Node canonicalTypeNode, + ContractProcessor processor) { + registry.register(blueId, canonicalTypeNode, processor); + return this; + } + public ContractProcessorRegistry build() { return registry; } diff --git a/src/main/java/blue/language/processor/DocumentProcessingRuntime.java b/src/main/java/blue/language/processor/DocumentProcessingRuntime.java index c732869..e7e9960 100644 --- a/src/main/java/blue/language/processor/DocumentProcessingRuntime.java +++ b/src/main/java/blue/language/processor/DocumentProcessingRuntime.java @@ -4,6 +4,7 @@ import blue.language.model.Node; import blue.language.processor.model.FrozenJsonPatch; import blue.language.processor.model.JsonPatch; +import blue.language.processor.registry.RuntimeBlueIds; import blue.language.processor.util.PointerUtils; import blue.language.processor.util.ProcessorPointerConstants; import blue.language.snapshot.FrozenNode; @@ -312,6 +313,42 @@ FrozenNode selectedFrozenAt(String path) { return node != null ? FrozenNode.fromResolvedNode(node) : null; } + /** + * Builds the resolved scope view required for contract recognition without + * mutating the selected document or replacing its canonical references. + */ + FrozenNode contractRecognitionScope(FrozenNode selectedScope, + FrozenNode resolvedScope) { + if (selectedScope == null || resolvedScope == null + || selectedScope.getContracts() == null + || selectedScope.getContracts().getProperties() == null + || resolvedScope.getContracts() == null + || resolvedScope.getContracts().getProperties() == null) { + return resolvedScope; + } + ProcessingSnapshotManager manager = currentSnapshotManager(); + Node recognitionScope = null; + for (String key : selectedScope.getContracts().getProperties().keySet()) { + FrozenNode effectiveContract = resolvedScope.getContracts().property(key); + if (effectiveContract == null || !effectiveContract.isReferenceOnly()) { + continue; + } + if (manager == null) { + throw new IllegalStateException( + "Contract Recognition Resolution requires provider content for contract '" + + key + "' at scope without a ProcessingSnapshotManager"); + } + FrozenNode materialized = manager.materializeVerifiedReference(effectiveContract); + if (recognitionScope == null) { + recognitionScope = resolvedScope.toNode(); + } + recognitionScope.getContracts().properties(key, materialized.toNode()); + } + return recognitionScope != null + ? FrozenNode.fromResolvedNode(recognitionScope) + : resolvedScope; + } + public Node canonicalNodeAt(String path) { String normalized = PointerUtils.normalizePointer(path); ResolvedSnapshot current = snapshot(); @@ -331,6 +368,96 @@ public FrozenNode canonicalFrozenAt(String path) { return node != null ? FrozenNode.fromResolvedNode(node) : null; } + /** + * Captures the current selected scope and its immutable canonical/resolved + * companion, then calculates that scope's standalone Content BlueId through + * the owning Language pipeline. + * + *

This method must be called at the protocol capture point. Both captured + * values are immutable and are obtained before the manager is invoked, so a + * later lifecycle mutation cannot change the identity input.

+ */ + public String calculatePreInitializationScopeContentBlueId(String scopePath) { + return calculatePreInitializationScopeContentBlueId(scopePath, null); + } + + String calculatePreInitializationScopeContentBlueId( + String scopePath, + ProcessingSnapshotManager scopeIdentitySnapshotManager) { + String normalized = PointerUtils.normalizeScope(scopePath); + metrics.incrementInitializationDocumentIdContentBlueIdCalculations(); + ProcessingSnapshotManager manager = currentSnapshotManager(); + boolean releaseScopeIdentityManager = false; + if (manager == null) { + manager = scopeIdentitySnapshotManager; + releaseScopeIdentityManager = manager != null; + } + if (manager == null) { + throw new IllegalStateException( + "Scope Content BlueId calculation requires a ProcessingSnapshotManager at scope " + + normalized); + } + + Throwable calculationFailure = null; + try { + // Capture the exact Phase 1 selected contribution before any + // identity work. Node-backed runtimes retain real Source overlay + // syntax and can be resolved afresh. Snapshot-backed runtimes are + // backed by Canonical Identity Input, which Blue Language §13.2 + // does not require to re-resolve as ordinary Source syntax; their + // already-verified immutable snapshot is therefore authoritative. + syncMaterializedView(); + Node selectedSource = materializedView.nodeAt(normalized); + FrozenNode selectedScopeContribution = selectedSource != null + ? FrozenNode.fromResolvedNode(selectedSource) + : null; + ResolvedSnapshot capturedSnapshot; + if (!selectedDocumentBacked && snapshot != null) { + // Canonical Identity Input is not required to have Source + // semantics (Blue Language §13.2). Even a successful + // re-resolution could therefore produce a different view. + // The current immutable snapshot is the verified Phase 1 + // evidence for snapshot-backed processing. + capturedSnapshot = snapshot; + } else { + capturedSnapshot = manager.fromDocumentTransient( + materializedView.copyRoot()); + } + if (capturedSnapshot == null) { + throw new IllegalStateException( + "Scope Content BlueId calculation could not capture a resolved processing state at scope " + + normalized); + } + + FrozenNode resolvedScope = capturedSnapshot.resolvedAt(normalized); + if (resolvedScope == null) { + throw new IllegalStateException( + "Scope Content BlueId calculation requires an existing selected scope at " + normalized); + } + if (selectedScopeContribution == null) { + selectedScopeContribution = capturedSnapshot.canonicalAt(normalized); + } + metrics.incrementInitializationDocumentIdCanonicalMaterializations(); + return manager.calculateScopeContentBlueId( + normalized, selectedScopeContribution, capturedSnapshot); + } catch (RuntimeException | Error failure) { + calculationFailure = failure; + throw failure; + } finally { + if (releaseScopeIdentityManager) { + try { + manager.releaseTransientState(); + } catch (RuntimeException | Error cleanupFailure) { + if (calculationFailure != null) { + calculationFailure.addSuppressed(cleanupFailure); + } else { + throw cleanupFailure; + } + } + } + } + } + public WorkingDocument workingDocument(String originScopePath) { return workingDocument(originScopePath, PatchSource.LEGACY_PUBLIC_API); } @@ -489,7 +616,23 @@ private void directWriteSnapshot(String path, Node value) { } ImmutablePatchPlanner.PatchPlan canonicalPlan = planning.canonicalPlanner.planWithExactReplacement("/", snapshotPatch); - ResolvedSnapshot next = planning.resolveCanonical(canonicalPlan.root()); + ResolvedSnapshot next; + try { + next = planning.resolveCanonical(canonicalPlan.root()); + } catch (RuntimeException resolutionFailure) { + if (!isTerminationMarkerProviderFailure(path, value, resolutionFailure)) { + throw resolutionFailure; + } + // A fatal provider error must remain reportable even though the + // unavailable reference is still present elsewhere in the + // document. The base snapshot already contains its verified + // resolved lane, so splice only the processor-owned marker into + // both immutable lanes without attempting provider resolution a + // second time. + ImmutablePatchPlanner.PatchPlan resolvedPlan = + planning.resolvedPlanner.planWithExactReplacement("/", snapshotPatch); + next = new ResolvedSnapshot(canonicalPlan.root(), resolvedPlan.root()); + } snapshot = currentSnapshotManager().cacheSnapshot(next); commitMaterializedSnapshot(snapshot); markStateAdvanced(true); @@ -499,6 +642,21 @@ private void directWriteSnapshot(String path, Node value) { } } + private boolean isTerminationMarkerProviderFailure(String path, + Node value, + RuntimeException failure) { + String normalizedPath = PointerUtils.canonicalizePointer(path); + Node type = value != null ? value.getType() : null; + if (!normalizedPath.endsWith(ProcessorPointerConstants.RELATIVE_TERMINATED) + || type == null + || !RuntimeBlueIds.PROCESSING_TERMINATED_MARKER.equals(type.getBlueId())) { + return false; + } + ProcessorErrorCategory category = ScopeIdentityErrorMapper.from(failure); + return category == ProcessorErrorCategory.ProviderUnavailable + || category == ProcessorErrorCategory.ProviderBlueIdMismatch; + } + private void applyMaterializedDirectWrite(Node root, String path, Node value) { if (value == null) { removeMaterializedPath(root, path); diff --git a/src/main/java/blue/language/processor/DocumentProcessor.java b/src/main/java/blue/language/processor/DocumentProcessor.java index ffc9b50..7f2001c 100644 --- a/src/main/java/blue/language/processor/DocumentProcessor.java +++ b/src/main/java/blue/language/processor/DocumentProcessor.java @@ -1,5 +1,6 @@ package blue.language.processor; +import blue.language.Blue; import blue.language.conformance.ConformanceEngine; import blue.language.mapping.NodeToObjectConverter; import blue.language.model.Node; @@ -100,6 +101,7 @@ public DocumentProcessor(ContractProcessorRegistry registry, ProcessingMetricsSink metricsSink) { this.contractRegistry = Objects.requireNonNull(registry, "registry"); this.contractTypeResolver = Objects.requireNonNull(contractTypeResolver, "contractTypeResolver"); + registerRegistryContractTypes(this.contractRegistry, this.contractTypeResolver); this.contractConverter = new NodeToObjectConverter(this.contractTypeResolver); this.matchingService = Objects.requireNonNull(matchingService, "matchingService"); this.contractLoader = new ContractLoader( @@ -230,6 +232,15 @@ public DocumentProcessor registerContractProcessor(ContractProcessorFor standalone initialization, configure a verified provider-backed + * snapshot manager/Blue runtime or use the exact-canonical-content overload. + * Otherwise a scope that requires the registered type fails before + * initiation with {@link ProcessorErrorCategory#ProviderUnavailable}.

+ */ public DocumentProcessor registerContractProcessor(String blueId, ContractProcessor processor) { rejectWriteUpgrade(); Lock configurationWrite = contractRegistry.configurationWriteLock(); @@ -248,6 +259,36 @@ public DocumentProcessor registerContractProcessor(String blueId, ContractProces } } + /** + * Registers an external contract processor together with its exact + * canonical Blue type content. The content is cloned and verified against + * {@code blueId} before the registry is mutated. + */ + public DocumentProcessor registerContractProcessor( + String blueId, + Node canonicalTypeNode, + ContractProcessor processor) { + rejectWriteUpgrade(); + Lock configurationWrite = contractRegistry.configurationWriteLock(); + configurationWrite.lock(); + lifecycleWrite.lock(); + try { + ensureOpen(); + Objects.requireNonNull(processor, "processor"); + registerExactContractProcessor( + contractRegistry, + contractTypeResolver, + blueId, + canonicalTypeNode, + processor); + clearCachesInternal(); + return this; + } finally { + lifecycleWrite.unlock(); + configurationWrite.unlock(); + } + } + public ContractProcessorRegistry getContractRegistry() { return contractRegistry; } @@ -280,6 +321,24 @@ ProcessingSnapshotManager snapshotManager() { return snapshotManager; } + ProcessingSnapshotManager scopeIdentitySnapshotManager() { + if (snapshotManager != null) { + return snapshotManager; + } + ContractMatchingService currentMatchingService = matchingService; + Blue languageRuntime = currentMatchingService != null + ? currentMatchingService.blue() + : null; + if (languageRuntime == null) { + return new RegisteredContractScopeIdentitySnapshotManager(contractRegistry); + } + DocumentProcessor languageProcessor = languageRuntime.getDocumentProcessor(); + ProcessingSnapshotManager languageManager = languageProcessor != this + ? languageProcessor.snapshotManager() + : null; + return languageManager != null ? languageManager.transientSequence() : null; + } + ContractMatchingService matchingService() { return matchingService; } @@ -458,6 +517,57 @@ private static TypeClassResolver defaultContractTypeResolver() { return new TypeClassResolver("blue.language.processor.model"); } + private static void registerRegistryContractTypes( + ContractProcessorRegistry registry, + TypeClassResolver resolver) { + synchronized (resolver) { + for (Map.Entry> entry + : registry.registeredContractTypes().entrySet()) { + resolver.register(entry.getKey(), entry.getValue()); + } + } + } + + private static void registerExactContractProcessor( + ContractProcessorRegistry registry, + TypeClassResolver resolver, + String blueId, + Node canonicalTypeNode, + ContractProcessor processor) { + Objects.requireNonNull(processor, "processor"); + Class contractType = processor.contractType(); + Lock configurationWrite = registry.configurationWriteLock(); + configurationWrite.lock(); + try { + synchronized (resolver) { + requireCompatibleTypeRegistration(resolver, blueId, contractType); + // Registry validation (canonical BlueId and processor shape) is + // mutation-free on failure. With both configuration locks held, + // the following resolver registration cannot conflict. + registry.register(blueId, canonicalTypeNode, processor); + resolver.register(blueId, contractType); + } + } finally { + configurationWrite.unlock(); + } + } + + private static void requireCompatibleTypeRegistration( + TypeClassResolver resolver, + String blueId, + Class contractType) { + if (blueId == null || blueId.isEmpty()) { + throw new IllegalArgumentException("blueId must not be empty"); + } + if (contractType == null) { + throw new IllegalArgumentException("clazz must not be null"); + } + Class existing = resolver.resolveClass(blueId); + if (existing != null && !existing.equals(contractType)) { + throw new IllegalStateException("Duplicate BlueId value: " + blueId); + } + } + private void registerAnnotatedContractType(Class contractType) { if (contractType != null && contractType.isAnnotationPresent(TypeBlueId.class)) { contractTypeResolver.registerAnnotatedClass(contractType); @@ -503,6 +613,12 @@ public Builder registerContractProcessor(ContractProcessor p return this; } + /** + * Registers a processor mapping without supplying provider content. + * Standalone initialization that needs this type fails with + * {@link ProcessorErrorCategory#ProviderUnavailable} unless a verified + * provider-backed manager/Blue runtime is configured. + */ public Builder registerContractProcessor(String blueId, ContractProcessor processor) { Objects.requireNonNull(processor, "processor"); this.contractRegistry.register(blueId, processor); @@ -510,6 +626,20 @@ public Builder registerContractProcessor(String blueId, ContractProcessor processor) { + Objects.requireNonNull(processor, "processor"); + registerExactContractProcessor( + this.contractRegistry, + this.contractTypeResolver, + blueId, + canonicalTypeNode, + processor); + return this; + } + public Builder withConformanceEngine(ConformanceEngine conformanceEngine) { this.conformanceEngine = conformanceEngine; return this; diff --git a/src/main/java/blue/language/processor/ImmutableJsonPatch.java b/src/main/java/blue/language/processor/ImmutableJsonPatch.java index dd68ac4..bd3d2f7 100644 --- a/src/main/java/blue/language/processor/ImmutableJsonPatch.java +++ b/src/main/java/blue/language/processor/ImmutableJsonPatch.java @@ -98,6 +98,23 @@ FrozenNode resolvedValue() { return resolvedValue; } + ImmutableJsonPatch withResolvedValue(FrozenNode replacement) { + if (op == JsonPatch.Op.REMOVE) { + return this; + } + FrozenNode checked = Objects.requireNonNull(replacement, "resolved patch value"); + if (checked.isStrictCanonical()) { + throw new IllegalArgumentException("Resolved patch value must use resolved construction mode"); + } + return new ImmutableJsonPatch(op, + authoredPath, + path, + authoredValue, + canonicalValue, + checked, + metrics); + } + FrozenNode valueFor(FrozenNode root) { if (op == JsonPatch.Op.REMOVE) { return null; diff --git a/src/main/java/blue/language/processor/PatchPlanningEngine.java b/src/main/java/blue/language/processor/PatchPlanningEngine.java index b89d4c8..8a11c35 100644 --- a/src/main/java/blue/language/processor/PatchPlanningEngine.java +++ b/src/main/java/blue/language/processor/PatchPlanningEngine.java @@ -192,16 +192,18 @@ private BatchPatchResult plan(List patches, ImmutablePatchPlanner.PatchPlan canonicalPlan = exactReplacement ? canonicalPlanner.planWithExactReplacement(originScopePath, prepared) : canonicalPlanner.plan(originScopePath, prepared); + ImmutableJsonPatch resolvedPatch = resolveProcessorManagedValue( + prepared, canonicalPlan); ImmutablePatchPlanner resolvedPlanner = ImmutablePatchPlanner.forFrozen(workingResolved); ImmutablePatchPlanner.PatchPlan resolvedPlan = exactReplacement - ? resolvedPlanner.planWithExactReplacement(originScopePath, prepared) - : resolvedPlanner.plan(originScopePath, prepared); + ? resolvedPlanner.planWithExactReplacement(originScopePath, resolvedPatch) + : resolvedPlanner.plan(originScopePath, resolvedPatch); PatchImpact impact = impactAnalyzer.analyze(exactReplacement, workingCanonical, workingResolved, canonicalPlan, resolvedPlan, - prepared); + resolvedPatch); if (impact.resolvedScalarMetadataPreservationRequired()) { resolvedPlan = resolvedPlanner.planWithPreservedResolvedScalarMetadata( originScopePath, prepared); @@ -211,7 +213,7 @@ private BatchPatchResult plan(List patches, && authoritativeFallbackReason == null) { authoritativeFallbackReason = impact.fallbackReason(); } - BatchPatchRecord record = new BatchPatchRecord(prepared, + BatchPatchRecord record = new BatchPatchRecord(resolvedPatch, canonicalPlan, resolvedPlan, impact, @@ -438,4 +440,18 @@ private boolean isProcessorManagedConformanceBypass(ImmutablePatchPlanner.PatchP String initialized = ProcessorPointerConstants.RELATIVE_INITIALIZED; return PointerUtils.descendantOrEqual(relativePath, initialized); } + + private ImmutableJsonPatch resolveProcessorManagedValue( + ImmutableJsonPatch patch, + ImmutablePatchPlanner.PatchPlan canonicalPlan) { + if (!exactReplacement + || authoritativeSnapshotManager == null + || patch.op() == JsonPatch.Op.REMOVE + || !isProcessorManagedConformanceBypass(canonicalPlan)) { + return patch; + } + ResolvedSnapshot resolvedValue = authoritativeSnapshotManager.fromDocumentTransient( + patch.canonicalValue().toNode()); + return patch.withResolvedValue(resolvedValue.frozenResolvedRoot()); + } } diff --git a/src/main/java/blue/language/processor/ProcessingMetricsSink.java b/src/main/java/blue/language/processor/ProcessingMetricsSink.java index be13ce5..3701b92 100644 --- a/src/main/java/blue/language/processor/ProcessingMetricsSink.java +++ b/src/main/java/blue/language/processor/ProcessingMetricsSink.java @@ -472,6 +472,14 @@ default void incrementProcessorManagedMarkerIncrementalResolutions() { addMetric("processorManagedMarkerIncrementalResolutions", 1L); } + default void incrementInitializationDocumentIdContentBlueIdCalculations() { + addMetric("initializationDocumentIdContentBlueIdCalculations", 1L); + } + + default void incrementInitializationDocumentIdCanonicalMaterializations() { + addMetric("initializationDocumentIdCanonicalMaterializations", 1L); + } + default void incrementInitializationDocumentIdUncheckedCalculations() { addMetric("initializationDocumentIdUncheckedCalculations", 1L); } diff --git a/src/main/java/blue/language/processor/ProcessingSnapshotManager.java b/src/main/java/blue/language/processor/ProcessingSnapshotManager.java index dc59f57..8e15033 100644 --- a/src/main/java/blue/language/processor/ProcessingSnapshotManager.java +++ b/src/main/java/blue/language/processor/ProcessingSnapshotManager.java @@ -7,6 +7,8 @@ import blue.language.snapshot.ResolvedSnapshot; import blue.language.snapshot.FrozenNode; +import java.util.Objects; + /** * Bridges the mutable processor runtime to the canonical immutable snapshot layer. */ @@ -23,6 +25,63 @@ default ResolvedSnapshot fromDocumentTransient(Node document) { return fromDocument(document); } + /** + * Calculates the Content BlueId of one selected processing scope as a + * standalone Blue Language document. + * + *

The supplied snapshot and selected subtree are an immutable capture of + * one processing state. Implementations must use the same preprocessing, + * provider-verification, resolution, and cache-generation context that owns + * this manager. A canonical fragment of the containing document is not, in + * general, a standalone scope identity input.

+ * + *

The captured selected contribution and completed resolved scope are + * projected to a standalone Source-equivalent document. The projection is + * accepted only when resolving it through this manager's full transient + * Language pipeline reproduces the exact captured resolved scope. The + * resolved view is never hashed directly and unchecked BlueId calculation + * is never used.

+ */ + default String calculateScopeContentBlueId(String scopePath, + FrozenNode selectedScope, + ResolvedSnapshot capturedDocumentSnapshot) { + return ScopeSourceProjection.project( + scopePath, selectedScope, capturedDocumentSnapshot, this) + .contentBlueId(); + } + + /** + * Materializes one pure reference through this manager's verified provider + * and cache-generation context for a runtime view that requires its + * content, such as Contract Recognition Resolution. + * + *

The returned node is resolved content, not a selected-document + * mutation. The reference is placed in a type position solely to require + * the normal Language resolver to fetch and verify its target. This keeps + * custom managers conservative while avoiding an unchecked provider side + * channel.

+ */ + default FrozenNode materializeVerifiedReference(FrozenNode reference) { + FrozenNode checked = Objects.requireNonNull(reference, "reference"); + if (!checked.isReferenceOnly()) { + return checked; + } + String blueId = checked.getReferenceBlueId(); + ResolvedSnapshot probe = Objects.requireNonNull( + fromDocumentTransient(new Node().type(new Node().blueId(blueId))), + "materializedReferenceSnapshot"); + FrozenNode materialized = probe.frozenResolvedRoot().getType(); + if (materialized == null || materialized.isReferenceOnly()) { + throw new IllegalArgumentException( + "Unable to materialize required reference for blueId: " + blueId); + } + Node content = materialized.toNode(); + // Resolved views may retain the source reference BlueId as provenance. + // It must not become a mixed-reference shape when consumed as content. + content.blueId(null); + return FrozenNode.fromResolvedNode(content); + } + /** * Opens a short-lived manager for one observable patch sequence. The * default preserves historical manager behavior; cache-aware managers can diff --git a/src/main/java/blue/language/processor/ProcessorEngine.java b/src/main/java/blue/language/processor/ProcessorEngine.java index caa7fac..4cd0d2a 100644 --- a/src/main/java/blue/language/processor/ProcessorEngine.java +++ b/src/main/java/blue/language/processor/ProcessorEngine.java @@ -34,6 +34,10 @@ private ProcessorEngine() { static DocumentProcessingResult initializeDocument(DocumentProcessor owner, Node document) { Objects.requireNonNull(document, "document"); + DocumentProcessingResult invalid = validateProcessingDocument(document); + if (invalid != null) { + return invalid; + } if (isInitialized(owner, document)) { throw new IllegalStateException("Document already initialized"); } @@ -50,6 +54,10 @@ static DocumentProcessingResult initializeDocument(DocumentProcessor owner, Node static DocumentProcessingResult initializeDocument(DocumentProcessor owner, ResolvedSnapshot snapshot) { Objects.requireNonNull(snapshot, "snapshot"); + DocumentProcessingResult invalid = validateProcessingDocument(snapshot.frozenResolvedRoot()); + if (invalid != null) { + return invalid.withSnapshot(snapshot); + } if (isInitialized(owner, snapshot)) { throw new IllegalStateException("Document already initialized"); } diff --git a/src/main/java/blue/language/processor/ProcessorErrorCategory.java b/src/main/java/blue/language/processor/ProcessorErrorCategory.java index e432bd7..b5fe7af 100644 --- a/src/main/java/blue/language/processor/ProcessorErrorCategory.java +++ b/src/main/java/blue/language/processor/ProcessorErrorCategory.java @@ -7,6 +7,8 @@ public enum ProcessorErrorCategory { InvalidProcessingDocument, UnsupportedContract, InvalidReservedMarker, + ProviderUnavailable, + ProviderBlueIdMismatch, InvalidRuntimePointer, BoundaryViolation, ReservedKeyWrite, diff --git a/src/main/java/blue/language/processor/RegisteredContractScopeIdentitySnapshotManager.java b/src/main/java/blue/language/processor/RegisteredContractScopeIdentitySnapshotManager.java new file mode 100644 index 0000000..bb61766 --- /dev/null +++ b/src/main/java/blue/language/processor/RegisteredContractScopeIdentitySnapshotManager.java @@ -0,0 +1,66 @@ +package blue.language.processor; + +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.processor.model.JsonPatch; +import blue.language.snapshot.ResolvedSnapshot; + +import java.util.Collections; + +/** + * Short-lived Language pipeline for a standalone {@link DocumentProcessor}. + * External content is available only when it was supplied explicitly with the + * corresponding contract registration. Built-in Language and Contracts types + * remain available through the normal {@link Blue} provider composition. + */ +final class RegisteredContractScopeIdentitySnapshotManager implements ProcessingSnapshotManager { + + private final Blue languageRuntime; + private final ProcessingSnapshotManager delegate; + + RegisteredContractScopeIdentitySnapshotManager(ContractProcessorRegistry registry) { + this.languageRuntime = new Blue(blueId -> { + Node canonicalTypeNode = registry.canonicalTypeNode(blueId); + if (canonicalTypeNode != null) { + return Collections.singletonList(canonicalTypeNode); + } + if (registry.processors().containsKey(blueId)) { + throw new IllegalArgumentException( + "Missing provider content for registered contract BlueId " + blueId); + } + return null; + }); + this.delegate = languageRuntime.getDocumentProcessor() + .snapshotManager() + .transientSequence(); + } + + @Override + public ResolvedSnapshot fromDocument(Node document) { + return delegate.fromDocumentTransient(document); + } + + @Override + public ResolvedSnapshot fromDocumentTransient(Node document) { + return delegate.fromDocumentTransient(document); + } + + @Override + public ResolvedSnapshot applyPatch(ResolvedSnapshot snapshot, JsonPatch patch) { + return delegate.applyPatch(snapshot, patch); + } + + @Override + public ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { + return delegate.cacheSnapshot(snapshot); + } + + @Override + public void releaseTransientState() { + try { + delegate.releaseTransientState(); + } finally { + languageRuntime.close(); + } + } +} diff --git a/src/main/java/blue/language/processor/ScopeExecutor.java b/src/main/java/blue/language/processor/ScopeExecutor.java index 4b6c8c1..fbd71af 100644 --- a/src/main/java/blue/language/processor/ScopeExecutor.java +++ b/src/main/java/blue/language/processor/ScopeExecutor.java @@ -58,7 +58,6 @@ private void initializeScope(String scopePath, boolean chargeScopeEntry, boolean String normalizedScope = ProcessorEngine.normalizeScope(scopePath); Set processedEmbedded = new LinkedHashSet<>(); ContractBundle bundle = null; - FrozenNode preInitSnapshot = null; ScopeRuntimeContext scopeContext = runtime.scope(normalizedScope); if ("/".equals(normalizedScope)) { runtime.setScopeEmbeddedDepth(normalizedScope, 0); @@ -95,17 +94,20 @@ private void initializeScope(String scopePath, boolean chargeScopeEntry, boolean return; } - if (preInitSnapshot == null) { - FrozenNode canonicalScopeNode = runtime.canonicalFrozenAt(normalizedScope); - preInitSnapshot = canonicalScopeNode != null ? canonicalScopeNode : scopeNode; - } - - long loadStart = System.nanoTime(); try { - bundle = owner.contractLoader().load( - selectedScopeAt(normalizedScope), scopeNode, normalizedScope, metrics); - } finally { - metrics.addBundleScopeContractLoadNanos(System.nanoTime() - loadStart); + bundle = loadBundle(scopeNode, normalizedScope, metrics); + } catch (RuntimeException failure) { + ProcessorErrorCategory category = ScopeIdentityErrorMapper.from(failure); + if (category != ProcessorErrorCategory.ProviderUnavailable + && category != ProcessorErrorCategory.ProviderBlueIdMismatch) { + throw failure; + } + execution.enterFatalTermination(normalizedScope, + null, + category, + execution.fatalReason(failure, + "Contract Recognition Resolution failed")); + return; } bundles.put(normalizedScope, bundle); @@ -126,13 +128,10 @@ private void initializeScope(String scopePath, boolean chargeScopeEntry, boolean processedEmbedded.add(childScope); scopeContext.recordProcessedEmbeddedPath(childScope); runtime.setScopeEmbeddedDepth(childScope, runtime.scopeEmbeddedDepth(normalizedScope) + 1); + FrozenNode selectedChildNode = runtime.selectedFrozenAt(childScope); FrozenNode childNode = runtime.resolvedFrozenAt(childScope); if (childNode != null) { - if (!isObjectScope(childNode)) { - if (!finalizeAfterInitialization) { - continue; - } - initializeCurrentScopeIfNeeded(normalizedScope, bundle); + if (!isObjectScope(selectedChildNode) || !isObjectScope(childNode)) { execution.enterFatalTermination(normalizedScope, bundle, ProcessorErrorCategory.BoundaryViolation, @@ -157,11 +156,23 @@ private void initializeScope(String scopePath, boolean chargeScopeEntry, boolean } runtime.chargeInitialization(); - String documentId = initializationDocumentId(preInitSnapshot); + String documentId; + try { + documentId = runtime.calculatePreInitializationScopeContentBlueId( + normalizedScope, owner.scopeIdentitySnapshotManager()); + } catch (RuntimeException ex) { + execution.enterFatalTermination(normalizedScope, + bundle, + ScopeIdentityErrorMapper.from(ex), + execution.fatalReason(ex, "Scope Content BlueId calculation failed")); + return; + } Node lifecycleEvent = ProcessorEngine.createLifecycleInitiatedEvent(documentId); ProcessorExecutionContext context = execution.createContext(normalizedScope, bundle, lifecycleEvent, true); deliverLifecycle(normalizedScope, bundle, lifecycleEvent, false); - addInitializationMarker(context, documentId); + if (!execution.shouldStopScopeWork(normalizedScope)) { + addInitializationMarker(context, documentId); + } if (finalizeAfterInitialization && !execution.shouldStopScopeWork(normalizedScope)) { ContractBundle refreshed = refreshBundle(normalizedScope); finalizeScope(normalizedScope, refreshed); @@ -523,14 +534,10 @@ private ContractBundle processEmbeddedChildren(String scopePath, Node event) { bundle = refreshBundle(normalizedScope); continue; } + FrozenNode selectedChildNode = runtime.selectedFrozenAt(childScope); FrozenNode childNode = runtime.resolvedFrozenAt(childScope); if (childNode != null) { - if (!isObjectScope(childNode)) { - if ("initialize".equals(eventKind(event))) { - bundle = refreshBundle(normalizedScope); - continue; - } - initializeCurrentScopeIfNeeded(normalizedScope, bundle); + if (!isObjectScope(selectedChildNode) || !isObjectScope(childNode)) { execution.enterFatalTermination(normalizedScope, bundle, ProcessorErrorCategory.BoundaryViolation, @@ -576,8 +583,11 @@ private ContractBundle refreshBundle(String scopePath) { private ContractBundle loadBundle(FrozenNode scopeNode, String normalizedScope, ProcessingMetricsSink metrics) { long loadStart = System.nanoTime(); try { + FrozenNode selectedScope = selectedScopeAt(normalizedScope); + FrozenNode recognitionScope = runtime.contractRecognitionScope( + selectedScope, scopeNode); return owner.contractLoader().load( - selectedScopeAt(normalizedScope), scopeNode, normalizedScope, metrics); + selectedScope, recognitionScope, normalizedScope, metrics); } finally { metrics.addBundleScopeContractLoadNanos(System.nanoTime() - loadStart); } @@ -612,39 +622,10 @@ private boolean isObjectScope(FrozenNode node) { return node != null && node.getValue() == null && !node.hasItems() - && !node.isReferenceOnly() + && node.getReferenceBlueId() == null && node.getPreviousBlueId() == null; } - private String eventKind(Node event) { - if (event == null || event.getProperties() == null) { - return null; - } - Node kind = event.getProperties().get("kind"); - Object value = kind != null ? kind.getValue() : null; - return value != null ? String.valueOf(value) : null; - } - - /** - * Compatibility invariant: - * /contracts/initialized/documentId is the historical unchecked identity of - * the selected pre-initialization node. It is not the canonical state BlueId. - * These values can differ for payload-only lists. Do not replace this - * calculation with FrozenNode.blueId(). - */ - private String initializationDocumentId(FrozenNode preInitializationSnapshot) { - ProcessingMetricsSink metrics = owner.metricsSink(); - metrics.incrementInitializationDocumentIdUncheckedCalculations(); - Node materialized; - if (preInitializationSnapshot != null) { - metrics.incrementInitializationDocumentIdNodeMaterializations(); - materialized = preInitializationSnapshot.toNode(); - } else { - materialized = new Node(); - } - return BlueIdCalculator.calculateUncheckedBlueId(materialized); - } - private void addInitializationMarker(ProcessorExecutionContext context, String documentId) { FrozenNode marker = ProcessorMarkerFactory.initialized(documentId); String pointer = context.resolvePointer(ProcessorPointerConstants.RELATIVE_INITIALIZED); @@ -652,22 +633,6 @@ private void addInitializationMarker(ProcessorExecutionContext context, String d context.applyBufferedEffects(); } - private void initializeCurrentScopeIfNeeded(String scopePath, ContractBundle bundle) { - String normalizedScope = ProcessorEngine.normalizeScope(scopePath); - if (runtime.hasInitializationMarker(normalizedScope) || execution.shouldStopScopeWork(normalizedScope)) { - return; - } - FrozenNode canonicalScopeNode = runtime.canonicalFrozenAt(normalizedScope); - String documentId = initializationDocumentId(canonicalScopeNode); - runtime.chargeInitialization(); - Node lifecycleEvent = ProcessorEngine.createLifecycleInitiatedEvent(documentId); - ProcessorExecutionContext context = execution.createContext(normalizedScope, bundle, lifecycleEvent, true); - deliverLifecycle(normalizedScope, bundle, lifecycleEvent, false); - if (!execution.shouldStopScopeWork(normalizedScope)) { - addInitializationMarker(context, documentId); - } - } - private void finalizeScope(String scopePath, ContractBundle bundle) { if (bundle == null) { return; @@ -874,10 +839,9 @@ private boolean semanticallyEqual(FrozenNode left, FrozenNode right) { if (left == null || right == null) { return left == right; } - // Reserved runtime subtrees are stored as unchecked canonical state, - // while a public FrozenJsonPatch carries a strict authored value. The - // boundary's historical oracle is the unchecked identity used by the - // mutable lane, so normalize both representations to that identity. + // Reserved runtime subtrees can arrive through different construction + // modes; compare their authored form so preservation checks remain + // representation-insensitive. return BlueIdCalculator.calculateUncheckedBlueId(left.toNode()) .equals(BlueIdCalculator.calculateUncheckedBlueId(right.toNode())); } diff --git a/src/main/java/blue/language/processor/ScopeIdentityErrorMapper.java b/src/main/java/blue/language/processor/ScopeIdentityErrorMapper.java new file mode 100644 index 0000000..ddf086f --- /dev/null +++ b/src/main/java/blue/language/processor/ScopeIdentityErrorMapper.java @@ -0,0 +1,28 @@ +package blue.language.processor; + +import blue.language.BlueLanguageErrorCategory; +import blue.language.BlueLanguageErrorClassifier; + +/** + * Maps Blue Language failures raised while calculating scope identity to the + * processor diagnostic categories exposed by Blue Contracts conformance. + */ +final class ScopeIdentityErrorMapper { + + private ScopeIdentityErrorMapper() { + } + + static ProcessorErrorCategory from(Throwable failure) { + return from(BlueLanguageErrorClassifier.classify(failure)); + } + + static ProcessorErrorCategory from(BlueLanguageErrorCategory category) { + if (category == BlueLanguageErrorCategory.ProviderUnavailable) { + return ProcessorErrorCategory.ProviderUnavailable; + } + if (category == BlueLanguageErrorCategory.ProviderBlueIdMismatch) { + return ProcessorErrorCategory.ProviderBlueIdMismatch; + } + return ProcessorErrorCategory.InternalProcessorError; + } +} diff --git a/src/main/java/blue/language/processor/ScopeSourceProjection.java b/src/main/java/blue/language/processor/ScopeSourceProjection.java new file mode 100644 index 0000000..8d3e2aa --- /dev/null +++ b/src/main/java/blue/language/processor/ScopeSourceProjection.java @@ -0,0 +1,508 @@ +package blue.language.processor; + +import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; +import blue.language.snapshot.ResolvedSnapshot; +import blue.language.utils.JsonPointer; +import blue.language.utils.MergeReverser; +import blue.language.utils.Nodes; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static blue.language.utils.Properties.LIST_CONTROL_REPLACE; +import static blue.language.utils.Properties.LIST_MERGE_POLICY_APPEND_ONLY; + +/** + * Immutable proof that a selected scope was projected to a standalone + * Source-equivalent document without changing its completed resolved view. + */ +final class ScopeSourceProjection { + + private final String scopePath; + private final FrozenNode standaloneSource; + private final ResolvedSnapshot standaloneSnapshot; + + private ScopeSourceProjection(String scopePath, + FrozenNode standaloneSource, + ResolvedSnapshot standaloneSnapshot) { + this.scopePath = scopePath; + this.standaloneSource = standaloneSource; + this.standaloneSnapshot = standaloneSnapshot; + } + + static ScopeSourceProjection project(String scopePath, + FrozenNode selectedScopeContribution, + ResolvedSnapshot capturedDocumentSnapshot, + ProcessingSnapshotManager snapshotManager) { + String normalizedScope = JsonPointer.canonicalize(scopePath); + ResolvedSnapshot captured = Objects.requireNonNull( + capturedDocumentSnapshot, "capturedDocumentSnapshot"); + ProcessingSnapshotManager manager = Objects.requireNonNull( + snapshotManager, "snapshotManager"); + FrozenNode capturedResolvedScope = captured.resolvedAt(normalizedScope); + if (capturedResolvedScope == null) { + throw new IllegalStateException( + "Cannot project missing selected scope " + normalizedScope); + } + + Node selectedContribution = selectedScopeContribution != null + ? selectedScopeContribution.toNode() + : null; + FrozenNode canonicalFragment = captured.canonicalAt(normalizedScope); + + // Prefer the actually selected Phase 1 contribution. For Node-backed + // processing this is already a real Source-equivalent subtree and is + // the only representation that can retain authoring controls and + // preprocessing provenance exactly. Add only context that a parent + // field supplied and that a standalone root therefore needs explicitly. + Node standaloneSource = selectedContribution != null + ? selectedContribution.clone() + : null; + if (standaloneSource != null) { + makeStandaloneRoot(standaloneSource, selectedContribution, + canonicalFragment, capturedResolvedScope); + } + + ResolvedSnapshot projected = null; + RuntimeException selectedProjectionFailure = null; + if (standaloneSource != null) { + try { + projected = Objects.requireNonNull( + manager.fromDocumentTransient(standaloneSource.clone()), + "projectedStandaloneSnapshot"); + if (!sameCompletedResolvedStructure( + projected.frozenResolvedRoot(), capturedResolvedScope)) { + projected = null; + } + } catch (RuntimeException failure) { + ProcessorErrorCategory category = ScopeIdentityErrorMapper.from(failure); + if (category == ProcessorErrorCategory.ProviderUnavailable + || category == ProcessorErrorCategory.ProviderBlueIdMismatch) { + throw failure; + } + selectedProjectionFailure = failure; + projected = null; + } + } + + if (projected == null) { + // A ResolvedSnapshot exposes Canonical Identity Input, which §13.2 + // does not require to be valid Source overlay syntax (final inherited + // lists are the important example). Start from captured canonical + // provenance, augment the parent-supplied root context, and derive + // the desired standalone canonical overlay. Convert only final + // inherited lists back to valid Source controls; never use the + // general author-facing resolved-view minimizer as an identity + // reconstruction oracle. + Node canonicalSeed = canonicalFragment != null + ? canonicalFragment.toNode() + : new Node(); + makeStandaloneRoot(canonicalSeed, selectedContribution, + canonicalFragment, capturedResolvedScope); + Node desiredStandaloneCanonical = new MergeReverser() + .reverseToCanonicalOverlay( + capturedResolvedScope.toNode(), canonicalSeed); + standaloneSource = sourceifyCanonicalFinalLists( + desiredStandaloneCanonical, + capturedResolvedScope.toNode(), + capturedResolvedScope.getType() != null + ? capturedResolvedScope.getType().toNode() + : null); + makeStandaloneRoot(standaloneSource, selectedContribution, + canonicalFragment, capturedResolvedScope); + try { + projected = Objects.requireNonNull( + manager.fromDocumentTransient(standaloneSource.clone()), + "projectedStandaloneSnapshot"); + } catch (RuntimeException failure) { + if (selectedProjectionFailure != null) { + failure.addSuppressed(selectedProjectionFailure); + } + throw failure; + } + } + + if (!sameCompletedResolvedStructure( + projected.frozenResolvedRoot(), capturedResolvedScope)) { + IllegalStateException failure = new IllegalStateException( + "Standalone selected-scope projection changed the resolved view at " + + normalizedScope + + "; first difference: " + + firstResolvedDifference( + capturedResolvedScope, + projected.frozenResolvedRoot(), + normalizedScope)); + if (selectedProjectionFailure != null) { + failure.addSuppressed(selectedProjectionFailure); + } + throw failure; + } + if (!projected.frozenCanonicalRoot().isStrictBlueIdValidation()) { + projected = projected.toStrictBlueIdValidatedCanonical(); + } + return new ScopeSourceProjection( + normalizedScope, + FrozenNode.fromResolvedNode(standaloneSource), + projected); + } + + private static void makeStandaloneRoot(Node standaloneSource, + Node selectedContribution, + FrozenNode canonicalFragment, + FrozenNode capturedResolvedScope) { + if (standaloneSource.isReferenceOnly()) { + return; + } + + Node canonical = canonicalFragment != null ? canonicalFragment.toNode() : null; + if (canonical != null && canonical.isReferenceOnly()) { + standaloneSource.replaceWith(canonical); + return; + } + + Node effectiveType = capturedResolvedScope.getType() != null + ? capturedResolvedScope.getType().toNode() + : null; + if ((standaloneSource.getType() == null + || Nodes.isEmptyNode(standaloneSource.getType())) + && effectiveType != null) { + standaloneSource.type(referenceOrInline(effectiveType)); + } + + String selectedName = selectedContribution != null + ? selectedContribution.getName() + : null; + String canonicalName = canonical != null ? canonical.getName() : null; + if (standaloneSource.getName() == null) { + standaloneSource.name(selectedName != null + ? selectedName + : canonicalName != null ? canonicalName : capturedResolvedScope.getName()); + } + String selectedDescription = selectedContribution != null + ? selectedContribution.getDescription() + : null; + String canonicalDescription = canonical != null ? canonical.getDescription() : null; + if (standaloneSource.getDescription() == null) { + standaloneSource.description(selectedDescription != null + ? selectedDescription + : canonicalDescription != null + ? canonicalDescription + : capturedResolvedScope.getDescription()); + } + + Node selectedContracts = selectedContribution != null + ? selectedContribution.getContracts() + : null; + if (selectedContracts != null + && Nodes.isEmptyNode(selectedContracts) + && standaloneSource.getContracts() == null) { + standaloneSource.contracts(new Node()); + } + if (capturedResolvedScope.getContracts() != null + && Nodes.isEmptyNode(capturedResolvedScope.getContracts().toNode()) + && standaloneSource.getContracts() == null) { + // Empty contracts are hash-neutral but observable in the selected + // resolved scope. Retain them so the projection proof compares the + // complete structural view rather than a cleaned approximation. + standaloneSource.contracts(new Node()); + } + } + + private static Node referenceOrInline(Node effectiveType) { + return effectiveType.getBlueId() != null + ? new Node().blueId(effectiveType.getBlueId()) + : effectiveType.clone(); + } + + private static boolean sameCompletedResolvedStructure(FrozenNode left, + FrozenNode right) { + return left.sameResolvedStructure(right); + } + + private static String firstResolvedDifference(FrozenNode captured, + FrozenNode projected, + String path) { + if (captured == projected) { + return path + " (unknown representation difference)"; + } + if (captured == null || projected == null) { + return path + " (captured=" + (captured != null) + + ", projected=" + (projected != null) + ")"; + } + if (!Objects.equals(captured.getName(), projected.getName())) { + return path + "/name (captured=" + captured.getName() + + ", projected=" + projected.getName() + + ", capturedBlueId=" + captured.getReferenceBlueId() + + ", projectedBlueId=" + projected.getReferenceBlueId() + ")"; + } + if (!Objects.equals(captured.getDescription(), projected.getDescription())) { + return path + "/description"; + } + if (!Objects.deepEquals(captured.getValue(), projected.getValue())) { + return path + "/value"; + } + if (!Objects.equals(captured.getReferenceBlueId(), projected.getReferenceBlueId())) { + return path + "/blueId"; + } + if (!Objects.equals(captured.getMergePolicy(), projected.getMergePolicy())) { + return path + "/mergePolicy"; + } + if (!Objects.equals(captured.getPreviousBlueId(), projected.getPreviousBlueId())) { + return path + "/$previous"; + } + if (!Objects.equals(captured.getPosition(), projected.getPosition())) { + return path + "/$pos"; + } + String nested = firstNestedDifference(captured.getType(), projected.getType(), path + "/type"); + if (nested != null) { + return nested; + } + nested = firstNestedDifference(captured.getItemType(), projected.getItemType(), + path + "/itemType"); + if (nested != null) { + return nested; + } + nested = firstNestedDifference(captured.getKeyType(), projected.getKeyType(), + path + "/keyType"); + if (nested != null) { + return nested; + } + nested = firstNestedDifference(captured.getValueType(), projected.getValueType(), + path + "/valueType"); + if (nested != null) { + return nested; + } + nested = firstNestedDifference(captured.getContracts(), projected.getContracts(), + path + "/contracts"); + if (nested != null) { + return nested; + } + nested = firstNestedDifference(captured.getBlue(), projected.getBlue(), path + "/blue"); + if (nested != null) { + return nested; + } + List capturedItems = captured.getItems(); + List projectedItems = projected.getItems(); + if (capturedItems == null || projectedItems == null) { + if (capturedItems != projectedItems) { + return path + "/items"; + } + } else { + if (capturedItems.size() != projectedItems.size()) { + return path + "/items/size"; + } + for (int index = 0; index < capturedItems.size(); index++) { + nested = firstNestedDifference(capturedItems.get(index), projectedItems.get(index), + path + "/items/" + index); + if (nested != null) { + return nested; + } + } + } + Map capturedProperties = captured.getProperties(); + Map projectedProperties = projected.getProperties(); + if (capturedProperties == null || projectedProperties == null) { + if (capturedProperties != projectedProperties) { + return path + "/properties"; + } + } else { + if (!capturedProperties.keySet().equals(projectedProperties.keySet())) { + return path + "/properties/keys"; + } + for (String key : capturedProperties.keySet()) { + nested = firstNestedDifference(capturedProperties.get(key), + projectedProperties.get(key), path + "/" + key); + if (nested != null) { + return nested; + } + } + } + if (!Objects.equals(String.valueOf(captured.getSchema()), + String.valueOf(projected.getSchema()))) { + return path + "/schema"; + } + return path + " (unknown representation difference)"; + } + + private static String firstNestedDifference(FrozenNode captured, + FrozenNode projected, + String path) { + if (captured == projected) { + return null; + } + if (captured == null || projected == null) { + return path; + } + if (captured.sameResolvedStructure(projected)) { + return null; + } + return firstResolvedDifference(captured, projected, path); + } + + /** + * Converts Canonical Identity Input back into a valid Source-equivalent + * overlay without discarding its captured labels or pure references. + * Canonical and Source forms differ materially only for final inherited + * list payloads here; those payloads are expressed as deterministic + * positional replacements or append-only suffixes. + */ + private static Node sourceifyCanonicalFinalLists(Node canonical, + Node resolved, + Node inherited) { + if (canonical == null) { + return null; + } + if (canonical.isReferenceOnly()) { + return canonical.clone(); + } + + Node source = canonical.clone(); + Node context = inherited != null + ? inherited + : resolved != null ? resolved.getType() : null; + + source.type(sourceifyMetadata( + canonical.getType(), + resolved != null ? resolved.getType() : null, + context != null ? context.getType() : null)); + source.itemType(sourceifyMetadata( + canonical.getItemType(), + resolved != null ? resolved.getItemType() : null, + context != null ? context.getItemType() : null)); + source.keyType(sourceifyMetadata( + canonical.getKeyType(), + resolved != null ? resolved.getKeyType() : null, + context != null ? context.getKeyType() : null)); + source.valueType(sourceifyMetadata( + canonical.getValueType(), + resolved != null ? resolved.getValueType() : null, + context != null ? context.getValueType() : null)); + + if (canonical.getContracts() != null) { + source.contracts(sourceifyCanonicalFinalLists( + canonical.getContracts(), + resolved != null ? resolved.getContracts() : null, + context != null ? context.getContracts() : null)); + } + if (canonical.getBlue() != null) { + source.blue(sourceifyCanonicalFinalLists( + canonical.getBlue(), + resolved != null ? resolved.getBlue() : null, + context != null ? context.getBlue() : null)); + } + + if (canonical.getProperties() != null) { + for (Map.Entry entry : canonical.getProperties().entrySet()) { + String key = entry.getKey(); + Node resolvedChild = resolved != null && resolved.getProperties() != null + ? resolved.getProperties().get(key) + : null; + Node inheritedChild = context != null && context.getProperties() != null + ? context.getProperties().get(key) + : null; + source.getProperties().put(key, sourceifyCanonicalFinalLists( + entry.getValue(), resolvedChild, inheritedChild)); + } + } + + if (canonical.getItems() != null) { + source.items(sourceItemsForFinalCanonicalList( + canonical, resolved, context)); + } + return source; + } + + private static Node sourceifyMetadata(Node canonical, + Node resolved, + Node inherited) { + if (canonical == null) { + return null; + } + return sourceifyCanonicalFinalLists(canonical, resolved, inherited); + } + + private static List sourceItemsForFinalCanonicalList(Node canonical, + Node resolved, + Node inherited) { + List canonicalItems = canonical.getItems(); + List resolvedItems = resolved != null ? resolved.getItems() : null; + if (resolvedItems == null || canonicalItems.size() != resolvedItems.size()) { + throw new IllegalStateException( + "Canonical final list does not match the captured resolved list"); + } + + List inheritedItems = inherited != null ? inherited.getItems() : null; + Node itemContext = resolved.getItemType() != null + ? resolved.getItemType() + : inherited != null ? inherited.getItemType() : null; + if (inheritedItems == null) { + List sourceItems = new ArrayList<>(canonicalItems.size()); + for (int index = 0; index < canonicalItems.size(); index++) { + sourceItems.add(sourceifyCanonicalFinalLists( + canonicalItems.get(index), resolvedItems.get(index), itemContext)); + } + return sourceItems; + } + if (resolvedItems.size() < inheritedItems.size()) { + throw new IllegalStateException( + "Captured final list is shorter than its inherited list"); + } + + boolean appendOnly = LIST_MERGE_POLICY_APPEND_ONLY.equals( + resolved.getMergePolicy() != null + ? resolved.getMergePolicy() + : inherited.getMergePolicy()); + List sourceItems = new ArrayList<>(); + for (int index = 0; index < inheritedItems.size(); index++) { + Node resolvedItem = resolvedItems.get(index); + if (sameResolvedNode(resolvedItem, inheritedItems.get(index))) { + continue; + } + if (appendOnly) { + throw new IllegalStateException( + "Captured append-only list changed its inherited prefix at index " + + index); + } + Node replacement = sourceifyCanonicalFinalLists( + canonicalItems.get(index), resolvedItem, itemContext); + sourceItems.add(new Node() + .position(index) + .properties(LIST_CONTROL_REPLACE, replacement)); + } + for (int index = inheritedItems.size(); index < resolvedItems.size(); index++) { + sourceItems.add(sourceifyCanonicalFinalLists( + canonicalItems.get(index), resolvedItems.get(index), itemContext)); + } + return sourceItems.isEmpty() ? null : sourceItems; + } + + private static boolean sameResolvedNode(Node left, Node right) { + if (left == right) { + return true; + } + if (left == null || right == null) { + return false; + } + return FrozenNode.fromResolvedNode(left) + .sameResolvedStructure(FrozenNode.fromResolvedNode(right)); + } + + String scopePath() { + return scopePath; + } + + FrozenNode standaloneSource() { + return standaloneSource; + } + + ResolvedSnapshot standaloneSnapshot() { + return standaloneSnapshot; + } + + String contentBlueId() { + return standaloneSnapshot.blueId(); + } +} diff --git a/src/main/java/blue/language/snapshot/FrozenNode.java b/src/main/java/blue/language/snapshot/FrozenNode.java index a5216e1..8f76e5f 100644 --- a/src/main/java/blue/language/snapshot/FrozenNode.java +++ b/src/main/java/blue/language/snapshot/FrozenNode.java @@ -293,12 +293,15 @@ public static String calculateBlueId(List nodes) { } /** - * Compares the exact resolved representation of two frozen nodes without + * Compares the exact resolved graph content of two frozen nodes without * materializing mutable {@link Node} graphs first. * - *

The construction-mode fields are intentionally ignored. This matches - * converting both inputs through {@code toNode()} and - * {@code fromResolvedNode(...)} before comparing their structural keys.

+ *

Construction-mode fields and object-property insertion order are + * intentionally ignored. Object payloads are keyed maps in the Language + * model, while list-element order remains significant. This comparison is + * therefore stricter than semantic BlueId equality but may be less strict + * than {@link #resolvedStructuralKey()}, which preserves representation + * details needed by the structural interner.

*/ public boolean sameResolvedStructure(FrozenNode other) { if (this == other) { @@ -324,7 +327,10 @@ public boolean sameResolvedStructure(FrozenNode other) { || !sameResolvedStructure(blue, other.blue)) { return false; } - return inlineValue == other.inlineValue; + // inlineValue records construction/serialization form only. It is + // normalized away by resolution and is not part of resolved semantic + // structure (unlike list order and the keyed object content above). + return true; } private static boolean sameResolvedStructure(FrozenNode left, FrozenNode right) { @@ -354,13 +360,10 @@ private static boolean sameResolvedProperties(Map left, if (left == null || right == null || left.size() != right.size()) { return false; } - Iterator> leftEntries = left.entrySet().iterator(); - Iterator> rightEntries = right.entrySet().iterator(); - while (leftEntries.hasNext()) { - Map.Entry leftEntry = leftEntries.next(); - Map.Entry rightEntry = rightEntries.next(); - if (!Objects.equals(leftEntry.getKey(), rightEntry.getKey()) - || !sameResolvedStructure(leftEntry.getValue(), rightEntry.getValue())) { + for (Map.Entry leftEntry : left.entrySet()) { + if (!right.containsKey(leftEntry.getKey()) + || !sameResolvedStructure( + leftEntry.getValue(), right.get(leftEntry.getKey()))) { return false; } } diff --git a/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java b/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java index fa1d43c..47556dd 100644 --- a/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java +++ b/src/main/java/blue/language/snapshot/ResolvedReferenceCache.java @@ -22,6 +22,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Consumer; import java.util.function.Supplier; /** @@ -63,6 +64,8 @@ public final class ResolvedReferenceCache implements AutoCloseable { private long structuralHighWaterWeight; private long structuralEvictions; private long structuralOversizedRejections; + private static volatile Consumer canonicalLoadObserver; + private static volatile Consumer canonicalLoadWaitObserver; public ResolvedReferenceCache() { this(BlueCachePolicy.boundedDefaults()); @@ -314,22 +317,31 @@ public FrozenNode getOrLoadVerifiedCanonical(String blueId, try { if (ownsLoad) { - // Another flight may have published after this thread's - // initial cache check but before it installed a new flight. - // Recheck after winning ownership so that late contenders - // do not invoke the provider a second time. - synchronized (cacheGeneration.mutationLock) { - if (loadingGeneration != cacheGeneration.value.get()) { - continue; - } - ensureCurrentGeneration(); - VerifiedReferenceEntry published = entriesByBlueId.get(blueId); - if (published == null) { - published = inheritedEntry(blueId); - } - if (published != null) { - return published.canonicalContent; + try { + notifyCanonicalLoadInstalled(blueId); + // Another flight may have published after this thread's + // initial cache check but before it installed a new flight. + // Recheck after winning ownership so that late contenders + // do not invoke the provider a second time. + synchronized (cacheGeneration.mutationLock) { + if (loadingGeneration != cacheGeneration.value.get()) { + flight.result.completeExceptionally( + RetryVerifiedReferenceLoadException.INSTANCE); + continue; + } + ensureCurrentGeneration(); + VerifiedReferenceEntry published = entriesByBlueId.get(blueId); + if (published == null) { + published = inheritedEntry(blueId); + } + if (published != null) { + flight.result.complete(published.canonicalContent); + return published.canonicalContent; + } } + } catch (RuntimeException | Error failure) { + flight.result.completeExceptionally(failure); + throw failure; } } @@ -354,7 +366,12 @@ public FrozenNode getOrLoadVerifiedCanonical(String blueId, } } } else { - loaded = awaitCanonicalLoad(flight); + notifyCanonicalLoadWait(blueId); + try { + loaded = awaitCanonicalLoad(flight); + } catch (RetryVerifiedReferenceLoadException retry) { + continue; + } } synchronized (cacheGeneration.mutationLock) { @@ -396,6 +413,28 @@ private static boolean isLoadingBlueId(Deque loadingStack, return false; } + static void setCanonicalLoadObserverForTesting(Consumer observer) { + canonicalLoadObserver = observer; + } + + static void setCanonicalLoadWaitObserverForTesting(Consumer observer) { + canonicalLoadWaitObserver = observer; + } + + private static void notifyCanonicalLoadInstalled(String blueId) { + Consumer observer = canonicalLoadObserver; + if (observer != null) { + observer.accept(blueId); + } + } + + private static void notifyCanonicalLoadWait(String blueId) { + Consumer observer = canonicalLoadWaitObserver; + if (observer != null) { + observer.accept(blueId); + } + } + private static FrozenNode awaitCanonicalLoad(CanonicalLoadFlight flight) { try { return flight.result.join(); @@ -416,6 +455,15 @@ private static RuntimeException propagateLoadFailure(Throwable failure) { return new IllegalStateException("Verified reference load failed", failure); } + private static final class RetryVerifiedReferenceLoadException extends RuntimeException { + private static final RetryVerifiedReferenceLoadException INSTANCE = + new RetryVerifiedReferenceLoadException(); + + private RetryVerifiedReferenceLoadException() { + super("Verified reference load generation changed", null, false, false); + } + } + public FrozenNode putVerifiedResolved(VerifiedReferenceResolution verification) { Objects.requireNonNull(verification, "verification"); return retainVerifiedResolved(verification.requestedBlueId(), diff --git a/src/main/java/blue/language/utils/MergeReverser.java b/src/main/java/blue/language/utils/MergeReverser.java index e2c980d..eea67e6 100644 --- a/src/main/java/blue/language/utils/MergeReverser.java +++ b/src/main/java/blue/language/utils/MergeReverser.java @@ -64,7 +64,10 @@ private void reverseNode(Node minimal, && (fromType == null || fromType.getValue() == null || !Objects.equals(merged.getValue(), fromType.getValue()))) { - minimal.value(merged.getValue()); + minimal.value(merged.getValue()) + .inlineValue(source != null + ? source.isInlineValue() + : merged.isInlineValue()); } setTypeIfDifferent(merged, fromType, minimal, canonicalOverlay, Node::getType, Node::type); @@ -73,10 +76,22 @@ private void reverseNode(Node minimal, setTypeIfDifferent(merged, fromType, minimal, canonicalOverlay, Node::getValueType, Node::valueType); preservePayloadTypeForMetadataOverride(merged, minimal); - if (merged.getName() != null && (fromType == null || !merged.getName().equals(fromType.getName()))) { + // Canonicalization must retain explicit instance labels even when the + // effective type happens to carry the same text. Root labels are never + // inherited from a type, and source provenance is the only way to + // distinguish an explicit equal label from an absent one. The optional + // author-facing minimizer has no source provenance and keeps its + // historical value-diff behavior. + if (canonicalOverlay && source != null && source.getName() != null) { + minimal.name(source.getName()); + } else if (merged.getName() != null + && (fromType == null || !merged.getName().equals(fromType.getName()))) { minimal.name(merged.getName()); } - if (merged.getDescription() != null && (fromType == null || !merged.getDescription().equals(fromType.getDescription()))) { + if (canonicalOverlay && source != null && source.getDescription() != null) { + minimal.description(source.getDescription()); + } else if (merged.getDescription() != null + && (fromType == null || !merged.getDescription().equals(fromType.getDescription()))) { minimal.description(merged.getDescription()); } diff --git a/src/main/resources/registry/blue-contracts-1.0/manifest.yaml b/src/main/resources/registry/blue-contracts-1.0/manifest.yaml index dee8346..035a53d 100644 --- a/src/main/resources/registry/blue-contracts-1.0/manifest.yaml +++ b/src/main/resources/registry/blue-contracts-1.0/manifest.yaml @@ -2,7 +2,7 @@ specVersion: "1.0" registryKind: Blue Contracts runtime type registry publishedBy: Blue Contracts and Processor 1.0 canonicalizationRule: Standard Blue Language 1.0 baseline preprocessing resolves symbolic core type aliases and runtime registry aliases before BlueId calculation. -conformanceFixturePackageIdentity: "sha256:22713df4d50a38b91762aea1e1a360019c1d16c2584ca1bac022305edb4c66d1" +conformanceFixturePackageIdentity: "sha256:e6d4895fa007837aa1b54a92d1e4cb3133e2e7daa5cc5bb22e7443b6009a244a" preprocessingEnvironment: coreRegistry: blue-language-1.0 runtimeRegistry: blue-contracts-1.0 diff --git a/src/test/java/blue/language/MergeReverserTest.java b/src/test/java/blue/language/MergeReverserTest.java index 47cd247..fd1e506 100644 --- a/src/test/java/blue/language/MergeReverserTest.java +++ b/src/test/java/blue/language/MergeReverserTest.java @@ -404,6 +404,34 @@ public void canonicalOverlayDoesNotSerializePreviousOrPos() throws Exception { }); } + @Test + public void canonicalOverlayPreservesExplicitRootLabelsEqualToTypeLabels() { + BasicNodeProvider nodeProvider = new BasicNodeProvider(); + Node canonicalType = new Node() + .name("Same Label") + .description("Same Description"); + nodeProvider.addSingleNodes(canonicalType); + String typeBlueId = nodeProvider.getBlueIdByName(canonicalType.getName()); + Blue blue = new Blue(nodeProvider); + Node source = new Node() + .name(canonicalType.getName()) + .description(canonicalType.getDescription()) + .type(new Node().blueId(typeBlueId)); + + Node preprocessed = blue.preprocess(source.clone()); + Node canonical = new MergeReverser().reverseToCanonicalOverlay( + blue.resolve(preprocessed.clone()), preprocessed); + Node expectedCanonical = source.clone(); + + assertEquals("Same Label", canonical.getName()); + assertEquals("Same Description", canonical.getDescription()); + assertEquals(BlueIdCalculator.calculateBlueId(expectedCanonical), + blue.calculateSemanticBlueId(source)); + assertNotEquals(blue.calculateSemanticBlueId( + new Node().type(new Node().blueId(typeBlueId))), + blue.calculateSemanticBlueId(source)); + } + @Test public void preservesScalarOverrideThatDiffersFromType() throws Exception { BasicNodeProvider nodeProvider = new BasicNodeProvider(); diff --git a/src/test/java/blue/language/ProcessingDocumentStateInvariantFailFirstTest.java b/src/test/java/blue/language/ProcessingDocumentStateInvariantFailFirstTest.java index 8ca1903..fbcab1f 100644 --- a/src/test/java/blue/language/ProcessingDocumentStateInvariantFailFirstTest.java +++ b/src/test/java/blue/language/ProcessingDocumentStateInvariantFailFirstTest.java @@ -9,7 +9,6 @@ import blue.language.processor.model.JsonPatch; import blue.language.processor.registry.RuntimeBlueIds; import blue.language.snapshot.ResolvedSnapshot; -import blue.language.utils.BlueIdCalculator; import blue.language.utils.MergeReverser; import blue.language.utils.NodeToMapListOrValue; import org.junit.jupiter.api.Test; @@ -188,8 +187,9 @@ private static Observation observe(AuditFixture fixture, private static Node expectedInitializedSelected(AuditFixture fixture, Node selectedBefore) { Node expected = selectedBefore.clone(); Blue identityBlue = fixture.newBlue(new AtomicInteger()); - String preInitializationIdentity = BlueIdCalculator.calculateUncheckedBlueId( - identityBlue.resolveToSnapshot(selectedBefore.clone()).frozenCanonicalRoot().toNode()); + String preInitializationIdentity = identityBlue.resolveToSnapshot(selectedBefore.clone()) + .frozenCanonicalRoot() + .blueId(); Node marker = new Node() .type(reference(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER)) .properties("documentId", text(preInitializationIdentity)); diff --git a/src/test/java/blue/language/ProcessingSnapshotProviderProvenanceTest.java b/src/test/java/blue/language/ProcessingSnapshotProviderProvenanceTest.java index a5df9dc..553eaaf 100644 --- a/src/test/java/blue/language/ProcessingSnapshotProviderProvenanceTest.java +++ b/src/test/java/blue/language/ProcessingSnapshotProviderProvenanceTest.java @@ -65,7 +65,8 @@ void initializationSnapshotAcceptsExplicitTrustedNonDirectType() { assertFalse(result.capabilityFailure(), result.failureReason()); assertNotNull(result.snapshot()); assertEquals("trusted", result.snapshot().resolvedRoot().getAsText("/fixed")); - assertEquals(3, fixture.fetches.get()); + assertEquals(5, fixture.fetches.get(), + "scope identity performs one additional verified full-pipeline resolution"); } @Test diff --git a/src/test/java/blue/language/ResolvedInstanceSchemaValidationTest.java b/src/test/java/blue/language/ResolvedInstanceSchemaValidationTest.java index 7e60dbd..d2891de 100644 --- a/src/test/java/blue/language/ResolvedInstanceSchemaValidationTest.java +++ b/src/test/java/blue/language/ResolvedInstanceSchemaValidationTest.java @@ -708,25 +708,25 @@ void publicAndProcessingSnapshotsPreserveNestedListReferenceIdentityColdAndWarm( .properties("identifier", new Node().value("subject-1")); fixture.delegate.addSingleNodes(referenced); String referenceId = fixture.delegate.getBlueIdByName("Nested Snapshot Subject"); - Node source = new Node().items( + Node source = new Node().properties("entries", new Node().items( fixture.holderInstance(reference(referenceId)), - fixture.holderInstance(reference(referenceId))); - + fixture.holderInstance(reference(referenceId)))); ResolvedSnapshot publicCold = fixture.blue.resolveToSnapshot(source); fixture.blue.clearResolvedSnapshotCache(); ResolvedSnapshot processingCold = fixture.blue.initializeDocument(source).snapshot(); ResolvedSnapshot publicWarm = fixture.blue.resolveToSnapshot(source); ResolvedSnapshot processingWarm = fixture.blue.initializeDocument(source).snapshot(); - assertEquals(publicCold.blueId(), processingCold.blueId()); assertEquals(publicCold.blueId(), publicWarm.blueId()); - assertEquals(publicCold.blueId(), processingWarm.blueId()); - assertEquals(referenceId, publicCold.canonicalAt("/0/subject").getReferenceBlueId()); - assertEquals(referenceId, processingCold.canonicalAt("/1/subject").getReferenceBlueId()); - assertEquals(publicCold.canonicalAt("/0/subject").resolvedStructuralKey(), - processingCold.canonicalAt("/0/subject").resolvedStructuralKey()); - assertEquals(publicCold.resolvedAt("/1/subject").resolvedStructuralKey(), - processingWarm.resolvedAt("/1/subject").resolvedStructuralKey()); + assertEquals(processingCold.blueId(), processingWarm.blueId()); + assertEquals(referenceId, + publicCold.canonicalAt("/entries/0/subject").getReferenceBlueId()); + assertEquals(referenceId, + processingCold.canonicalAt("/entries/1/subject").getReferenceBlueId()); + assertEquals(publicCold.canonicalAt("/entries/0/subject").resolvedStructuralKey(), + processingCold.canonicalAt("/entries/0/subject").resolvedStructuralKey()); + assertEquals(publicCold.resolvedAt("/entries/1/subject").resolvedStructuralKey(), + processingWarm.resolvedAt("/entries/1/subject").resolvedStructuralKey()); } private static Schema required() { diff --git a/src/test/java/blue/language/processor/ContractBundleCacheTest.java b/src/test/java/blue/language/processor/ContractBundleCacheTest.java index b1ea87a..341f3e5 100644 --- a/src/test/java/blue/language/processor/ContractBundleCacheTest.java +++ b/src/test/java/blue/language/processor/ContractBundleCacheTest.java @@ -139,13 +139,12 @@ void embeddedScopesCacheIndependently() { } private Blue configuredBlue(RecordingMetrics metrics) { - DocumentProcessor processor = DocumentProcessor.builder() - .withProcessingMetricsSink(metrics) - .registerContractProcessor(new TestEventChannelProcessor()) - .registerContractProcessor(new IncrementPropertyContractProcessor()) - .registerContractProcessor(new SetPropertyContractProcessor()) - .build(); - return ProcessorTestSupport.blue().documentProcessor(processor); + Blue blue = ProcessorTestSupport.blue(); + blue.getDocumentProcessor().processingMetricsSink(metrics); + blue.registerContractProcessor(new TestEventChannelProcessor()); + blue.registerContractProcessor(new IncrementPropertyContractProcessor()); + blue.registerContractProcessor(new SetPropertyContractProcessor()); + return blue; } private Node event(Blue blue, String eventId) { diff --git a/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java b/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java index 1f79207..57a0e81 100644 --- a/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java +++ b/src/test/java/blue/language/processor/DocumentProcessorInitializationTest.java @@ -37,6 +37,7 @@ void initializeDocumentEmitsRootLifecycleEvent() { DocumentProcessingResult result = blue.initializeDocument(original); assertFalse(result.capabilityFailure(), result.failureReason()); + assertNull(result.errorCategory(), result.failureReason()); assertTrue(blue.isInitialized(result.document())); assertEquals(1, result.triggeredEvents().size()); @@ -63,8 +64,7 @@ void initializationMarkerUsesFrozenPatchAndLocalProcessorStateResolution() { Node original = blue.yamlToNode("name: Minimal Doc\n" + "contracts: {}\n"); ResolvedSnapshot preInitialization = blue.resolveToSnapshot(original.clone()); - String oldMaterializedDocumentId = BlueIdCalculator.calculateUncheckedBlueId( - preInitialization.frozenCanonicalRoot().toNode()); + String expectedDocumentId = preInitialization.frozenCanonicalRoot().blueId(); DocumentProcessingResult result = blue.initializeDocument(original); @@ -75,7 +75,7 @@ void initializationMarkerUsesFrozenPatchAndLocalProcessorStateResolution() { .get("initialized"); assertEquals(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER, initialized.getType().getBlueId()); - assertEquals(oldMaterializedDocumentId, + assertEquals(expectedDocumentId, initialized.getProperties().get("documentId").getValue()); assertEquals(lifecycleDocumentId(result.triggeredEvents().get(0)), initialized.getProperties().get("documentId").getValue()); @@ -89,8 +89,8 @@ void initializationMarkerUsesFrozenPatchAndLocalProcessorStateResolution() { assertEquals(1L, snapshot.counter("processorManagedMarkerPatches"), snapshot.toString()); assertEquals(0L, snapshot.counter("processorManagedMarkerIncrementalResolutions"), snapshot.toString()); assertEquals(0L, snapshot.counter("fullSnapshotFallbackReason.CONTRACTS_CHANGED"), snapshot.toString()); - assertEquals(1L, snapshot.counter("initializationDocumentIdUncheckedCalculations"), snapshot.toString()); - assertEquals(1L, snapshot.counter("initializationDocumentIdNodeMaterializations"), snapshot.toString()); + assertEquals(1L, snapshot.counter("initializationDocumentIdContentBlueIdCalculations"), snapshot.toString()); + assertEquals(1L, snapshot.counter("initializationDocumentIdCanonicalMaterializations"), snapshot.toString()); } @Test @@ -113,12 +113,12 @@ void snapshotBackedInitializationMarkerUsesIncrementalProcessorStateResolution() assertEquals(1L, snapshot.counter("processorManagedMarkerIncrementalResolutions"), snapshot.toString()); assertEquals(1L, snapshot.counter("incrementalSnapshotResolutions"), snapshot.toString()); assertEquals(0L, snapshot.counter("fullSnapshotFallbacks"), snapshot.toString()); - assertEquals(1L, snapshot.counter("initializationDocumentIdUncheckedCalculations"), snapshot.toString()); - assertEquals(1L, snapshot.counter("initializationDocumentIdNodeMaterializations"), snapshot.toString()); + assertEquals(1L, snapshot.counter("initializationDocumentIdContentBlueIdCalculations"), snapshot.toString()); + assertEquals(1L, snapshot.counter("initializationDocumentIdCanonicalMaterializations"), snapshot.toString()); } @Test - void initializationDocumentIdUsesUncheckedIdentityWhenCanonicalBlueIdDiffers() { + void initializationDocumentIdUsesContentBlueIdWhenUncheckedIdentityDiffers() { Blue blue = ProcessorTestSupport.blue(); RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); blue.getDocumentProcessor().processingMetricsSink(metrics); @@ -139,10 +139,10 @@ void initializationDocumentIdUsesUncheckedIdentityWhenCanonicalBlueIdDiffers() { assertFalse(result.capabilityFailure(), result.failureReason()); String markerDocumentId = markerDocumentId(result.document(), "/"); - assertEquals(unchecked, markerDocumentId, + assertEquals(canonical, markerDocumentId, "canonical=" + canonical + ", unchecked=" + unchecked); - assertEquals(unchecked, lifecycleDocumentId(result.triggeredEvents().get(0))); - assertNotEquals(canonical, markerDocumentId); + assertEquals(canonical, lifecycleDocumentId(result.triggeredEvents().get(0))); + assertNotEquals(unchecked, markerDocumentId); ProcessingMetricsSnapshot snapshot = metrics.snapshot(); assertEquals(0L, snapshot.counter("mutablePatchValuesFrozen"), snapshot.toString()); assertEquals(1L, snapshot.counter("frozenPatchValuesAccepted"), snapshot.toString()); @@ -152,7 +152,7 @@ void initializationDocumentIdUsesUncheckedIdentityWhenCanonicalBlueIdDiffers() { } @Test - void initializationDocumentIdUsesMaterializedUncheckedOracleAcrossIdentityShapes() { + void initializationDocumentIdUsesContentBlueIdAcrossIdentityShapes() { Blue blue = ProcessorTestSupport.blue(); List fixtures = new ArrayList<>(Arrays.asList( "name: Simple Object Shape\n" + @@ -236,7 +236,7 @@ void initializationDocumentIdUsesMaterializedUncheckedOracleAcrossIdentityShapes " - /child\n")); for (String yaml : fixtures) { - assertInitializationUsesUncheckedIdentityAndReloads(blue, yaml); + assertInitializationUsesContentBlueIdAndReloads(blue, yaml); } BasicNodeProvider provider = new BasicNodeProvider(); @@ -247,7 +247,7 @@ void initializationDocumentIdUsesMaterializedUncheckedOracleAcrossIdentityShapes " - previous-b\n"); String previousBlueId = BlueIdCalculator.calculateBlueId(previous.getItems()); provider.addListAndItsItems(previous.getItems()); - assertInitializationUsesUncheckedIdentityAndReloads(previousBlue, + assertInitializationUsesContentBlueIdAndReloads(previousBlue, "name: Previous List Control Shape\n" + "history:\n" + " type:\n" + @@ -261,7 +261,7 @@ void initializationDocumentIdUsesMaterializedUncheckedOracleAcrossIdentityShapes } @Test - void bexShapedNestedListsUseHistoricalUncheckedInitializationIdentity() { + void bexShapedNestedListsUseContentBlueIdInitializationIdentity() { Blue blue = ProcessorTestSupport.blue(); List fixtures = Arrays.asList( "name: Compute Do Payload List\n" + @@ -308,13 +308,15 @@ void bexShapedNestedListsUseHistoricalUncheckedInitializationIdentity() { "contracts: {}\n"); for (String yaml : fixtures) { - assertInitializationUsesUncheckedIdentityAndReloads(blue, yaml); + assertInitializationUsesContentBlueIdAndReloads(blue, yaml); } } @Test - void embeddedScopeInitializationDocumentIdsUseTheirOwnUncheckedPreInitializationIdentity() { - Blue blue = ProcessorTestSupport.blue(); + void embeddedScopeInitializationDocumentIdsUseTheirOwnContentPreInitializationIdentity() { + BasicNodeProvider identityProvider = new BasicNodeProvider(); + identityProvider.addSingleNodes(new Node().name("CaptureLifecycleDocumentId")); + Blue blue = ProcessorTestSupport.blue(identityProvider); blue.registerExternalContractType(CAPTURE_LIFECYCLE_DOCUMENT_ID_BLUE_ID, new Node().name("CaptureLifecycleDocumentId"), new CaptureLifecycleDocumentIdProcessor()); @@ -351,35 +353,39 @@ void embeddedScopeInitializationDocumentIdsUseTheirOwnUncheckedPreInitialization " type:\n" + " blueId: " + CAPTURE_LIFECYCLE_DOCUMENT_ID_BLUE_ID + "\n" + " propertyKey: /rootLifecycleDocumentId\n"); - ResolvedSnapshot preInitialization = blue.getDocumentProcessor() - .snapshotManager() - .fromDocument(original.clone()); - FrozenNode rootBefore = preInitialization.frozenCanonicalRoot(); - FrozenNode childBefore = rootBefore.property("child"); - String rootUnchecked = uncheckedInitializationId(rootBefore); - String childUnchecked = uncheckedInitializationId(childBefore); - assertNotEquals(childBefore.blueId(), childUnchecked, - "canonical=" + childBefore.blueId() + ", unchecked=" + childUnchecked); + Node standaloneChildBeforeLifecycle = original.getAsNode("/child").clone(); + ResolvedSnapshot childPreInitialization = blue.resolveToSnapshot(standaloneChildBeforeLifecycle); + String childContentBlueId = childPreInitialization.blueId(); + String childUnchecked = uncheckedInitializationId(childPreInitialization.frozenCanonicalRoot()); + assertNotEquals(childContentBlueId, childUnchecked, + "canonical=" + childContentBlueId + ", unchecked=" + childUnchecked); + + Node rootAfterChildPhase1 = original.clone(); + Node childAfterPhase1 = rootAfterChildPhase1.getAsNode("/child"); + childAfterPhase1.properties("childLifecycleDocumentId", new Node().value(childContentBlueId)); + childAfterPhase1.getContracts().properties( + "initialized", ProcessorMarkerFactory.initialized(childContentBlueId).toNode()); + String rootContentBlueId = blue.calculateSemanticBlueId(rootAfterChildPhase1); DocumentProcessingResult result = blue.initializeDocument(original); assertFalse(result.capabilityFailure(), result.failureReason()); Node initialized = result.document(); - assertEquals(rootUnchecked, markerDocumentId(initialized, "/")); - assertEquals(childUnchecked, markerDocumentId(initialized, "/child")); - assertEquals(rootUnchecked, initialized.getAsText("/rootLifecycleDocumentId")); - assertEquals(childUnchecked, initialized.getAsText("/child/childLifecycleDocumentId")); + assertEquals(rootContentBlueId, markerDocumentId(initialized, "/")); + assertEquals(childContentBlueId, markerDocumentId(initialized, "/child")); + assertEquals(rootContentBlueId, initialized.getAsText("/rootLifecycleDocumentId")); + assertEquals(childContentBlueId, initialized.getAsText("/child/childLifecycleDocumentId")); ProcessingMetricsSnapshot snapshot = metrics.snapshot(); assertEquals(0L, snapshot.counter("mutablePatchValuesFrozen"), snapshot.toString()); assertEquals(2L, snapshot.counter("patchImpactProcessorManagedState"), snapshot.toString()); assertEquals(2L, snapshot.counter("processorManagedMarkerPatches"), snapshot.toString()); assertEquals(0L, snapshot.counter("fullSnapshotFallbackReason.CONTRACTS_CHANGED"), snapshot.toString()); - assertEquals(2L, snapshot.counter("initializationDocumentIdUncheckedCalculations"), snapshot.toString()); - assertEquals(2L, snapshot.counter("initializationDocumentIdNodeMaterializations"), snapshot.toString()); + assertEquals(2L, snapshot.counter("initializationDocumentIdContentBlueIdCalculations"), snapshot.toString()); + assertEquals(2L, snapshot.counter("initializationDocumentIdCanonicalMaterializations"), snapshot.toString()); } @Test - void initializeCurrentScopeIfNeededUsesUncheckedInitializationIdentityBeforeFatalBoundaryStop() { + void nonObjectEmbeddedChildTerminatesDuringPhase1WithoutInitialization() { Blue blue = ProcessorTestSupport.blue(); RecordingProcessingMetricsSink metrics = new RecordingProcessingMetricsSink(); blue.getDocumentProcessor().processingMetricsSink(metrics); @@ -395,19 +401,18 @@ void initializeCurrentScopeIfNeededUsesUncheckedInitializationIdentityBeforeFata " blueId: 8FVc8MPz6DcTMgcY3RXU6EBpGa9arWPJ141K2H86yi8Q\n" + " paths:\n" + " - /child\n"); - ResolvedSnapshot preInitialization = blue.resolveToSnapshot(original.clone()); - String unchecked = uncheckedInitializationId(preInitialization.frozenCanonicalRoot()); - DocumentProcessingResult result = blue.initializeDocument(original); assertFalse(result.capabilityFailure(), result.failureReason()); - assertNotNull(result.failureReason()); - assertEquals(unchecked, markerDocumentId(result.document(), "/")); - assertEquals(unchecked, lifecycleDocumentId(result.triggeredEvents().get(0))); + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status()); + assertEquals(ProcessorErrorCategory.BoundaryViolation, result.errorCategory()); + assertNull(result.document().getContracts().getProperties().get("initialized")); + assertTrue(result.triggeredEvents().stream().noneMatch(event -> event.getType() != null + && RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED.equals(event.getType().getBlueId()))); ProcessingMetricsSnapshot snapshot = metrics.snapshot(); assertEquals(0L, snapshot.counter("mutablePatchValuesFrozen"), snapshot.toString()); - assertEquals(1L, snapshot.counter("patchImpactProcessorManagedState"), snapshot.toString()); - assertEquals(1L, snapshot.counter("processorManagedMarkerPatches"), snapshot.toString()); + assertEquals(0L, snapshot.counter("patchImpactProcessorManagedState"), snapshot.toString()); + assertEquals(0L, snapshot.counter("processorManagedMarkerPatches"), snapshot.toString()); } @Test @@ -794,16 +799,15 @@ void childLifecycleIsBridgedToParent() { assertEquals(new BigInteger("1"), childLifecycle.getValue()); } - private static void assertInitializationUsesUncheckedIdentityAndReloads(Blue blue, String yaml) { + private static void assertInitializationUsesContentBlueIdAndReloads(Blue blue, String yaml) { Node original = blue.yamlToNode(yaml); - ResolvedSnapshot preInitialization = blue.resolveToSnapshot(original.clone()); - String unchecked = uncheckedInitializationId(preInitialization.frozenCanonicalRoot()); + String contentBlueId = independentRootInitializationContentBlueId(blue, original); DocumentProcessingResult result = blue.initializeDocument(original); assertFalse(result.capabilityFailure(), result.failureReason()); - assertEquals(unchecked, markerDocumentId(result.document(), "/"), yaml); - assertTrue(hasLifecycleDocumentId(result, unchecked), yaml); + assertEquals(contentBlueId, markerDocumentId(result.document(), "/"), yaml); + assertTrue(hasLifecycleDocumentId(result, contentBlueId), yaml); ResolvedSnapshot finalSnapshot = blue.resolveToSnapshot(result.document().clone()); ResolvedSnapshot reloaded = blue.resolveToSnapshot( blue.jsonToNode(blue.nodeToJson(result.document()))); @@ -814,6 +818,31 @@ private static void assertInitializationUsesUncheckedIdentityAndReloads(Blue blu blue.nodeToJson(reloaded.resolvedRoot()), yaml); } + private static String independentRootInitializationContentBlueId(Blue blue, Node original) { + Node preRootLifecycle = original.clone(); + Node contracts = original.getContracts(); + Node embedded = contracts != null && contracts.getProperties() != null + ? contracts.getProperties().get("embedded") + : null; + Node paths = embedded != null && embedded.getProperties() != null + ? embedded.getProperties().get("paths") + : null; + if (paths != null && paths.getItems() != null) { + for (Node pathNode : paths.getItems()) { + String childPath = String.valueOf(pathNode.getValue()); + Node childSource = original.getAsNode(childPath).clone(); + String childContentBlueId = blue.calculateSemanticBlueId(childSource); + Node childAfterPhase1 = preRootLifecycle.getAsNode(childPath); + if (childAfterPhase1.getContracts() == null) { + childAfterPhase1.contracts(new Node()); + } + childAfterPhase1.getContracts().properties( + "initialized", ProcessorMarkerFactory.initialized(childContentBlueId).toNode()); + } + } + return blue.calculateSemanticBlueId(preRootLifecycle); + } + private static String uncheckedInitializationId(FrozenNode node) { return BlueIdCalculator.calculateUncheckedBlueId(node.toNode()); } diff --git a/src/test/java/blue/language/processor/ProcessEmbeddedTest.java b/src/test/java/blue/language/processor/ProcessEmbeddedTest.java index 0a4d731..600cc3e 100644 --- a/src/test/java/blue/language/processor/ProcessEmbeddedTest.java +++ b/src/test/java/blue/language/processor/ProcessEmbeddedTest.java @@ -11,6 +11,7 @@ import blue.language.processor.contracts.TestEventChannelProcessor; import blue.language.processor.model.TestEvent; import blue.language.processor.registry.RuntimeBlueIds; +import blue.language.provider.BasicNodeProvider; import org.junit.jupiter.api.Test; import java.math.BigInteger; @@ -626,6 +627,47 @@ void embeddedPathSelectingNonObjectCausesFatalTermination() { assertEquals("fatal", rootTerminated.getProperties().get("cause").getValue()); } + @Test + void embeddedPathSelectingPureReferenceIsBoundaryViolationBeforeInitialization() { + Node childType = new Node() + .name("Referenced Embedded Context Type") + .properties("inherited", new Node().value("forces typed materialization")); + Node referenced = new Node() + .name("Referenced Object Is Not A Selected Object Node") + .properties("payload", new Node().value("provider content")); + BasicNodeProvider provider = new BasicNodeProvider(childType, referenced); + String childTypeBlueId = provider.getBlueIdByName(childType.getName()); + Node parentType = new Node() + .name("Referenced Embedded Parent Type") + .properties("child", new Node().type(new Node().blueId(childTypeBlueId))); + provider.addSingleNodes(parentType); + String parentTypeBlueId = provider.getBlueIdByName(parentType.getName()); + String referencedBlueId = provider.getBlueIdByName(referenced.getName()); + String yaml = "name: Referenced Embedded\n" + + "type:\n" + + " blueId: " + parentTypeBlueId + "\n" + + "child:\n" + + " blueId: " + referencedBlueId + "\n" + + "contracts:\n" + + " embedded:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.PROCESS_EMBEDDED + "\n" + + " paths:\n" + + " - /child\n"; + + Blue blue = ProcessorTestSupport.blue(provider); + DocumentProcessingResult result = blue.initializeDocument(blue.yamlToNode(yaml)); + + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertEquals(ProcessorErrorCategory.BoundaryViolation, + result.errorCategory(), result.failureReason()); + assertTrue(result.document().getProperties().get("child").isReferenceOnly(), + "the referenced child must not be initialized or mutated as an active scope"); + Node rootTerminated = terminatedMarker(result.document(), "/"); + assertNotNull(rootTerminated); + assertEquals("fatal", rootTerminated.getProperties().get("cause").getValue()); + } + @Test void rejectsMultipleProcessEmbeddedMarkersWithinScope() { String yaml = "name: Multi Embedded Doc\n" + diff --git a/src/test/java/blue/language/processor/ProcessorProcessEventContextTest.java b/src/test/java/blue/language/processor/ProcessorProcessEventContextTest.java index a43188d..fac0f33 100644 --- a/src/test/java/blue/language/processor/ProcessorProcessEventContextTest.java +++ b/src/test/java/blue/language/processor/ProcessorProcessEventContextTest.java @@ -471,6 +471,12 @@ void snapshotFailureFollowsExistingHandlerFailureMapping() { " type:\n" + " blueId: " + TEST_EVENT_CHANNEL_TYPE + "\n" + handler("read", "events", 0)); + // This test exercises handler failure mapping, not initialization + // identity. Make that precondition explicit instead of relying on an + // invented provider node for the registered Java contract classes. + document.getContracts().properties("initialized", new Node() + .type(new Node().blueId(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER)) + .properties("documentId", new Node().value("existing"))); AtomicInteger freezerCalls = new AtomicInteger(); ProcessorEngine.Execution execution = new ProcessorEngine.Execution(owner, document, diff --git a/src/test/java/blue/language/processor/RegisteredContractProviderEvidenceTest.java b/src/test/java/blue/language/processor/RegisteredContractProviderEvidenceTest.java new file mode 100644 index 0000000..63eb6b5 --- /dev/null +++ b/src/test/java/blue/language/processor/RegisteredContractProviderEvidenceTest.java @@ -0,0 +1,272 @@ +package blue.language.processor; + +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.model.Schema; +import blue.language.processor.model.ChannelContract; +import blue.language.processor.model.Contract; +import blue.language.processor.registry.RuntimeBlueIds; +import blue.language.provider.BasicNodeProvider; +import blue.language.utils.BlueIdCalculator; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RegisteredContractProviderEvidenceTest { + + @Test + void exactCanonicalRegistrationMatchesFullProviderBackedRuntime() { + TypeFixture fixture = new TypeFixture(); + Node suppliedCanonicalType = fixture.canonicalType.clone(); + DocumentProcessor standalone = DocumentProcessor.builder() + .registerContractProcessor( + fixture.blueId, suppliedCanonicalType, new EvidenceChannelProcessor()) + .build(); + + // Registration owns an immutable copy of the provider evidence. + suppliedCanonicalType.name("mutated after registration"); + + DocumentProcessingResult standaloneResult = standalone.initializeDocument( + fixture.document()); + DocumentProcessingResult fullRuntimeResult; + try (Blue fullRuntime = new Blue(fixture.provider)) { + fullRuntime.registerContractProcessor( + fixture.blueId, new EvidenceChannelProcessor()); + fullRuntimeResult = fullRuntime.initializeDocument(fixture.document()); + } + + assertEquals(ProcessorStatus.SUCCESS, standaloneResult.status(), + standaloneResult.failureReason()); + assertEquals(ProcessorStatus.SUCCESS, fullRuntimeResult.status(), + fullRuntimeResult.failureReason()); + assertEquals(initializationDocumentId(fullRuntimeResult), + initializationDocumentId(standaloneResult)); + assertEquals(lifecycleDocumentId(fullRuntimeResult), + lifecycleDocumentId(standaloneResult)); + assertEquals(initializationDocumentId(standaloneResult), + lifecycleDocumentId(standaloneResult)); + assertNotEquals(EvidenceChannel.class.getSimpleName(), + fixture.canonicalType.getName()); + assertNotNull(fixture.canonicalType.getDescription()); + assertEquals(RuntimeBlueIds.CHANNEL, + fixture.canonicalType.getType().getBlueId()); + assertNotNull(fixture.canonicalType.getProperties().get("protocolVersion").getSchema()); + } + + @Test + void runtimeExactCanonicalRegistrationInitializesStandaloneProcessor() { + TypeFixture fixture = new TypeFixture(); + DocumentProcessor standalone = new DocumentProcessor(); + + standalone.registerContractProcessor( + fixture.blueId, + fixture.canonicalType, + new EvidenceChannelProcessor()); + DocumentProcessingResult result = standalone.initializeDocument( + fixture.document()); + + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertEquals(initializationDocumentId(result), lifecycleDocumentId(result)); + } + + @Test + void registryBuilderEvidenceSeedsStandaloneProcessorTypeResolver() { + TypeFixture fixture = new TypeFixture(); + EvidenceChannelProcessor registered = new EvidenceChannelProcessor(); + ContractProcessorRegistry registry = ContractProcessorRegistryBuilder.create() + .register(fixture.blueId, fixture.canonicalType, registered) + .build(); + + DocumentProcessor standalone = new DocumentProcessor(registry); + DocumentProcessingResult result = standalone.initializeDocument( + fixture.document()); + + assertEquals(EvidenceChannel.class, + standalone.getContractTypeResolver().resolveClass(fixture.blueId)); + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertSame(registered, registry.processors().get(fixture.blueId)); + } + + @Test + void legacyExplicitBlueIdRegistrationDoesNotInventProviderContent() { + TypeFixture fixture = new TypeFixture(); + DocumentProcessor standalone = DocumentProcessor.builder() + .registerContractProcessor(fixture.blueId, new EvidenceChannelProcessor()) + .build(); + + DocumentProcessingResult result = standalone.initializeDocument(fixture.document()); + + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertEquals(ProcessorErrorCategory.ProviderUnavailable, result.errorCategory()); + assertNull(result.document().getContracts().getProperties().get("initialized")); + assertFalse(hasLifecycleInitiatedEvent(result)); + } + + @Test + void mismatchingCanonicalRegistrationIsRejectedAtomically() { + TypeFixture fixture = new TypeFixture(); + ContractProcessorRegistry registry = new ContractProcessorRegistry(); + Node wrongContent = fixture.canonicalType.clone().description("different identity"); + + IllegalArgumentException failure = assertThrows(IllegalArgumentException.class, + () -> registry.register( + fixture.blueId, wrongContent, new EvidenceChannelProcessor())); + + assertEquals(ProcessorErrorCategory.ProviderBlueIdMismatch, + ScopeIdentityErrorMapper.from(failure)); + assertFalse(registry.processors().containsKey(fixture.blueId)); + assertNull(registry.canonicalTypeNode(fixture.blueId)); + } + + @Test + void conflictingRuntimeTypeRegistrationLeavesRegistryAndResolverUnchanged() { + TypeFixture fixture = new TypeFixture(); + EvidenceChannelProcessor original = new EvidenceChannelProcessor(); + DocumentProcessor standalone = DocumentProcessor.builder() + .registerContractProcessor( + fixture.blueId, fixture.canonicalType, original) + .build(); + ContractProcessorRegistry registry = standalone.getContractRegistry(); + long versionBefore = registry.version(); + String evidenceBefore = BlueIdCalculator.calculateBlueId( + registry.canonicalTypeNode(fixture.blueId)); + + assertThrows(IllegalStateException.class, + () -> standalone.registerContractProcessor( + fixture.blueId, + fixture.canonicalType, + new ConflictingEvidenceChannelProcessor())); + + assertEquals(versionBefore, registry.version()); + assertSame(original, registry.processors().get(fixture.blueId)); + assertEquals(EvidenceChannel.class, + standalone.getContractTypeResolver().resolveClass(fixture.blueId)); + assertEquals(evidenceBefore, BlueIdCalculator.calculateBlueId( + registry.canonicalTypeNode(fixture.blueId))); + } + + @Test + void conflictingBuilderTypeRegistrationLeavesFirstRegistrationUsable() { + TypeFixture fixture = new TypeFixture(); + EvidenceChannelProcessor original = new EvidenceChannelProcessor(); + DocumentProcessor.Builder builder = DocumentProcessor.builder() + .registerContractProcessor( + fixture.blueId, fixture.canonicalType, original); + + assertThrows(IllegalStateException.class, + () -> builder.registerContractProcessor( + fixture.blueId, + fixture.canonicalType, + new ConflictingEvidenceChannelProcessor())); + + DocumentProcessor standalone = builder.build(); + assertSame(original, + standalone.getContractRegistry().processors().get(fixture.blueId)); + assertEquals(EvidenceChannel.class, + standalone.getContractTypeResolver().resolveClass(fixture.blueId)); + } + + @Test + void unsupportedProcessorRegistrationDoesNotPartiallyMutateRegistry() { + TypeFixture fixture = new TypeFixture(); + ContractProcessorRegistry registry = new ContractProcessorRegistry(); + ContractProcessor unsupported = () -> Contract.class; + + assertThrows(IllegalArgumentException.class, + () -> registry.register( + fixture.blueId, fixture.canonicalType, unsupported)); + + assertEquals(0L, registry.version()); + assertFalse(registry.processors().containsKey(fixture.blueId)); + assertNull(registry.canonicalTypeNode(fixture.blueId)); + } + + private static String initializationDocumentId(DocumentProcessingResult result) { + return result.document().getAsText("/contracts/initialized/documentId"); + } + + private static String lifecycleDocumentId(DocumentProcessingResult result) { + for (Node event : result.triggeredEvents()) { + if (event.getType() != null + && RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED.equals( + event.getType().getBlueId())) { + return event.getAsText("/documentId"); + } + } + return null; + } + + private static boolean hasLifecycleInitiatedEvent(DocumentProcessingResult result) { + return lifecycleDocumentId(result) != null; + } + + private static final class TypeFixture { + private final Node canonicalType; + private final BasicNodeProvider provider; + private final String blueId; + + private TypeFixture() { + Node authoredType = new Node() + .name("Protocol Evidence Channel") + .description("Identity-bearing provider description") + .type(new Node().blueId(RuntimeBlueIds.CHANNEL)) + .properties("protocolVersion", new Node() + .value("v1") + .schema(new Schema().required(new Node().value(true)))); + provider = new BasicNodeProvider(authoredType); + blueId = provider.getBlueIdByName(authoredType.getName()); + canonicalType = provider.fetchFirstByBlueId(blueId).clone().blueId(null); + } + + private Node document() { + return new Node() + .name("Provider Evidence Document") + .contracts(new Node().properties("incoming", new Node() + .type(new Node().blueId(blueId)))); + } + } + + public static final class EvidenceChannel extends ChannelContract { + private String protocolVersion; + + public String getProtocolVersion() { + return protocolVersion; + } + + public void setProtocolVersion(String protocolVersion) { + this.protocolVersion = protocolVersion; + } + } + + public static final class ConflictingEvidenceChannel extends ChannelContract { + } + + private static final class EvidenceChannelProcessor + implements ChannelProcessor { + + @Override + public Class contractType() { + return EvidenceChannel.class; + } + + @Override + public boolean matches(EvidenceChannel contract, ChannelEvaluationContext context) { + return false; + } + } + + private static final class ConflictingEvidenceChannelProcessor + implements ChannelProcessor { + + @Override + public Class contractType() { + return ConflictingEvidenceChannel.class; + } + } +} diff --git a/src/test/java/blue/language/processor/ScopeIdentityErrorMapperTest.java b/src/test/java/blue/language/processor/ScopeIdentityErrorMapperTest.java new file mode 100644 index 0000000..e3c1ccb --- /dev/null +++ b/src/test/java/blue/language/processor/ScopeIdentityErrorMapperTest.java @@ -0,0 +1,40 @@ +package blue.language.processor; + +import blue.language.BlueLanguageErrorCategory; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ScopeIdentityErrorMapperTest { + + @Test + void preservesProviderCategoriesFromLanguageCategories() { + assertEquals(ProcessorErrorCategory.ProviderUnavailable, + ScopeIdentityErrorMapper.from(BlueLanguageErrorCategory.ProviderUnavailable)); + assertEquals(ProcessorErrorCategory.ProviderBlueIdMismatch, + ScopeIdentityErrorMapper.from(BlueLanguageErrorCategory.ProviderBlueIdMismatch)); + } + + @Test + void classifiesProviderFailuresFromThrowables() { + assertEquals(ProcessorErrorCategory.ProviderUnavailable, + ScopeIdentityErrorMapper.from( + new IllegalStateException("No content found for blueId: missing"))); + assertEquals(ProcessorErrorCategory.ProviderBlueIdMismatch, + ScopeIdentityErrorMapper.from( + new IllegalArgumentException( + "Provider returned content for requested BlueId but computed BlueId differs"))); + } + + @Test + void mapsOtherLanguageFailuresToInternalProcessorError() { + assertEquals(ProcessorErrorCategory.InternalProcessorError, + ScopeIdentityErrorMapper.from(BlueLanguageErrorCategory.CanonicalizationError)); + assertEquals(ProcessorErrorCategory.InternalProcessorError, + ScopeIdentityErrorMapper.from(BlueLanguageErrorCategory.InvalidBlueIdInput)); + assertEquals(ProcessorErrorCategory.InternalProcessorError, + ScopeIdentityErrorMapper.from((BlueLanguageErrorCategory) null)); + assertEquals(ProcessorErrorCategory.InternalProcessorError, + ScopeIdentityErrorMapper.from((Throwable) null)); + } +} diff --git a/src/test/java/blue/language/processor/ScopeSourceProjectionTest.java b/src/test/java/blue/language/processor/ScopeSourceProjectionTest.java new file mode 100644 index 0000000..2b768c4 --- /dev/null +++ b/src/test/java/blue/language/processor/ScopeSourceProjectionTest.java @@ -0,0 +1,695 @@ +package blue.language.processor; + +import blue.language.Blue; +import blue.language.NodeProvider; +import blue.language.model.Node; +import blue.language.processor.model.JsonPatch; +import blue.language.processor.registry.RuntimeBlueIds; +import blue.language.provider.BasicNodeProvider; +import blue.language.snapshot.FrozenNode; +import blue.language.snapshot.ResolvedSnapshot; +import blue.language.utils.BlueIdCalculator; +import blue.language.utils.Properties; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ScopeSourceProjectionTest { + + @Test + void snapshotCaptureDoesNotAdoptASuccessfulButDifferentCanonicalReresolution() { + Blue blue = ProcessorTestSupport.blue(); + ResolvedSnapshot authoritative = blue.resolveToSnapshot(new Node() + .name("Authoritative Snapshot Scope") + .properties("state", text("captured"))); + SuccessfulButAlteredSnapshotManager manager = + new SuccessfulButAlteredSnapshotManager(authoritative); + DocumentProcessingRuntime runtime = new DocumentProcessingRuntime( + authoritative, null, manager); + + String actual = runtime.calculatePreInitializationScopeContentBlueId("/"); + + assertEquals(authoritative.blueId(), actual); + assertSame(authoritative, manager.capturedSnapshot, + "snapshot-backed identity must use the current immutable Phase 1 snapshot"); + assertEquals(0, manager.fromDocumentTransientCalls, + "canonical identity input must not be re-resolved merely to capture snapshot state"); + + ResolvedSnapshot altered = manager.fromDocumentTransient(authoritative.canonicalRoot()); + assertFalse(altered.frozenResolvedRoot() + .sameResolvedStructure(authoritative.frozenResolvedRoot()), + "the forbidden re-resolution path is intentionally successful but different"); + assertEquals(1, manager.fromDocumentTransientCalls); + } + + @Test + void inheritedListControlsProjectAsARealStandaloneSourceOverlay() { + BasicNodeProvider provider = new BasicNodeProvider(); + provider.addSingleDocs( + "name: Controlled Scope Type\n" + + "list:\n" + + " type: List\n" + + " mergePolicy: positional\n" + + " items:\n" + + " - A\n" + + " - B"); + String scopeTypeBlueId = provider.getBlueIdByName("Controlled Scope Type"); + Blue blue = ProcessorTestSupport.blue(provider); + Node inheritedItems = blue.resolve(new Node().type(reference(scopeTypeBlueId))) + .getAsNode("/list"); + String previousBlueId = BlueIdCalculator.calculateBlueId(inheritedItems.getItems()); + provider.addListAndItsItems(inheritedItems.getItems()); + Node source = blue.yamlToNode( + "type:\n" + + " blueId: " + scopeTypeBlueId + "\n" + + "list:\n" + + " type: List\n" + + " mergePolicy: positional\n" + + " items:\n" + + " - $previous:\n" + + " blueId: " + previousBlueId + "\n" + + " - $pos: 1\n" + + " value: C"); + ResolvedSnapshot captured = blue.resolveToSnapshot(source.clone()); + + ScopeSourceProjection nodeProjection = ScopeSourceProjection.project( + "/", + FrozenNode.fromResolvedNode(source), + captured, + blue.getDocumentProcessor().snapshotManager()); + ScopeSourceProjection snapshotProjection = ScopeSourceProjection.project( + "/", + captured.frozenCanonicalRoot(), + captured, + blue.getDocumentProcessor().snapshotManager()); + + String expected = blue.calculateSemanticBlueId(source); + assertEquals(expected, nodeProjection.contentBlueId()); + assertEquals(expected, snapshotProjection.contentBlueId()); + assertTrue(nodeProjection.standaloneSnapshot().frozenResolvedRoot() + .sameResolvedStructure(captured.frozenResolvedRoot())); + assertTrue(snapshotProjection.standaloneSnapshot().frozenResolvedRoot() + .sameResolvedStructure(captured.frozenResolvedRoot())); + List nodeItems = nodeProjection.standaloneSource() + .property("list").getItems(); + assertNotNull(nodeItems); + assertEquals(previousBlueId, nodeItems.get(0).getPreviousBlueId(), + "Node-backed capture must retain the authored anchor provenance"); + List snapshotItems = snapshotProjection.standaloneSource() + .property("list").getItems(); + assertEquals(1, snapshotItems.size(), + "snapshot projection must not invent an external anchor dependency"); + assertEquals(Integer.valueOf(1), snapshotItems.get(0).getPosition()); + + DocumentProcessingResult nodeResult = blue.initializeDocument(source.clone()); + DocumentProcessingResult snapshotResult = blue.initializeDocument(captured); + assertInitializationIdentity(nodeResult, expected); + assertInitializationIdentity(snapshotResult, expected); + } + + @Test + void snapshotProjectionDoesNotRequireSyntheticPreviousListProviderContent() { + BasicNodeProvider provider = new BasicNodeProvider(); + provider.addSingleDocs( + "name: Embedded Positional List Scope Type\n" + + "list:\n" + + " type: List\n" + + " mergePolicy: positional\n" + + " items:\n" + + " - A\n" + + " - B"); + String scopeTypeBlueId = provider.getBlueIdByName( + "Embedded Positional List Scope Type"); + Blue blue = ProcessorTestSupport.blue(provider); + Node source = blue.yamlToNode( + "type:\n" + + " blueId: " + scopeTypeBlueId + "\n" + + "list:\n" + + " items:\n" + + " - $pos: 1\n" + + " value: C"); + ResolvedSnapshot captured = blue.resolveToSnapshot(source.clone()); + + ScopeSourceProjection nodeProjection = ScopeSourceProjection.project( + "/", + FrozenNode.fromResolvedNode(source), + captured, + blue.getDocumentProcessor().snapshotManager()); + ScopeSourceProjection snapshotProjection = ScopeSourceProjection.project( + "/", + captured.frozenCanonicalRoot(), + captured, + blue.getDocumentProcessor().snapshotManager()); + + String expected = blue.calculateSemanticBlueId(source); + assertEquals(expected, nodeProjection.contentBlueId()); + assertEquals(expected, snapshotProjection.contentBlueId()); + assertTrue(snapshotProjection.standaloneSnapshot().frozenResolvedRoot() + .sameResolvedStructure(captured.frozenResolvedRoot())); + assertInitializationIdentity(blue.initializeDocument(source.clone()), expected); + assertInitializationIdentity(blue.initializeDocument(captured), expected); + } + + @Test + void snapshotProjectionRestoresPureReferenceInsideInheritedListReplacement() { + BasicNodeProvider provider = new BasicNodeProvider(); + provider.addSingleDocs( + "name: Reference List Scope Type\n" + + "list:\n" + + " type: List\n" + + " mergePolicy: positional\n" + + " items:\n" + + " - A\n" + + " - B"); + Node referenced = new Node() + .name("Replacement Entry") + .properties("payload", text("verified")); + provider.addSingleNodes(referenced); + String scopeTypeBlueId = provider.getBlueIdByName("Reference List Scope Type"); + String referencedBlueId = provider.getBlueIdByName("Replacement Entry"); + Blue blue = ProcessorTestSupport.blue(provider); + Node inheritedList = blue.resolve(new Node().type(reference(scopeTypeBlueId))) + .getAsNode("/list"); + String previousBlueId = BlueIdCalculator.calculateBlueId(inheritedList.getItems()); + provider.addListAndItsItems(inheritedList.getItems()); + Node source = blue.yamlToNode( + "type:\n" + + " blueId: " + scopeTypeBlueId + "\n" + + "list:\n" + + " type: List\n" + + " mergePolicy: positional\n" + + " items:\n" + + " - $previous:\n" + + " blueId: " + previousBlueId + "\n" + + " - $pos: 1\n" + + " $replace:\n" + + " blueId: " + referencedBlueId); + ResolvedSnapshot captured = blue.resolveToSnapshot(source.clone()); + + ScopeSourceProjection projection = ScopeSourceProjection.project( + "/", + captured.frozenCanonicalRoot(), + captured, + blue.getDocumentProcessor().snapshotManager()); + + String expected = blue.calculateSemanticBlueId(source); + assertEquals(expected, projection.contentBlueId()); + assertTrue(projection.standaloneSnapshot().frozenResolvedRoot() + .sameResolvedStructure(captured.frozenResolvedRoot())); + FrozenNode replacement = projection.standaloneSource().property("list") + .getItems().get(0).property("$replace"); + assertTrue(replacement.isReferenceOnly()); + assertEquals(referencedBlueId, replacement.getReferenceBlueId()); + + assertInitializationIdentity(blue.initializeDocument(source.clone()), expected); + assertInitializationIdentity(blue.initializeDocument(captured), expected); + } + + @Test + void embeddedParentTypedScopeKeepsListsLabelsAndReferencesAcrossNodeAndSnapshotInputs() { + BasicNodeProvider provider = new BasicNodeProvider(); + Node referenced = new Node() + .name("Combined Projection Reference") + .properties("payload", text("verified")); + Node referencedContract = new Node() + .name("Combined Projection Lifecycle Channel") + .type(reference(RuntimeBlueIds.LIFECYCLE_EVENT_CHANNEL)); + provider.addSingleNodes(referenced, referencedContract); + String referencedBlueId = provider.getBlueIdByName(referenced.getName()); + String referencedContractBlueId = provider.getBlueIdByName(referencedContract.getName()); + + Node childType = new Node() + .name("Combined Embedded Child Type") + .description("Combined embedded description") + .properties("entries", new Node() + .type(reference(Properties.LIST_TYPE_BLUE_ID)) + .mergePolicy("positional") + .items(Arrays.asList( + new Node() + .name("Equal Nested Label") + .properties("nested", new Node().items(Arrays.asList( + text("inherited-a"), text("inherited-b")))), + text("inherited-tail")))); + provider.addSingleNodes(childType); + String childTypeBlueId = provider.getBlueIdByName(childType.getName()); + + Node rootType = new Node() + .name("Combined Embedded Root Type") + .properties("child", new Node().type(reference(childTypeBlueId))); + provider.addSingleNodes(rootType); + String rootTypeBlueId = provider.getBlueIdByName(rootType.getName()); + + Blue blue = ProcessorTestSupport.blue(provider); + List inheritedItems = blue.resolve(new Node().type(reference(childTypeBlueId))) + .getAsNode("/entries").getItems(); + String previousBlueId = BlueIdCalculator.calculateBlueId(inheritedItems); + provider.addListAndItsItems(inheritedItems); + Node selectedChild = new Node() + .properties("entries", new Node() + .type(reference(Properties.LIST_TYPE_BLUE_ID)) + .mergePolicy("positional") + .items(Arrays.asList( + new Node().previousBlueId(previousBlueId), + new Node().position(0).properties("$replace", new Node() + .name("Equal Nested Label") + .properties("nested", new Node().items(Arrays.asList( + text("inherited-a"), text("inherited-b")))) + .properties("selected", text("local"))), + reference(referencedBlueId)))) + .contracts(new Node().properties( + "referenceEvidence", reference(referencedContractBlueId))); + Node source = new Node() + .type(reference(rootTypeBlueId)) + .properties("child", selectedChild) + .contracts(new Node().properties("embedded", new Node() + .type(reference(RuntimeBlueIds.PROCESS_EMBEDDED)) + .properties("paths", new Node().items(Arrays.asList(text("/child")))))); + Node standaloneChild = selectedChild.clone().type(reference(childTypeBlueId)); + String expected = blue.calculateSemanticBlueId(standaloneChild); + ResolvedSnapshot captured = blue.resolveToSnapshot(source.clone()); + + ScopeSourceProjection snapshotProjection = ScopeSourceProjection.project( + "/child", + captured.canonicalAt("/child"), + captured, + blue.getDocumentProcessor().snapshotManager()); + DocumentProcessingResult nodeResult = blue.initializeDocument(source.clone()); + DocumentProcessingResult snapshotResult = blue.initializeDocument(captured); + + assertEquals(expected, snapshotProjection.contentBlueId()); + assertTrue(snapshotProjection.standaloneSnapshot().frozenResolvedRoot() + .sameResolvedStructure(captured.resolvedAt("/child"))); + assertScopeInitializationIdentity(nodeResult, "/child", expected); + assertScopeInitializationIdentity(snapshotResult, "/child", expected); + } + + @Test + void providerBackedPureReferenceAtSelectedRootRetainsItsSourceProvenance() { + BasicNodeProvider provider = new BasicNodeProvider(); + Node referencedScope = new Node() + .name("Referenced Scope") + .description("Provider-backed selected root") + .properties("state", text("ready")); + provider.addSingleNodes(referencedScope); + String referencedBlueId = provider.getBlueIdByName(referencedScope.getName()); + Blue blue = ProcessorTestSupport.blue(provider); + ResolvedSnapshot captured = blue.resolveToSnapshot(reference(referencedBlueId)); + + ScopeSourceProjection projection = ScopeSourceProjection.project( + "/", + captured.frozenCanonicalRoot(), + captured, + blue.getDocumentProcessor().snapshotManager()); + + assertTrue(projection.standaloneSource().isReferenceOnly()); + assertEquals(referencedBlueId, projection.standaloneSource().getReferenceBlueId()); + assertEquals(referencedBlueId, projection.contentBlueId()); + assertTrue(projection.standaloneSnapshot().frozenResolvedRoot() + .sameResolvedStructure(captured.frozenResolvedRoot())); + + assertInvalidProcessingDocument( + blue.initializeDocument(reference(referencedBlueId))); + assertInvalidProcessingDocument(blue.initializeDocument(captured)); + } + + @Test + void protocolIdentityPreservesPureReferencesInPropertyListAndContracts() { + Node referencedPayload = new Node() + .name("Protocol Reference Payload") + .properties("payload", text("verified")); + Node referencedLifecycleChannel = new Node() + .name("Protocol Reference Lifecycle Channel") + .type(reference(RuntimeBlueIds.LIFECYCLE_EVENT_CHANNEL)); + String payloadBlueId = BlueIdCalculator.calculateBlueId(referencedPayload); + String channelBlueId = BlueIdCalculator.calculateBlueId(referencedLifecycleChannel); + Node source = new Node() + .properties("propertyReference", reference(payloadBlueId)) + .properties("list", new Node().items(Arrays.asList( + reference(payloadBlueId), text("tail")))) + .contracts(new Node().properties( + "referencedLifecycle", reference(channelBlueId))); + + Blue oracle = ProcessorTestSupport.blue(referenceProvider( + referencedPayload, referencedLifecycleChannel)); + String expected = oracle.calculateSemanticBlueId(source.clone()); + + Blue projectionBlue = ProcessorTestSupport.blue(referenceProvider( + referencedPayload, referencedLifecycleChannel)); + ResolvedSnapshot captured = projectionBlue.resolveToSnapshot(source.clone()); + + ScopeSourceProjection projection = ScopeSourceProjection.project( + "/", + captured.frozenCanonicalRoot(), + captured, + projectionBlue.getDocumentProcessor().snapshotManager()); + + assertEquals(expected, projection.contentBlueId()); + assertTrue(projection.standaloneSnapshot().frozenResolvedRoot() + .sameResolvedStructure(captured.frozenResolvedRoot())); + assertTrue(projection.standaloneSource().property("propertyReference").isReferenceOnly()); + assertTrue(projection.standaloneSource().property("list") + .getItems().get(0).isReferenceOnly()); + assertTrue(projection.standaloneSource().getContracts() + .property("referencedLifecycle").isReferenceOnly()); + + Blue nodeExecution = ProcessorTestSupport.blue(referenceProvider( + referencedPayload, referencedLifecycleChannel)); + assertInitializationIdentity(nodeExecution.initializeDocument(source.clone()), expected); + assertInitializationIdentity(nodeExecution.initializeDocument(source.clone()), expected); + + Blue snapshotProducer = ProcessorTestSupport.blue(referenceProvider( + referencedPayload, referencedLifecycleChannel)); + ResolvedSnapshot snapshotInput = snapshotProducer.resolveToSnapshot(source.clone()); + Blue snapshotExecution = ProcessorTestSupport.blue(referenceProvider( + referencedPayload, referencedLifecycleChannel)); + assertInitializationIdentity(snapshotExecution.initializeDocument(snapshotInput), expected); + assertInitializationIdentity(snapshotExecution.initializeDocument(snapshotInput), expected); + } + + @Test + void providerFailureForPureReferenceContractTerminatesBeforeInitiation() { + Node referencedLifecycleChannel = new Node() + .name("Unavailable Protocol Reference Lifecycle Channel") + .type(reference(RuntimeBlueIds.LIFECYCLE_EVENT_CHANNEL)); + String channelBlueId = BlueIdCalculator.calculateBlueId(referencedLifecycleChannel); + Node source = new Node().contracts(new Node().properties( + "referencedLifecycle", reference(channelBlueId))); + + DocumentProcessingResult missing = ProcessorTestSupport.blue( + blueId -> null).initializeDocument(source.clone()); + assertProviderFailureBeforeInitiation( + missing, ProcessorErrorCategory.ProviderUnavailable, channelBlueId); + + NodeProvider mismatchProvider = blueId -> channelBlueId.equals(blueId) + ? Collections.singletonList(new Node().name("Wrong Contract Content")) + : null; + DocumentProcessingResult mismatch = ProcessorTestSupport.blue( + mismatchProvider).initializeDocument(source.clone()); + assertProviderFailureBeforeInitiation( + mismatch, ProcessorErrorCategory.ProviderBlueIdMismatch, channelBlueId); + } + + @Test + void structuralProofMismatchTerminatesBeforeInitiation() { + Blue configured = ProcessorTestSupport.blue(); + DocumentProcessor configuredProcessor = configured.getDocumentProcessor(); + String proofChildBlueId = BlueIdCalculator.calculateBlueId( + new Node().name("Same BlueId Proof Child")); + ProcessingSnapshotManager mismatchManager = new ProofMismatchSnapshotManager( + configuredProcessor.snapshotManager(), proofChildBlueId); + DocumentProcessor processor = DocumentProcessor.builder() + .withSnapshotManager(mismatchManager) + .withConformanceEngine(configuredProcessor.conformanceEngine()) + .withMatchingService(configuredProcessor.matchingService()) + .build(); + Node source = configured.yamlToNode( + "name: Structural Proof Mismatch\ncontracts: {}\n"); + + DocumentProcessingResult result = processor.initializeDocument(source); + + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertEquals(ProcessorErrorCategory.InternalProcessorError, + result.errorCategory(), result.failureReason()); + assertTrue(result.failureReason().contains( + "Standalone selected-scope projection changed the resolved view"), + result.failureReason()); + assertTrue(result.failureReason().contains("/proofChild"), result.failureReason()); + assertFalse(hasNode(result.document(), "/contracts/initialized")); + assertTrue(hasNode(result.document(), "/contracts/terminated")); + for (Node event : result.triggeredEvents()) { + String eventType = event.getType() != null ? event.getType().getBlueId() : null; + assertNotEquals(RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED, eventType, + "structural proof failure must precede lifecycle initiation"); + } + } + + @Test + void projectionPreservesReferencesPreprocessingAndFinalListControlSemantics() { + BasicNodeProvider provider = new BasicNodeProvider(); + Node referenced = new Node() + .name("Projection Reference") + .properties("payload", text("verified")); + provider.addSingleNodes(referenced); + String referencedBlueId = provider.getBlueIdByName(referenced.getName()); + + List previousItems = Arrays.asList(text("old-a"), text("old-b")); + String previousBlueId = BlueIdCalculator.calculateBlueId(previousItems); + provider.addList(previousItems); + + Node replacement = new Node() + .position(0) + .properties("$replace", reference(referencedBlueId)); + Node controlledList = new Node() + .type(reference(Properties.LIST_TYPE_BLUE_ID)) + .items(Arrays.asList( + new Node().previousBlueId(previousBlueId), + replacement, + new Node().items(Arrays.asList(text("nested-a"), text("nested-b"))))); + Node source = new Node() + .properties("preprocessed", new Node().type("Text").value("alias-source")) + .properties("propertyReference", reference(referencedBlueId)) + .properties("controlledList", controlledList) + .contracts(new Node().properties("referenceEvidence", reference(referencedBlueId))); + Blue blue = ProcessorTestSupport.blue(provider); + ResolvedSnapshot captured = blue.resolveToSnapshot(source.clone()); + + ScopeSourceProjection projection = ScopeSourceProjection.project( + "/", + captured.frozenCanonicalRoot(), + captured, + blue.getDocumentProcessor().snapshotManager()); + FrozenNode projectedSource = projection.standaloneSource(); + FrozenNode projectedList = projectedSource.property("controlledList"); + + assertEquals(blue.calculateSemanticBlueId(source), projection.contentBlueId()); + assertTrue(projection.standaloneSnapshot().frozenResolvedRoot() + .sameResolvedStructure(captured.frozenResolvedRoot())); + assertTrue(projectedSource.property("propertyReference").isReferenceOnly()); + assertTrue(projectedSource.getContracts().property("referenceEvidence").isReferenceOnly()); + assertEquals(Properties.TEXT_TYPE_BLUE_ID, + projectedSource.property("preprocessed").getType().getReferenceBlueId()); + assertNotNull(projectedList); + assertEquals(3, projectedList.getItems().size()); + assertTrue(projectedList.getItems().get(0).isReferenceOnly()); + assertEquals(referencedBlueId, + projectedList.getItems().get(0).getReferenceBlueId()); + assertTrue(projectedList.getItems().get(2).hasItems()); + for (FrozenNode item : projectedList.getItems()) { + assertFalse(item.getPreviousBlueId() != null || item.getPosition() != null, + "canonical standalone projection must preserve final list semantics, not controls"); + } + } + + private static Node reference(String blueId) { + return new Node().blueId(blueId); + } + + private static void assertInitializationIdentity(DocumentProcessingResult result, + String expected) { + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertEquals(expected, + result.document().getAsText("/contracts/initialized/documentId")); + assertTrue(result.triggeredEvents().stream().anyMatch(event -> event.getType() != null + && blue.language.processor.registry.RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED + .equals(event.getType().getBlueId()) + && expected.equals(event.getAsText("/documentId")))); + } + + private static void assertScopeInitializationIdentity(DocumentProcessingResult result, + String scopePath, + String expected) { + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertEquals(expected, result.document().getAsText( + scopePath + "/contracts/initialized/documentId")); + } + + private static void assertInvalidProcessingDocument(DocumentProcessingResult result) { + assertEquals(ProcessorStatus.INVALID_PROCESSING_DOCUMENT, + result.status(), result.failureReason()); + assertEquals(ProcessorErrorCategory.InvalidProcessingDocument, + result.errorCategory(), result.failureReason()); + assertEquals(0L, result.totalGas()); + assertTrue(result.triggeredEvents().isEmpty()); + assertTrue(result.document().isReferenceOnly()); + } + + private static void assertProviderFailureBeforeInitiation( + DocumentProcessingResult result, + ProcessorErrorCategory expectedCategory, + String requestedBlueId) { + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertEquals(expectedCategory, result.errorCategory(), result.failureReason()); + assertTrue(result.failureReason().contains(requestedBlueId), result.failureReason()); + assertFalse(hasNode(result.document(), "/contracts/initialized")); + assertTrue(hasNode(result.document(), "/contracts/terminated")); + for (Node event : result.triggeredEvents()) { + String eventType = event.getType() != null ? event.getType().getBlueId() : null; + assertNotEquals(RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED, eventType, + "provider failure must precede lifecycle initiation"); + } + } + + private static Node text(String value) { + return new Node().value(value); + } + + private static BasicNodeProvider referenceProvider(Node... nodes) { + BasicNodeProvider provider = new BasicNodeProvider(); + Node[] copies = new Node[nodes.length]; + for (int index = 0; index < nodes.length; index++) { + copies[index] = nodes[index].clone(); + } + provider.addSingleNodes(copies); + return provider; + } + + private static boolean hasNode(Node document, String path) { + try { + return document.getAsNode(path) != null; + } catch (IllegalArgumentException ignored) { + return false; + } + } + + private static final class ProofMismatchSnapshotManager + implements ProcessingSnapshotManager { + private final ProcessingSnapshotManager delegate; + private final String proofChildBlueId; + + private ProofMismatchSnapshotManager(ProcessingSnapshotManager delegate, + String proofChildBlueId) { + this.delegate = delegate; + this.proofChildBlueId = proofChildBlueId; + } + + @Override + public ResolvedSnapshot fromDocument(Node document) { + return withProofChild(delegate.fromDocument(document), "captured"); + } + + @Override + public ResolvedSnapshot fromDocumentTransient(Node document) { + return withProofChild(delegate.fromDocumentTransient(document), "captured"); + } + + @Override + public String calculateScopeContentBlueId(String scopePath, + FrozenNode selectedScope, + ResolvedSnapshot capturedDocumentSnapshot) { + return ScopeSourceProjection.project( + scopePath, + selectedScope, + capturedDocumentSnapshot, + new ProjectionMismatchSnapshotManager(delegate, proofChildBlueId)) + .contentBlueId(); + } + + @Override + public ResolvedSnapshot applyPatch(ResolvedSnapshot snapshot, JsonPatch patch) { + return delegate.applyPatch(snapshot, patch); + } + + @Override + public ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { + return delegate.cacheSnapshot(snapshot); + } + + private ResolvedSnapshot withProofChild(ResolvedSnapshot snapshot, String state) { + Node resolved = snapshot.resolvedRoot(); + resolved.properties("proofChild", new Node() + .blueId(proofChildBlueId) + .properties("state", text(state))); + return new ResolvedSnapshot( + snapshot.frozenCanonicalRoot(), + FrozenNode.fromResolvedNode(resolved), + snapshot.blueId()); + } + } + + private static final class SuccessfulButAlteredSnapshotManager + implements ProcessingSnapshotManager { + private final ResolvedSnapshot authoritative; + private int fromDocumentTransientCalls; + private ResolvedSnapshot capturedSnapshot; + + private SuccessfulButAlteredSnapshotManager(ResolvedSnapshot authoritative) { + this.authoritative = authoritative; + } + + @Override + public ResolvedSnapshot fromDocument(Node document) { + return fromDocumentTransient(document); + } + + @Override + public ResolvedSnapshot fromDocumentTransient(Node document) { + fromDocumentTransientCalls++; + Node alteredResolved = authoritative.resolvedRoot(); + alteredResolved.properties("state", text("successfully-reresolved-differently")); + return new ResolvedSnapshot( + authoritative.frozenCanonicalRoot(), + FrozenNode.fromResolvedNode(alteredResolved), + authoritative.blueId()); + } + + @Override + public String calculateScopeContentBlueId(String scopePath, + FrozenNode selectedScope, + ResolvedSnapshot capturedDocumentSnapshot) { + capturedSnapshot = capturedDocumentSnapshot; + return capturedDocumentSnapshot.blueId(); + } + + @Override + public ResolvedSnapshot applyPatch(ResolvedSnapshot snapshot, JsonPatch patch) { + throw new UnsupportedOperationException("not used by the capture regression"); + } + } + + private static final class ProjectionMismatchSnapshotManager + implements ProcessingSnapshotManager { + private final ProcessingSnapshotManager delegate; + private final String proofChildBlueId; + + private ProjectionMismatchSnapshotManager(ProcessingSnapshotManager delegate, + String proofChildBlueId) { + this.delegate = delegate; + this.proofChildBlueId = proofChildBlueId; + } + + @Override + public ResolvedSnapshot fromDocument(Node document) { + return mismatched(delegate.fromDocument(document)); + } + + @Override + public ResolvedSnapshot fromDocumentTransient(Node document) { + return mismatched(delegate.fromDocumentTransient(document)); + } + + @Override + public ResolvedSnapshot applyPatch(ResolvedSnapshot snapshot, JsonPatch patch) { + return delegate.applyPatch(snapshot, patch); + } + + private ResolvedSnapshot mismatched(ResolvedSnapshot snapshot) { + Node resolved = snapshot.resolvedRoot(); + resolved.properties("proofChild", new Node() + .blueId(proofChildBlueId) + .properties("state", text("projected"))); + return new ResolvedSnapshot( + snapshot.frozenCanonicalRoot(), + FrozenNode.fromResolvedNode(resolved), + snapshot.blueId()); + } + } +} diff --git a/src/test/java/blue/language/processor/SelectedScopeContentBlueIdFailFirstTest.java b/src/test/java/blue/language/processor/SelectedScopeContentBlueIdFailFirstTest.java new file mode 100644 index 0000000..099a6a2 --- /dev/null +++ b/src/test/java/blue/language/processor/SelectedScopeContentBlueIdFailFirstTest.java @@ -0,0 +1,734 @@ +package blue.language.processor; + +import blue.language.Blue; +import blue.language.NodeProvider; +import blue.language.conformance.ConformanceEngine; +import blue.language.model.Node; +import blue.language.processor.model.HandlerContract; +import blue.language.processor.model.JsonPatch; +import blue.language.processor.registry.RuntimeBlueIds; +import blue.language.provider.BasicNodeProvider; +import blue.language.snapshot.FrozenNode; +import blue.language.snapshot.ResolvedSnapshot; +import blue.language.utils.BlueIdCalculator; +import blue.language.utils.MergeReverser; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static blue.language.utils.UncheckedObjectMapper.YAML_MAPPER; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Fail-first coverage for the initialization identity of an embedded scope. + * + *

Every expected identity in this class is calculated from an explicitly + * constructed standalone Source-equivalent document before processing begins. + * The tests deliberately do not hash a child fragment from the parent's + * Canonical Identity Input and do not reconstruct pre-initialization state from + * a returned, already-mutated document.

+ */ +class SelectedScopeContentBlueIdFailFirstTest { + + @Test + void typeDerivedSelectedChildUsesStandaloneIdentityInsteadOfEmptyNodeIdentity() { + ScopeFixture fixture = new ScopeFixture(); + Node source = fixture.source(false); + ResolvedSnapshot parentSnapshot = fixture.identityBlue().resolveToSnapshot(source.clone()); + + assertNull(parentSnapshot.canonicalAt("/child"), + "the selected child must be omitted from the parent identity as fully type-derived"); + + ExpectedIdentities expected = fixture.expectedBeforeLifecycle(source); + LifecycleRecorder recorder = new LifecycleRecorder(); + DocumentProcessingResult result = fixture.executionBlue(recorder).initializeDocument(source); + + assertSuccessful(result); + assertScopeIdentity(result.document(), recorder, "/child", expected.child); + assertNotEquals(emptyNodeBlueId(), expected.child, + "an existing selected scope must never use the empty-node fallback"); + } + + @Test + void contextualChildCanonicalFragmentDoesNotReplaceStandaloneInheritedTypeIdentity() { + ScopeFixture fixture = new ScopeFixture(); + Node source = fixture.source(true); + Blue identityBlue = fixture.identityBlue(); + ResolvedSnapshot parentSnapshot = identityBlue.resolveToSnapshot(source.clone()); + FrozenNode contextualFragment = parentSnapshot.canonicalAt("/child"); + + assertNotNull(contextualFragment); + assertNull(contextualFragment.getType(), + "the parent fragment intentionally omits the type supplied by parent field metadata"); + + Node standaloneChild = fixture.standaloneChildBeforeLifecycle(source); + assertEquals(fixture.childTypeBlueId, standaloneChild.getType().getBlueId()); + String expectedChild = identityBlue.calculateSemanticBlueId(standaloneChild); + assertNotEquals(contextualFragment.blueId(), expectedChild, + "the contextual parent fragment is not the standalone scope Content BlueId input"); + + LifecycleRecorder recorder = new LifecycleRecorder(); + DocumentProcessingResult result = fixture.executionBlue(recorder).initializeDocument(source); + + assertSuccessful(result); + assertScopeIdentity(result.document(), recorder, "/child", expectedChild); + } + + @Test + void explicitRootNameEqualToTypeNameRemainsIdentityBearing() { + Node canonicalType = new Node().name("Same Label"); + BasicNodeProvider provider = new BasicNodeProvider(canonicalType); + String typeBlueId = provider.getBlueIdByName(canonicalType.getName()); + Node source = new Node() + .name(canonicalType.getName()) + .type(reference(typeBlueId)); + Blue identityBlue = ProcessorTestSupport.blue(provider); + String expected = identityBlue.calculateSemanticBlueId(source.clone()); + String withoutExplicitName = identityBlue.calculateSemanticBlueId( + new Node().type(reference(typeBlueId))); + + assertNotEquals(withoutExplicitName, expected); + assertRootInitializationIdentity(identityBlue, source, expected); + } + + @Test + void explicitRootDescriptionEqualToTypeDescriptionRemainsIdentityBearing() { + Node canonicalType = new Node() + .name("Description Type") + .description("Same Description"); + BasicNodeProvider provider = new BasicNodeProvider(canonicalType); + String typeBlueId = provider.getBlueIdByName(canonicalType.getName()); + Node source = new Node() + .description(canonicalType.getDescription()) + .type(reference(typeBlueId)); + Blue identityBlue = ProcessorTestSupport.blue(provider); + String expected = identityBlue.calculateSemanticBlueId(source.clone()); + String withoutExplicitDescription = identityBlue.calculateSemanticBlueId( + new Node().type(reference(typeBlueId))); + + assertNotEquals(withoutExplicitDescription, expected); + assertRootInitializationIdentity(identityBlue, source, expected); + } + + @Test + void explicitRootLabelsDifferentFromTypeLabelsRemainIdentityBearing() { + Node canonicalType = new Node() + .name("Type Label") + .description("Type Description"); + BasicNodeProvider provider = new BasicNodeProvider(canonicalType); + String typeBlueId = provider.getBlueIdByName(canonicalType.getName()); + Node source = new Node() + .name("Instance Label") + .description("Instance Description") + .type(reference(typeBlueId)); + Blue identityBlue = ProcessorTestSupport.blue(provider); + String expected = identityBlue.calculateSemanticBlueId(source.clone()); + + assertRootInitializationIdentity(identityBlue, source, expected); + } + + @Test + void lifecycleMutationIsAfterOwnCaptureAndChildMutationIsBeforeParentCapture() { + ScopeFixture fixture = new ScopeFixture(); + Node source = fixture.source(true); + ExpectedIdentities expected = fixture.expectedBeforeLifecycle(source); + LifecycleRecorder recorder = new LifecycleRecorder(); + + DocumentProcessingResult result = fixture.executionBlue(recorder).initializeDocument(source); + + assertSuccessful(result); + assertEquals(ScopeFixture.CHILD_MUTATION, + result.document().getAsText("/child/lifecycleMutation")); + assertEquals(ScopeFixture.ROOT_MUTATION, + result.document().getAsText("/rootLifecycleMutation")); + assertScopeIdentity(result.document(), recorder, "/child", expected.child); + assertScopeIdentity(result.document(), recorder, "/", expected.rootAfterChildPhase1); + } + + @Test + void nodeAndResolvedSnapshotInputsUseTheSameStandaloneScopeIdentities() { + ScopeFixture fixture = new ScopeFixture(); + Node source = fixture.source(true); + ExpectedIdentities expected = fixture.expectedBeforeLifecycle(source); + + LifecycleRecorder nodeRecorder = new LifecycleRecorder(); + Blue nodeBlue = fixture.executionBlue(nodeRecorder); + DocumentProcessingResult nodeResult = nodeBlue.initializeDocument(source.clone()); + + LifecycleRecorder snapshotRecorder = new LifecycleRecorder(); + Blue snapshotBlue = fixture.executionBlue(snapshotRecorder); + ResolvedSnapshot inputSnapshot = snapshotBlue.resolveToSnapshot(source.clone()); + DocumentProcessingResult snapshotResult = snapshotBlue.initializeDocument(inputSnapshot); + + assertSuccessful(nodeResult); + assertSuccessful(snapshotResult); + Node nodeRootAtCapture = nodeRecorder.onlyScopeSource("/"); + Node snapshotRootAtCapture = snapshotRecorder.onlyScopeSource("/"); + Blue parityOracle = fixture.identityBlue(); + String nodeCapturedIdentity = parityOracle.calculateSemanticBlueId(nodeRootAtCapture); + String snapshotCapturedIdentity = parityOracle.calculateSemanticBlueId(snapshotRootAtCapture); + Node snapshotResolvedChild = inputSnapshot.resolvedNodeAt("/child"); + Node snapshotMinimizedChild = new MergeReverser() + .reverseToMinimizedOverlay(snapshotResolvedChild.clone()); + + assertEquals(nodeCapturedIdentity, snapshotCapturedIdentity, + () -> "Node and snapshot selected roots must remain Source-equivalent at capture.\nnode=" + + parityOracle.nodeToJson(nodeRootAtCapture) + + "\nsnapshot=" + parityOracle.nodeToJson(snapshotRootAtCapture)); + assertEquals(expected.rootAfterChildPhase1, nodeCapturedIdentity); + assertScopeIdentity(nodeResult.document(), nodeRecorder, "/child", expected.child); + assertScopeIdentity(snapshotResult.document(), snapshotRecorder, "/child", expected.child); + assertScopeIdentity(nodeResult.document(), nodeRecorder, "/", expected.rootAfterChildPhase1); + assertEquals(expected.rootAfterChildPhase1, snapshotRecorder.onlyId("/"), + () -> "Snapshot root Lifecycle identity must use the captured selected root." + + "\ncaptured=" + parityOracle.nodeToJson(snapshotRootAtCapture) + + "\nresolvedChild=" + parityOracle.nodeToJson(snapshotResolvedChild) + + "\nminimizedChild=" + parityOracle.nodeToJson(snapshotMinimizedChild)); + assertEquals(expected.rootAfterChildPhase1, + markerDocumentId(snapshotResult.document(), "/")); + } + + @Test + void coldAndWarmCachesKeepTheSameStandaloneScopeIdentities() { + ScopeFixture fixture = new ScopeFixture(); + Node source = fixture.source(true); + ExpectedIdentities expected = fixture.expectedBeforeLifecycle(source); + LifecycleRecorder recorder = new LifecycleRecorder(); + Blue blue = fixture.executionBlue(recorder); + + DocumentProcessingResult cold = blue.initializeDocument(source.clone()); + DocumentProcessingResult warm = blue.initializeDocument(source.clone()); + + assertSuccessful(cold); + assertSuccessful(warm); + assertEquals(2, recorder.ids("/child").size()); + assertEquals(2, recorder.ids("/").size()); + assertScopeIdentity(cold.document(), recorder.id("/child", 0), "/child", expected.child); + assertScopeIdentity(warm.document(), recorder.id("/child", 1), "/child", expected.child); + assertScopeIdentity(cold.document(), recorder.id("/", 0), "/", expected.rootAfterChildPhase1); + assertScopeIdentity(warm.document(), recorder.id("/", 1), "/", expected.rootAfterChildPhase1); + } + + @Test + void nestedListAndProviderReferenceUseStrictStandaloneContentIdentity() { + ScopeFixture fixture = new ScopeFixture(); + Node source = fixture.source(true); + Node standaloneChild = fixture.standaloneChildBeforeLifecycle(source); + ResolvedSnapshot expectedSnapshot = fixture.identityBlue().resolveToSnapshot(standaloneChild); + String expectedChild = expectedSnapshot.blueId(); + String unchecked = BlueIdCalculator.calculateUncheckedBlueId( + expectedSnapshot.frozenCanonicalRoot().toNode()); + FrozenNode canonicalReference = expectedSnapshot.frozenCanonicalRoot().property("providerPayload"); + + assertNotEquals(unchecked, expectedChild, + "the nested payload must distinguish unchecked hashing from Content BlueId"); + assertNotNull(canonicalReference); + assertTrue(canonicalReference.isReferenceOnly(), + "source pure-reference provenance must survive standalone canonicalization"); + assertEquals(fixture.providerPayloadBlueId, canonicalReference.getReferenceBlueId()); + + LifecycleRecorder recorder = new LifecycleRecorder(); + DocumentProcessingResult result = fixture.executionBlue(recorder).initializeDocument(source); + + assertSuccessful(result); + assertScopeIdentity(result.document(), recorder, "/child", expectedChild); + assertNotEquals(unchecked, markerDocumentId(result.document(), "/child")); + } + + @Test + void missingProviderContentDuringScopeIdentityTerminatesFatallyBeforeInitiation() { + assertIdentityFailureTerminatesBeforeInitiation( + new IllegalArgumentException( + "No content found for blueId: scope-identity-missing"), + ProcessorErrorCategory.ProviderUnavailable); + } + + @Test + void providerBlueIdMismatchDuringScopeIdentityTerminatesFatallyBeforeInitiation() { + assertIdentityFailureTerminatesBeforeInitiation( + new IllegalArgumentException( + "Provider returned content for requested blueId scope-identity-request " + + "but computed BlueId scope-identity-other"), + ProcessorErrorCategory.ProviderBlueIdMismatch); + } + + @Test + void snapshotBackedScopeIdentityMissingProviderContentIsProviderUnavailable() { + SnapshotProviderFailureFixture fixture = new SnapshotProviderFailureFixture(); + ResolvedSnapshot producerSnapshot = fixture.producerSnapshot(); + Blue consumer = new Blue(blueId -> null); + + DocumentProcessingResult result = consumer.initializeDocument(producerSnapshot); + + assertSnapshotProviderIdentityFailure( + result, ProcessorErrorCategory.ProviderUnavailable, fixture.typeBlueId); + } + + @Test + void snapshotBackedScopeIdentityRejectsProviderBlueIdMismatch() { + SnapshotProviderFailureFixture fixture = new SnapshotProviderFailureFixture(); + ResolvedSnapshot producerSnapshot = fixture.producerSnapshot(); + Node wrongType = new Node() + .name("Wrong Snapshot Scope Type") + .properties("fixed", text("wrong-provider-content")); + NodeProvider wrongContentProvider = blueId -> fixture.typeBlueId.equals(blueId) + ? Collections.singletonList(wrongType.clone()) + : null; + Blue consumer = new Blue(wrongContentProvider); + + DocumentProcessingResult result = consumer.initializeDocument(producerSnapshot); + + assertSnapshotProviderIdentityFailure( + result, ProcessorErrorCategory.ProviderBlueIdMismatch, fixture.typeBlueId); + } + + private static void assertSnapshotProviderIdentityFailure( + DocumentProcessingResult result, + ProcessorErrorCategory expectedCategory, + String requestedBlueId) { + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertEquals(expectedCategory, result.errorCategory(), result.failureReason()); + assertTrue(result.failureReason().contains(requestedBlueId), result.failureReason()); + assertFalse(hasNode(result.document(), "/contracts/initialized")); + assertTrue(hasNode(result.document(), "/contracts/terminated"), + "the original provider failure must still produce the fatal termination marker"); + assertEquals("fatal", result.document().getAsText("/contracts/terminated/cause")); + for (Node event : result.triggeredEvents()) { + String eventType = event.getType() != null ? event.getType().getBlueId() : null; + assertNotEquals(RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED, eventType, + "provider failure during the scope identity rerun must precede initiation"); + } + } + + private static void assertIdentityFailureTerminatesBeforeInitiation( + RuntimeException identityFailure, + ProcessorErrorCategory expectedCategory) { + ScopeFixture fixture = new ScopeFixture(); + LifecycleRecorder recorder = new LifecycleRecorder(); + IdentityFailureRuntime runtime = fixture.identityFailureRuntime(recorder, identityFailure); + + DocumentProcessingResult result = runtime.processor.initializeDocument(fixture.source(true)); + + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertEquals(expectedCategory, result.errorCategory(), result.failureReason()); + assertFalse(runtime.manager.requestedScopes.isEmpty()); + assertEquals("/child", runtime.manager.requestedScopes.get(0)); + assertTrue(recorder.ids("/child").isEmpty(), + "Document Processing Initiated must not be delivered when identity calculation fails"); + assertTrue(recorder.ids("/").isEmpty(), + "an ancestor must not initialize after its child identity calculation fails"); + assertFalse(hasNode(result.document(), "/child/contracts/initialized")); + assertFalse(hasNode(result.document(), "/contracts/initialized")); + for (Node event : result.triggeredEvents()) { + String eventType = event.getType() != null ? event.getType().getBlueId() : null; + assertNotEquals(RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED, eventType, + "no initiated event may be published after scope identity failure"); + } + } + + private static void assertSuccessful(DocumentProcessingResult result) { + assertFalse(result.capabilityFailure(), result.failureReason()); + assertNull(result.errorCategory(), result.failureReason()); + } + + private static void assertRootInitializationIdentity(Blue blue, + Node source, + String expected) { + DocumentProcessingResult result = blue.initializeDocument(source.clone()); + + assertSuccessful(result); + assertEquals(expected, markerDocumentId(result.document(), "/")); + boolean initiated = false; + for (Node event : result.triggeredEvents()) { + String eventType = event.getType() != null ? event.getType().getBlueId() : null; + if (RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED.equals(eventType) + && expected.equals(event.getAsText("/documentId"))) { + initiated = true; + break; + } + } + assertTrue(initiated, + "Lifecycle and initialized marker must reuse the independently computed Content BlueId"); + } + + private static void assertScopeIdentity(Node document, + LifecycleRecorder recorder, + String scope, + String expected) { + assertScopeIdentity(document, recorder.onlyId(scope), scope, expected); + } + + private static void assertScopeIdentity(Node document, + String lifecycleId, + String scope, + String expected) { + assertEquals(expected, lifecycleId, "Lifecycle documentId at " + scope); + assertEquals(expected, markerDocumentId(document, scope), + "initialized marker documentId at " + scope); + } + + private static String markerDocumentId(Node document, String scope) { + String prefix = "/".equals(scope) ? "" : scope; + return document.getAsText(prefix + "/contracts/initialized/documentId"); + } + + private static boolean hasNode(Node document, String path) { + try { + return document.getAsNode(path) != null; + } catch (IllegalArgumentException ignored) { + return false; + } + } + + private static String emptyNodeBlueId() { + return BlueIdCalculator.calculateBlueId(new Node()); + } + + private static Node reference(String blueId) { + return new Node().blueId(blueId); + } + + private static Node text(String value) { + return new Node().value(value); + } + + private static final class ExpectedIdentities { + private final String child; + private final String rootAfterChildPhase1; + + private ExpectedIdentities(String child, String rootAfterChildPhase1) { + this.child = child; + this.rootAfterChildPhase1 = rootAfterChildPhase1; + } + } + + private static final class SnapshotProviderFailureFixture { + private final BasicNodeProvider producerProvider = new BasicNodeProvider(); + private final String typeBlueId; + + private SnapshotProviderFailureFixture() { + Node providerType = new Node() + .name("Snapshot Backed Scope Type") + .properties("fixed", text("resolved-by-producer")); + producerProvider.addSingleNodes(providerType); + typeBlueId = producerProvider.getBlueIdByName(providerType.getName()); + } + + private ResolvedSnapshot producerSnapshot() { + Node source = new Node() + .type(reference(typeBlueId)) + .properties("local", text("selected-state")); + ResolvedSnapshot snapshot = new Blue(producerProvider).resolveToSnapshot(source); + assertEquals("resolved-by-producer", snapshot.resolvedRoot().getAsText("/fixed")); + assertEquals(typeBlueId, + snapshot.frozenCanonicalRoot().getType().getReferenceBlueId()); + return snapshot; + } + } + + private static final class ScopeFixture { + private static final String CHILD_MUTATION = "child-after-capture"; + private static final String ROOT_MUTATION = "root-after-capture"; + + private final BasicNodeProvider provider = new BasicNodeProvider(); + private final Node lifecycleHandlerType; + private final String lifecycleHandlerBlueId; + private final String childTypeBlueId; + private final String rootTypeBlueId; + private final String providerPayloadBlueId; + + private ScopeFixture() { + lifecycleHandlerType = YAML_MAPPER.readValue( + "name: Capture And Mutate Initialization Lifecycle\n" + + "type:\n" + + " blueId: " + RuntimeBlueIds.HANDLER + "\n", + Node.class); + provider.addSingleNodes(lifecycleHandlerType); + lifecycleHandlerBlueId = provider.getBlueIdByName(lifecycleHandlerType.getName()); + + Node providerPayload = YAML_MAPPER.readValue( + "name: Provider Backed Scope Payload\n" + + "payload: verified\n", + Node.class); + provider.addSingleNodes(providerPayload); + providerPayloadBlueId = provider.getBlueIdByName(providerPayload.getName()); + + Node childType = YAML_MAPPER.readValue( + "name: Contextual Embedded Child Type\n" + + "fixed: from-child-type\n" + + lifecycleContractsYaml("/lifecycleMutation", CHILD_MUTATION), + Node.class); + provider.addSingleNodes(childType); + childTypeBlueId = provider.getBlueIdByName(childType.getName()); + + Node rootType = YAML_MAPPER.readValue( + "name: Contextual Root Type\n" + + "child:\n" + + " type:\n" + + " blueId: " + childTypeBlueId + "\n", + Node.class); + provider.addSingleNodes(rootType); + rootTypeBlueId = provider.getBlueIdByName(rootType.getName()); + } + + private Blue identityBlue() { + return ProcessorTestSupport.blue(provider); + } + + private Blue executionBlue(LifecycleRecorder recorder) { + Blue blue = ProcessorTestSupport.blue(provider); + blue.registerExternalContractType( + lifecycleHandlerBlueId, + lifecycleHandlerType, + new CaptureAndMutateLifecycleProcessor(recorder)); + return blue; + } + + private IdentityFailureRuntime identityFailureRuntime( + LifecycleRecorder recorder, + RuntimeException failure) { + Blue configured = executionBlue(recorder); + DocumentProcessor configuredProcessor = configured.getDocumentProcessor(); + IdentityFailingSnapshotManager manager = new IdentityFailingSnapshotManager( + configuredProcessor.snapshotManager(), failure); + DocumentProcessor processor = DocumentProcessor.builder() + .withSnapshotManager(manager) + .withConformanceEngine(configuredProcessor.conformanceEngine()) + .withMatchingService(configuredProcessor.matchingService()) + .registerContractProcessor( + lifecycleHandlerBlueId, + new CaptureAndMutateLifecycleProcessor(recorder)) + .build(); + return new IdentityFailureRuntime(configured, processor, manager); + } + + private Node source(boolean withContextualPayload) { + StringBuilder yaml = new StringBuilder() + .append("name: Selected Root\n") + .append("type:\n") + .append(" blueId: ").append(rootTypeBlueId).append('\n') + .append("child:\n") + .append(" fixed: from-child-type\n"); + if (withContextualPayload) { + yaml.append(" payload:\n") + .append(" do:\n") + .append(" - - 1\n") + .append(" - [2, 3]\n") + .append(" providerPayload:\n") + .append(" blueId: ").append(providerPayloadBlueId).append('\n'); + } + yaml.append(indent(lifecycleContractsYaml("/lifecycleMutation", CHILD_MUTATION), 2)) + .append("contracts:\n") + .append(" embedded:\n") + .append(" type:\n") + .append(" blueId: ").append(RuntimeBlueIds.PROCESS_EMBEDDED).append('\n') + .append(" paths:\n") + .append(" - /child\n") + .append(indent(lifecycleContractsBodyYaml( + "/rootLifecycleMutation", ROOT_MUTATION), 2)); + return YAML_MAPPER.readValue(yaml.toString(), Node.class); + } + + private Node standaloneChildBeforeLifecycle(Node source) { + Node child = source.getAsNode("/child").clone(); + child.type(reference(childTypeBlueId)); + return child; + } + + private ExpectedIdentities expectedBeforeLifecycle(Node source) { + Blue identityBlue = identityBlue(); + String childId = identityBlue.calculateSemanticBlueId( + standaloneChildBeforeLifecycle(source)); + + Node rootAfterChildPhase1 = source.clone(); + Node child = rootAfterChildPhase1.getAsNode("/child"); + child.properties("lifecycleMutation", text(CHILD_MUTATION)); + child.getContracts().properties("initialized", initializedMarker(childId)); + String rootId = identityBlue.calculateSemanticBlueId(rootAfterChildPhase1); + return new ExpectedIdentities(childId, rootId); + } + + private Node initializedMarker(String documentId) { + return new Node() + .type(reference(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER)) + .properties("documentId", text(documentId)); + } + + private String lifecycleContractsYaml(String propertyKey, String propertyValue) { + return "contracts:\n" + indent(lifecycleContractsBodyYaml(propertyKey, propertyValue), 2); + } + + private String lifecycleContractsBodyYaml(String propertyKey, String propertyValue) { + return "lifecycle:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.LIFECYCLE_EVENT_CHANNEL + "\n" + + "captureAndMutate:\n" + + " channel: lifecycle\n" + + " type:\n" + + " blueId: " + lifecycleHandlerBlueId + "\n" + + " event:\n" + + " type:\n" + + " blueId: " + RuntimeBlueIds.DOCUMENT_PROCESSING_INITIATED + "\n" + + " propertyKey: " + propertyKey + "\n" + + " propertyValue: " + propertyValue + "\n"; + } + + private static String indent(String value, int spaces) { + String prefix = String.format("%" + spaces + "s", ""); + return prefix + value.replace("\n", "\n" + prefix).replaceAll("\\s+$", "") + "\n"; + } + } + + public static final class CaptureAndMutateLifecycle extends HandlerContract { + private String propertyKey; + private String propertyValue; + + public String getPropertyKey() { + return propertyKey; + } + + public void setPropertyKey(String propertyKey) { + this.propertyKey = propertyKey; + } + + public String getPropertyValue() { + return propertyValue; + } + + public void setPropertyValue(String propertyValue) { + this.propertyValue = propertyValue; + } + } + + private static final class CaptureAndMutateLifecycleProcessor + implements HandlerProcessor { + private final LifecycleRecorder recorder; + + private CaptureAndMutateLifecycleProcessor(LifecycleRecorder recorder) { + this.recorder = recorder; + } + + @Override + public Class contractType() { + return CaptureAndMutateLifecycle.class; + } + + @Override + public void execute(CaptureAndMutateLifecycle contract, ProcessorExecutionContext context) { + Node documentId = context.event().getProperties().get("documentId"); + if (documentId == null) { + // The same lifecycle channel also carries termination. This + // observer is deliberately scoped to initiation identity. + return; + } + recorder.record(context.scopePath(), + String.valueOf(documentId.getValue()), + context.documentAt(context.scopePath())); + context.applyPatch(JsonPatch.replace( + context.resolvePointer(contract.getPropertyKey()), + text(contract.getPropertyValue()))); + } + } + + private static final class LifecycleRecorder { + private final Map> idsByScope = new LinkedHashMap<>(); + private final Map> sourcesByScope = new LinkedHashMap<>(); + + private synchronized void record(String scope, String documentId, Node scopeSource) { + idsByScope.computeIfAbsent(scope, ignored -> new ArrayList<>()).add(documentId); + sourcesByScope.computeIfAbsent(scope, ignored -> new ArrayList<>()) + .add(scopeSource != null ? scopeSource.clone() : null); + } + + private synchronized List ids(String scope) { + List ids = idsByScope.get(scope); + return ids != null ? new ArrayList<>(ids) : new ArrayList<>(); + } + + private String onlyId(String scope) { + List ids = ids(scope); + assertEquals(1, ids.size(), "captured Lifecycle events at " + scope); + return ids.get(0); + } + + private String id(String scope, int index) { + return ids(scope).get(index); + } + + private synchronized Node onlyScopeSource(String scope) { + List sources = sourcesByScope.get(scope); + assertNotNull(sources, "captured scope Source-equivalent inputs at " + scope); + assertEquals(1, sources.size(), "captured scope Source-equivalent inputs at " + scope); + return sources.get(0).clone(); + } + } + + private static final class IdentityFailureRuntime { + @SuppressWarnings("unused") + private final Blue configuredBlueOwner; + private final DocumentProcessor processor; + private final IdentityFailingSnapshotManager manager; + + private IdentityFailureRuntime(Blue configuredBlueOwner, + DocumentProcessor processor, + IdentityFailingSnapshotManager manager) { + this.configuredBlueOwner = configuredBlueOwner; + this.processor = processor; + this.manager = manager; + } + } + + private static final class IdentityFailingSnapshotManager implements ProcessingSnapshotManager { + private final ProcessingSnapshotManager delegate; + private final RuntimeException failure; + private final List requestedScopes = new ArrayList<>(); + + private IdentityFailingSnapshotManager(ProcessingSnapshotManager delegate, + RuntimeException failure) { + this.delegate = delegate; + this.failure = failure; + } + + @Override + public ResolvedSnapshot fromDocument(Node document) { + return delegate.fromDocument(document); + } + + @Override + public ResolvedSnapshot fromDocumentTransient(Node document) { + return delegate.fromDocumentTransient(document); + } + + @Override + public String calculateScopeContentBlueId(String scopePath, + FrozenNode selectedScope, + ResolvedSnapshot capturedDocumentSnapshot) { + requestedScopes.add(scopePath); + throw failure; + } + + @Override + public ConformanceEngine transientConformanceEngine(ConformanceEngine conformanceEngine) { + return delegate.transientConformanceEngine(conformanceEngine); + } + + @Override + public ResolvedSnapshot applyPatch(ResolvedSnapshot snapshot, JsonPatch patch) { + return delegate.applyPatch(snapshot, patch); + } + + @Override + public ResolvedSnapshot cacheSnapshot(ResolvedSnapshot snapshot) { + return delegate.cacheSnapshot(snapshot); + } + } +} diff --git a/src/test/java/blue/language/processor/external/ExternalContractIntegrationTest.java b/src/test/java/blue/language/processor/external/ExternalContractIntegrationTest.java index ca8006e..316967c 100644 --- a/src/test/java/blue/language/processor/external/ExternalContractIntegrationTest.java +++ b/src/test/java/blue/language/processor/external/ExternalContractIntegrationTest.java @@ -19,6 +19,7 @@ import blue.language.processor.model.HandlerContract; import blue.language.processor.model.JsonPatch; import blue.language.processor.model.MarkerContract; +import blue.language.processor.registry.RuntimeBlueIds; import java.math.BigInteger; import org.junit.jupiter.api.Test; @@ -42,8 +43,10 @@ class ExternalContractIntegrationTest { void builderRegistersExternalContractsByExplicitBlueIdAndExecutesThem() { ExternalAddAmountProcessor.reset(); DocumentProcessor processor = DocumentProcessor.builder() - .registerContractProcessor(CHANNEL_BLUE_ID, new ExternalAlwaysChannelProcessor()) - .registerContractProcessor(HANDLER_BLUE_ID, new ExternalAddAmountProcessor()) + .registerContractProcessor(CHANNEL_BLUE_ID, + externalTypeNode(ExternalAlwaysChannel.class), new ExternalAlwaysChannelProcessor()) + .registerContractProcessor(HANDLER_BLUE_ID, + externalTypeNode(ExternalAddAmount.class), new ExternalAddAmountProcessor()) .build(); Blue blue = new Blue(); @@ -109,7 +112,8 @@ void registeredExternalTypeRejectsWrongCanonicalNode() { @Test void unknownExternalContractTypeProducesCapabilityFailureWithoutMutation() { DocumentProcessor processor = DocumentProcessor.builder() - .registerContractProcessor(CHANNEL_BLUE_ID, new ExternalAlwaysChannelProcessor()) + .registerContractProcessor(CHANNEL_BLUE_ID, + externalTypeNode(ExternalAlwaysChannel.class), new ExternalAlwaysChannelProcessor()) .build(); Blue blue = new Blue(); Node document = blue.yamlToNode(counterDocument(UNKNOWN_BLUE_ID)); @@ -126,8 +130,10 @@ void unknownExternalContractTypeProducesCapabilityFailureWithoutMutation() { void handlerProcessorCanUseSharedFrozenEventPatternMatching() { MatchingAddAmountProcessor.reset(); DocumentProcessor processor = DocumentProcessor.builder() - .registerContractProcessor(CHANNEL_BLUE_ID, new ExternalAlwaysChannelProcessor()) - .registerContractProcessor(MATCHING_HANDLER_BLUE_ID, new MatchingAddAmountProcessor()) + .registerContractProcessor(CHANNEL_BLUE_ID, + externalTypeNode(ExternalAlwaysChannel.class), new ExternalAlwaysChannelProcessor()) + .registerContractProcessor(MATCHING_HANDLER_BLUE_ID, + externalTypeNode(MatchingAddAmount.class), new MatchingAddAmountProcessor()) .build(); Blue blue = new Blue(); Node document = blue.yamlToNode( @@ -181,8 +187,7 @@ void channelContextEventMutationIsIgnoredUnlessEvaluationReturnsChannelizedEvent " blueId: " + CAPTURE_HANDLER_BLUE_ID + "\n" + " channel: incoming\n"); - DocumentProcessingResult initialized = processor.initializeDocument(document); - processor.processDocument(initialized.document(), amountEvent(1)); + processor.processDocument(markInitialized(document), amountEvent(1)); assertTrue(CaptureEventFlagProcessor.executed); assertFalse(CaptureEventFlagProcessor.sawNormalizedFlag); @@ -199,8 +204,8 @@ void channelProcessorCanRejectStaleNonDuplicateEventsUsingCheckpointContext() { Blue blue = new Blue(); Node document = blue.yamlToNode(counterDocument(SEQUENCE_CHANNEL_BLUE_ID, HANDLER_BLUE_ID)); - DocumentProcessingResult initialized = processor.initializeDocument(document); - DocumentProcessingResult first = processor.processDocument(initialized.document(), sequencedAmountEvent(7, 10)); + DocumentProcessingResult first = processor.processDocument( + markInitialized(document), sequencedAmountEvent(7, 10)); DocumentProcessingResult stale = processor.processDocument(first.document(), sequencedAmountEvent(100, 8)); DocumentProcessingResult fresh = processor.processDocument(stale.document(), sequencedAmountEvent(5, 11)); @@ -216,9 +221,12 @@ void channelProcessorCanRejectStaleNonDuplicateEventsUsingCheckpointContext() { void handlerProcessorCanDeriveChannelFromAnotherScopeContractDuringLoading() { DerivingAddAmountProcessor.reset(); DocumentProcessor processor = DocumentProcessor.builder() - .registerContractProcessor(CHANNEL_BLUE_ID, new ExternalAlwaysChannelProcessor()) - .registerContractProcessor(OPERATION_BLUE_ID, new ExternalOperationProcessor()) - .registerContractProcessor(DERIVED_HANDLER_BLUE_ID, new DerivingAddAmountProcessor()) + .registerContractProcessor(CHANNEL_BLUE_ID, + externalTypeNode(ExternalAlwaysChannel.class), new ExternalAlwaysChannelProcessor()) + .registerContractProcessor(OPERATION_BLUE_ID, + externalTypeNode(ExternalOperation.class), new ExternalOperationProcessor()) + .registerContractProcessor(DERIVED_HANDLER_BLUE_ID, + externalTypeNode(DerivingAddAmount.class), new DerivingAddAmountProcessor()) .build(); Blue blue = new Blue(); Node document = blue.yamlToNode( @@ -251,8 +259,10 @@ void handlerProcessorCanDeriveChannelFromAnotherScopeContractDuringLoading() { void channelEvaluationCanReturnMultipleDeliveriesWithIndependentCheckpoints() { ExternalAddAmountProcessor.reset(); DocumentProcessor processor = DocumentProcessor.builder() - .registerContractProcessor(MULTI_DELIVERY_CHANNEL_BLUE_ID, new MultiDeliveryChannelProcessor()) - .registerContractProcessor(HANDLER_BLUE_ID, new ExternalAddAmountProcessor()) + .registerContractProcessor(MULTI_DELIVERY_CHANNEL_BLUE_ID, + externalTypeNode(MultiDeliveryChannel.class), new MultiDeliveryChannelProcessor()) + .registerContractProcessor(HANDLER_BLUE_ID, + externalTypeNode(ExternalAddAmount.class), new ExternalAddAmountProcessor()) .build(); Blue blue = new Blue(); Node document = blue.yamlToNode(counterDocument(MULTI_DELIVERY_CHANNEL_BLUE_ID, HANDLER_BLUE_ID)); @@ -274,9 +284,12 @@ void channelProcessorCanEvaluateSameScopeChannelFromContext() { DelegatingChannelProcessor.reset(); CaptureEventFlagProcessor.reset(); DocumentProcessor processor = DocumentProcessor.builder() - .registerContractProcessor(CHANNEL_BLUE_ID, new ExternalAlwaysChannelProcessor()) - .registerContractProcessor(DELEGATING_CHANNEL_BLUE_ID, new DelegatingChannelProcessor()) - .registerContractProcessor(CAPTURE_HANDLER_BLUE_ID, new CaptureEventFlagProcessor()) + .registerContractProcessor(CHANNEL_BLUE_ID, + externalTypeNode(ExternalAlwaysChannel.class), new ExternalAlwaysChannelProcessor()) + .registerContractProcessor(DELEGATING_CHANNEL_BLUE_ID, + externalTypeNode(DelegatingChannel.class), new DelegatingChannelProcessor()) + .registerContractProcessor(CAPTURE_HANDLER_BLUE_ID, + externalTypeNode(CaptureEventFlag.class), new CaptureEventFlagProcessor()) .build(); Blue blue = new Blue(); Node document = blue.yamlToNode( @@ -308,8 +321,10 @@ void channelProcessorCanEvaluateSameScopeChannelFromContext() { @Test void derivedHandlerWithoutSameScopeChannelIsInert() { DocumentProcessor processor = DocumentProcessor.builder() - .registerContractProcessor(OPERATION_BLUE_ID, new ExternalOperationProcessor()) - .registerContractProcessor(DERIVED_HANDLER_BLUE_ID, new DerivingAddAmountProcessor()) + .registerContractProcessor(OPERATION_BLUE_ID, + externalTypeNode(ExternalOperation.class), new ExternalOperationProcessor()) + .registerContractProcessor(DERIVED_HANDLER_BLUE_ID, + externalTypeNode(DerivingAddAmount.class), new DerivingAddAmountProcessor()) .build(); Blue blue = new Blue(); Node document = blue.yamlToNode( @@ -367,6 +382,13 @@ private static Node externalTypeNode(Class type) { return new Node().name(type.getSimpleName()); } + private static Node markInitialized(Node document) { + document.getContracts().properties("initialized", new Node() + .type(new Node().blueId(RuntimeBlueIds.PROCESSING_INITIALIZED_MARKER)) + .properties("documentId", new Node().value("existing"))); + return document; + } + public static final class ExternalAlwaysChannel extends ChannelContract { } diff --git a/src/test/java/blue/language/snapshot/FrozenNodeStructuralInternerTest.java b/src/test/java/blue/language/snapshot/FrozenNodeStructuralInternerTest.java index 5cedd03..c9a1cec 100644 --- a/src/test/java/blue/language/snapshot/FrozenNodeStructuralInternerTest.java +++ b/src/test/java/blue/language/snapshot/FrozenNodeStructuralInternerTest.java @@ -149,7 +149,7 @@ void directResolvedStructureComparisonMatchesRefreezeNormalization() { } @Test - void directResolvedStructureComparisonPreservesPropertyOrder() { + void directResolvedStructureComparisonIgnoresNonSemanticPropertyOrder() { Map firstOrder = new LinkedHashMap<>(); firstOrder.put("a", new Node().value(1)); firstOrder.put("b", new Node().value(2)); @@ -160,8 +160,23 @@ void directResolvedStructureComparisonPreservesPropertyOrder() { FrozenNode second = FrozenNode.fromResolvedNode(new Node().properties(secondOrder)); assertEquals(first.blueId(), second.blueId()); - assertFalse(first.sameResolvedStructure(second)); - assertLegacyNormalizationParity(first, second); + assertFalse(first.resolvedStructuralKey().equals(second.resolvedStructuralKey()), + "interner keys retain exact representation order"); + assertTrue(first.sameResolvedStructure(second)); + assertTrue(second.sameResolvedStructure(first)); + } + + @Test + void directResolvedStructureComparisonIgnoresInlineConstructionMode() { + FrozenNode inline = FrozenNode.fromResolvedNode( + new Node().value("same").inlineValue(true)); + FrozenNode wrapped = FrozenNode.fromResolvedNode( + new Node().value("same").inlineValue(false)); + + assertFalse(inline.resolvedStructuralKey().equals(wrapped.resolvedStructuralKey()), + "interner keys retain exact construction representation"); + assertTrue(inline.sameResolvedStructure(wrapped)); + assertTrue(wrapped.sameResolvedStructure(inline)); } @ParameterizedTest(name = "{0}") @@ -196,8 +211,7 @@ private static Stream observableFieldVariants() { Arguments.of("mergePolicy", (UnaryOperator) node -> node.mergePolicy("replace")), Arguments.of("previousBlueId", (UnaryOperator) node -> node.previousBlueId("previous-id")), Arguments.of("position", (UnaryOperator) node -> node.position(3)), - Arguments.of("blue", (UnaryOperator) node -> node.blue(new Node().value("directive"))), - Arguments.of("inlineValue", (UnaryOperator) node -> node.inlineValue(true)) + Arguments.of("blue", (UnaryOperator) node -> node.blue(new Node().value("directive"))) ); } diff --git a/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java b/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java index 16b350f..98708a0 100644 --- a/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java +++ b/src/test/java/blue/language/snapshot/ResolvedReferenceCacheContractTest.java @@ -25,6 +25,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -209,6 +210,108 @@ void concurrentCanonicalMissesShareOneProviderLoad() throws Exception { } } + @Test + void publishedEntryAfterOwnedFlightInstallCompletesWaitingLookupWithoutProviderLoad() throws Exception { + FrozenNode canonical = FrozenNode.fromNode(new Node().value("published-during-flight")); + String blueId = canonical.blueId(); + ResolvedReferenceCache cache = new ResolvedReferenceCache(); + CountDownLatch ownerInstalled = new CountDownLatch(1); + CountDownLatch waiterAwaiting = new CountDownLatch(1); + CountDownLatch releaseOwner = new CountDownLatch(1); + AtomicBoolean blockFirstOwner = new AtomicBoolean(true); + AtomicInteger loads = new AtomicInteger(); + ExecutorService executor = Executors.newFixedThreadPool(2); + ResolvedReferenceCache.setCanonicalLoadObserverForTesting(installedBlueId -> { + if (blueId.equals(installedBlueId) && blockFirstOwner.compareAndSet(true, false)) { + ownerInstalled.countDown(); + awaitUnchecked(releaseOwner); + } + }); + ResolvedReferenceCache.setCanonicalLoadWaitObserverForTesting(waitingBlueId -> { + if (blueId.equals(waitingBlueId)) { + waiterAwaiting.countDown(); + } + }); + try { + Future owner = executor.submit(() -> + cache.getOrLoadVerifiedCanonical(blueId, () -> { + loads.incrementAndGet(); + return canonical; + })); + assertTrue(ownerInstalled.await(5, TimeUnit.SECONDS)); + Future waiter = executor.submit(() -> + cache.getOrLoadVerifiedCanonical(blueId, () -> { + loads.incrementAndGet(); + return canonical; + })); + assertTrue(waiterAwaiting.await(5, TimeUnit.SECONDS)); + + assertSame(canonical, cache.putVerifiedCanonical(blueId, canonical)); + releaseOwner.countDown(); + + assertSame(canonical, owner.get(5, TimeUnit.SECONDS)); + assertSame(canonical, waiter.get(5, TimeUnit.SECONDS)); + assertEquals(0, loads.get()); + } finally { + ResolvedReferenceCache.setCanonicalLoadWaitObserverForTesting(null); + ResolvedReferenceCache.setCanonicalLoadObserverForTesting(null); + releaseOwner.countDown(); + executor.shutdownNow(); + } + } + + @Test + void generationChangeAfterOwnedFlightInstallReleasesWaitingLookup() throws Exception { + FrozenNode canonical = FrozenNode.fromNode(new Node().value("generation-during-flight")); + String blueId = canonical.blueId(); + ResolvedReferenceCache cache = new ResolvedReferenceCache(); + CountDownLatch ownerInstalled = new CountDownLatch(1); + CountDownLatch waiterAwaiting = new CountDownLatch(1); + CountDownLatch releaseOwner = new CountDownLatch(1); + AtomicBoolean blockFirstOwner = new AtomicBoolean(true); + AtomicInteger loads = new AtomicInteger(); + ExecutorService executor = Executors.newFixedThreadPool(2); + ResolvedReferenceCache.setCanonicalLoadObserverForTesting(installedBlueId -> { + if (blueId.equals(installedBlueId) && blockFirstOwner.compareAndSet(true, false)) { + ownerInstalled.countDown(); + awaitUnchecked(releaseOwner); + } + }); + ResolvedReferenceCache.setCanonicalLoadWaitObserverForTesting(waitingBlueId -> { + if (blueId.equals(waitingBlueId)) { + waiterAwaiting.countDown(); + } + }); + try { + Future owner = executor.submit(() -> + cache.getOrLoadVerifiedCanonical(blueId, () -> { + loads.incrementAndGet(); + return canonical; + })); + assertTrue(ownerInstalled.await(5, TimeUnit.SECONDS)); + Future waiter = executor.submit(() -> + cache.getOrLoadVerifiedCanonical(blueId, () -> { + loads.incrementAndGet(); + return canonical; + })); + assertTrue(waiterAwaiting.await(5, TimeUnit.SECONDS)); + + cache.clear(); + releaseOwner.countDown(); + + assertSame(canonical, owner.get(5, TimeUnit.SECONDS)); + assertSame(canonical, waiter.get(5, TimeUnit.SECONDS)); + assertSame(canonical, + cache.getVerifiedCanonical(blueId).orElseThrow(AssertionError::new)); + assertTrue(loads.get() >= 1); + } finally { + ResolvedReferenceCache.setCanonicalLoadWaitObserverForTesting(null); + ResolvedReferenceCache.setCanonicalLoadObserverForTesting(null); + releaseOwner.countDown(); + executor.shutdownNow(); + } + } + @Test void providerLoadDoesNotHoldTheLegacyCollisionStripe() throws Exception { FrozenNode[] collision = canonicalNodesWhoseBlueIdsSharedLegacyStripe(); diff --git a/src/test/resources/blue-contracts-1.0/fixtures/gas/T071_external_channel_attempt_gas_for_rejected_candidates_exact.yaml b/src/test/resources/blue-contracts-1.0/fixtures/gas/T071_external_channel_attempt_gas_for_rejected_candidates_exact.yaml index 8695eb2..ca7c5e1 100644 --- a/src/test/resources/blue-contracts-1.0/fixtures/gas/T071_external_channel_attempt_gas_for_rejected_candidates_exact.yaml +++ b/src/test/resources/blue-contracts-1.0/fixtures/gas/T071_external_channel_attempt_gas_for_rejected_candidates_exact.yaml @@ -25,7 +25,7 @@ initialDocument: event: kind: external-gas expectedCapabilityFailure: false -expectedExactGas: 1207 +expectedExactGas: 1208 expectedDocumentPaths: /acceptedRan: value: true diff --git a/src/test/resources/blue-contracts-1.0/fixtures/initialization/initializationContentBlueIdComputedBeforeInitializedMarker.yaml b/src/test/resources/blue-contracts-1.0/fixtures/initialization/initializationContentBlueIdComputedBeforeInitializedMarker.yaml index 7f3bd6c..60c3070 100644 --- a/src/test/resources/blue-contracts-1.0/fixtures/initialization/initializationContentBlueIdComputedBeforeInitializedMarker.yaml +++ b/src/test/resources/blue-contracts-1.0/fixtures/initialization/initializationContentBlueIdComputedBeforeInitializedMarker.yaml @@ -11,7 +11,7 @@ initialDocument: paths: - /child child: - value: before-init + state: before-init event: kind: initialize expectedStatus: success @@ -19,6 +19,7 @@ expectedInitializationContentBlueIdInput: scope: / timing: after-phase-1-before-initialized-marker excludesPath: /contracts/initialized + expectedContentBlueId: xivoDTohkrQjSffU6WVdsJAsEKhWvxDw2PQEjkV66KR expectedDocumentPathExists: - /contracts/initialized/documentId assertions: diff --git a/src/test/resources/blue-contracts-1.0/fixtures/manifest.yaml b/src/test/resources/blue-contracts-1.0/fixtures/manifest.yaml index 6f0e1db..6f6ede4 100644 --- a/src/test/resources/blue-contracts-1.0/fixtures/manifest.yaml +++ b/src/test/resources/blue-contracts-1.0/fixtures/manifest.yaml @@ -1,5 +1,5 @@ specVersion: "1.0" -fixturePackageIdentity: "sha256:22713df4d50a38b91762aea1e1a360019c1d16c2584ca1bac022305edb4c66d1" +fixturePackageIdentity: "sha256:e6d4895fa007837aa1b54a92d1e4cb3133e2e7daa5cc5bb22e7443b6009a244a" requiredFixtureSet: exact fixtureCount: 136 fixturePackageIdentityAlgorithm: