diff --git a/.gitignore b/.gitignore index 007e65d..ad6f2ff 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ build/ out/ *.class +.DS_Store diff --git a/README.md b/README.md index b168ac5..ae26814 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ repositories { } dependencies { - implementation "blue.coordination:blue-coordination-java:1.0.0" + implementation "blue.coordination:blue-coordination-java:2.0.0-rc.4" } ``` @@ -29,8 +29,8 @@ The project targets Java 8-compatible bytecode, builds with JDK 25, runs tests on Java 8, and depends on: ```groovy -api "blue.language:blue-language-java:3.1.0-rc.10" -api "blue.repo:blue-repo-java:3.0.0-rc.8" +api "blue.language:blue-language-java:3.1.0-rc.16" +api "blue.repo:blue-repo-java:3.0.0-rc.10" api "blue.bex:blue-bex-java:1.1.0-rc.2" ``` @@ -224,6 +224,21 @@ Run tests: ./gradlew test ``` +Run the focused correctness and bounded-memory suites: + +```bash +./gradlew workflowPlanDifferentialTest +./gradlew complexFixtureIntegrationTest +./gradlew memoryIntegrationTest +``` + +Each focused task uses one worker capped at 2 GiB. Passing `-PtestJfr` +runs that focused task on the current modern Gradle JVM and records under +`build/reports/jfr/`; the normal `test` task continues to run on Java 8. + +Blue Language is pinned to the released +`blue.language:blue-language-java:3.1.0-rc.16` artifact from Maven Central. + Build jars: ```bash @@ -236,6 +251,35 @@ Publish locally: ./gradlew publishToMavenLocal ``` +Stage the artifact without writing outside this repository: + +```bash +./gradlew stageLocalMaven +``` + +The staged Maven repository is `build/staging-deploy`. + +Run JMH and generate JSON, CSV, Markdown, and environment metadata: + +```bash +./gradlew jmh +./gradlew jmh -PtestJfr +``` + +Reports are written to `build/reports/jmh`. Generic JMH gates are deliberately +reported as `NOT_CONFIGURED`; operation-level acceptance is exercised by the +focused integration and differential suites in this repository. + +Create the reproducible source archive and SHA-256 sidecar: + +```bash +./gradlew sourceArchive +``` + +Artifacts are written to `build/distributions`. The verified performance and +correctness evidence is recorded in +[`docs/performance/complex-operations-coordination.md`](docs/performance/complex-operations-coordination.md). + ## Test Coverage Current test areas: diff --git a/build.gradle b/build.gradle index 2553add..3be4387 100644 --- a/build.gradle +++ b/build.gradle @@ -15,15 +15,33 @@ plugins { group = 'blue.coordination' version = determineProjectVersion() +def blueLanguageVersion = '3.1.0-rc.16' +def binaryCompatibilityBaselineVersion = providers.gradleProperty( + 'binaryCompatibilityBaselineVersion').getOrElse('2.0.0-rc.4') + base { archivesName = 'blue-coordination-java' } repositories { - if (!System.getenv('CI')) { - mavenLocal() - } mavenCentral() + exclusiveContent { + forRepository { + ivy { + name = 'releasedCoordinationBaseline' + url = uri('https://repo1.maven.org/maven2') + patternLayout { + artifact 'blue/coordination/blue-coordination-java/[revision]/blue-coordination-java-[revision].[ext]' + } + metadataSources { + artifact() + } + } + } + filter { + includeModule 'blue.coordination.baseline', 'blue-coordination-java' + } + } } java { @@ -38,8 +56,16 @@ tasks.withType(JavaCompile).configureEach { options.release = 8 } +configurations { + binaryCompatibilityBaseline { + canBeConsumed = false + canBeResolved = true + transitive = false + } +} + dependencies { - api 'blue.language:blue-language-java:3.1.0-rc.10' + api "blue.language:blue-language-java:${blueLanguageVersion}" api 'blue.repo:blue-repo-java:3.0.0-rc.10' api 'blue.bex:blue-bex-java:1.1.0-rc.2' @@ -49,6 +75,9 @@ dependencies { testImplementation platform('org.junit:junit-bom:5.10.2') testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Alias the released artifact so Gradle never substitutes the current root project. + binaryCompatibilityBaseline "blue.coordination.baseline:blue-coordination-java:${binaryCompatibilityBaselineVersion}" } compileTestJava { @@ -71,6 +100,412 @@ test { } } +def testJfrEnabled = providers.gradleProperty('testJfr') + .map { !'false'.equalsIgnoreCase(it == null ? '' : it.trim()) } + .getOrElse(false) + +def configureFocusedTest = { Test focusedTest, List includedTests -> + focusedTest.group = 'verification' + focusedTest.testClassesDirs = sourceSets.test.output.classesDirs + focusedTest.classpath = sourceSets.test.runtimeClasspath + focusedTest.dependsOn tasks.named('testClasses') + focusedTest.useJUnitPlatform() + focusedTest.maxHeapSize = '2g' + focusedTest.maxParallelForks = 1 + focusedTest.forkEvery = 0L + focusedTest.filter { + includedTests.each { includeTestsMatching(it) } + } + focusedTest.reports { + junitXml.required = true + html.required = true + } + focusedTest.testLogging { + events 'PASSED', 'FAILED', 'SKIPPED' + showStandardStreams = true + } + if (testJfrEnabled) { + def recording = layout.buildDirectory.file("reports/jfr/${focusedTest.name}.jfr").get().asFile + focusedTest.doFirst { + recording.parentFile.mkdirs() + if (recording.exists() && !recording.delete()) { + throw new GradleException("Could not replace JFR recording: ${recording}") + } + } + focusedTest.jvmArgs "-XX:StartFlightRecording=filename=${recording.absolutePath},settings=profile,dumponexit=true" + } else { + focusedTest.javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(8) + } + } +} + +tasks.register('workflowPlanDifferentialTest', Test) { focusedTest -> + description = 'Runs sequential-workflow planning, execution-state, static-update, and semantic differential tests.' + configureFocusedTest(focusedTest, [ + 'blue.coordination.processor.CoordinationProcessorsTest', + 'blue.coordination.processor.SequentialWorkflowExecutionTest', + 'blue.coordination.processor.bex.BexProcessingMetricsTest', + 'blue.coordination.processor.compute.ComputeFrozenPatchHandoffIntegrationTest', + 'blue.coordination.processor.compute.ComputeProgramPlanIntegrationTest', + 'blue.coordination.processor.compute.ComputeWorkflowExecutionTest', + 'blue.coordination.processor.compute.UpdateDocumentBatchApplyIntegrationTest', + 'blue.coordination.processor.workflow.*DifferentialTest', + 'blue.coordination.processor.workflow.ComputeEffectPlanTest', + 'blue.coordination.processor.workflow.SequentialWorkflowRunnerLifecycleTest', + 'blue.coordination.processor.workflow.StaticUpdatePlanTest', + 'blue.coordination.processor.workflow.WorkflowExecutionStateTest', + 'blue.coordination.processor.workflow.WorkflowPatchEntryTest' + ]) +} + +tasks.register('complexFixtureIntegrationTest', Test) { focusedTest -> + description = 'Runs PayNote, embedded-document, mandate, and termination workflow fixtures.' + configureFocusedTest(focusedTest, [ + 'blue.coordination.processor.EmbeddedTerminationWorkflowTest', + 'blue.coordination.processor.compute.*Paynote*', + 'blue.coordination.processor.compute.*Embedded*', + 'blue.coordination.processor.compute.*Mandate*', + 'blue.coordination.processor.compute.*Termination*', + 'blue.coordination.processor.compute.TerminateProcessingWorkflowTest' + ]) +} + +tasks.register('memoryIntegrationTest', Test) { focusedTest -> + description = 'Runs bounded-cache, round-trip, stress, and memory-oriented workflow tests in one 2 GiB worker.' + configureFocusedTest(focusedTest, [ + 'blue.coordination.processor.*Stress*', + 'blue.coordination.processor.*Memory*', + 'blue.coordination.processor.compute.*RoundTrip*', + 'blue.coordination.processor.compute.*Memory*', + 'blue.coordination.processor.compute.RepresentativeWorkflowLifecycleSmokeTest', + 'blue.coordination.processor.workflow.*CacheTest' + ]) +} + +tasks.register('languageAdoptionMetricsArtifactTest', Test) { focusedTest -> + description = 'Runs four representative Language-adoption scenarios and writes deterministic JSON/CSV metrics evidence.' + configureFocusedTest(focusedTest, [ + 'blue.coordination.processor.compute.LanguageAdoptionMetricsArtifactTest' + ]) + outputs.files( + layout.buildDirectory.file('reports/language-adoption/scenario-metrics.json'), + layout.buildDirectory.file('reports/language-adoption/scenario-metrics.csv')) +} + +// Minimal class-file reader used by the verification tasks below. Keeping this +// in the build avoids adding a release plugin solely for two deterministic +// checks, and compares JVM descriptors rather than source formatting. +def parseBinaryApiClass = { byte[] bytecode -> + def input = new DataInputStream(new ByteArrayInputStream(bytecode)) + if (input.readInt() != (0xCAFEBABE as int)) { + throw new GradleException('Invalid class-file magic') + } + int minorVersion = input.readUnsignedShort() + int majorVersion = input.readUnsignedShort() + int constantPoolCount = input.readUnsignedShort() + Object[] constantPool = new Object[constantPoolCount] + for (int index = 1; index < constantPoolCount; index++) { + int tag = input.readUnsignedByte() + if (tag == 1) { + constantPool[index] = input.readUTF() + } else if (tag == 3 || tag == 4) { + input.readInt() + } else if (tag == 5 || tag == 6) { + input.readLong() + index++ + } else if (tag == 7) { + constantPool[index] = Integer.valueOf(input.readUnsignedShort()) + } else if (tag == 8 || tag == 16 || tag == 19 || tag == 20) { + input.readUnsignedShort() + } else if (tag == 9 || tag == 10 || tag == 11 || tag == 12 + || tag == 17 || tag == 18) { + input.readUnsignedShort() + input.readUnsignedShort() + } else if (tag == 15) { + input.readUnsignedByte() + input.readUnsignedShort() + } else { + throw new GradleException("Unsupported constant-pool tag ${tag}") + } + } + + def utf8 = { int index -> + Object value = constantPool[index] + if (!(value instanceof String)) { + throw new GradleException("Invalid UTF-8 constant-pool index ${index}") + } + (String) value + } + def className = { int index -> + if (index == 0) { + return null + } + Object nameIndex = constantPool[index] + if (!(nameIndex instanceof Integer)) { + throw new GradleException("Invalid class constant-pool index ${index}") + } + utf8(((Integer) nameIndex).intValue()).replace('/', '.') + } + def skipAttributes = { DataInputStream stream -> + int attributeCount = stream.readUnsignedShort() + for (int attributeIndex = 0; attributeIndex < attributeCount; attributeIndex++) { + stream.readUnsignedShort() + long remaining = ((long) stream.readInt()) & 0xffffffffL + while (remaining > 0L) { + long skipped = stream.skip(remaining) + if (skipped <= 0L) { + if (stream.read() < 0) { + throw new EOFException('Truncated class-file attribute') + } + skipped = 1L + } + remaining -= skipped + } + } + } + def readMembers = { DataInputStream stream -> + def members = new TreeMap() + int memberCount = stream.readUnsignedShort() + for (int memberIndex = 0; memberIndex < memberCount; memberIndex++) { + int access = stream.readUnsignedShort() + String name = utf8(stream.readUnsignedShort()) + String descriptor = utf8(stream.readUnsignedShort()) + skipAttributes(stream) + if ((access & (0x0001 | 0x0004)) != 0 && name != '') { + members.put(name + descriptor, Integer.valueOf(access)) + } + } + members + } + + int access = input.readUnsignedShort() + String name = className(input.readUnsignedShort()) + String superName = className(input.readUnsignedShort()) + int interfaceCount = input.readUnsignedShort() + def interfaces = new TreeSet() + for (int interfaceIndex = 0; interfaceIndex < interfaceCount; interfaceIndex++) { + interfaces.add(className(input.readUnsignedShort())) + } + Map fields = readMembers(input) + Map methods = readMembers(input) + skipAttributes(input) + [ + name : name, + access : access, + superName : superName, + interfaces : interfaces, + fields : fields, + methods : methods, + minorVersion: minorVersion, + majorVersion: majorVersion + ] +} + +def readBinaryApi = { File archive -> + def classes = new TreeMap>() + def zip = new java.util.zip.ZipFile(archive) + try { + def entries = Collections.list(zip.entries()) + .findAll { entry -> + !entry.directory + && entry.name.endsWith('.class') + && !entry.name.startsWith('META-INF/versions/') + && !entry.name.endsWith('/module-info.class') + && entry.name != 'module-info.class' + } + .sort { left, right -> left.name <=> right.name } + entries.each { entry -> + Map parsed = zip.getInputStream(entry).withCloseable { stream -> + parseBinaryApiClass(stream.bytes) + } + if ((((Integer) parsed.access).intValue() & (0x0001 | 0x0004)) != 0) { + classes.put((String) parsed.name, parsed) + } + } + } finally { + zip.close() + } + classes +} + +def visibilityCompatible = { int oldAccess, int newAccess -> + boolean oldPublic = (oldAccess & 0x0001) != 0 + boolean newPublic = (newAccess & 0x0001) != 0 + boolean newProtected = (newAccess & 0x0004) != 0 + oldPublic ? newPublic : (newPublic || newProtected) +} + +def incompatibleModifierChanges = { int oldAccess, int newAccess, boolean method -> + def changes = new ArrayList() + if (((oldAccess ^ newAccess) & 0x0008) != 0) { + changes.add('static modifier changed') + } + if ((oldAccess & 0x0010) == 0 && (newAccess & 0x0010) != 0) { + changes.add('became final') + } + if (method && (oldAccess & 0x0400) == 0 && (newAccess & 0x0400) != 0) { + changes.add('became abstract') + } + changes +} + +def binaryCompatibilityReport = layout.buildDirectory.file( + 'reports/binary-compatibility/blue-coordination-java.txt') +def binaryCompatibilityCheck = tasks.register('binaryCompatibilityCheck') { + group = 'verification' + description = 'Checks public/protected JVM API compatibility with the previous Coordination candidate.' + dependsOn tasks.named('jar') + inputs.files(configurations.binaryCompatibilityBaseline) + inputs.file(tasks.named('jar').flatMap { it.archiveFile }) + inputs.property('baselineVersion', binaryCompatibilityBaselineVersion) + outputs.file(binaryCompatibilityReport) + doLast { + Set resolvedBaseline = configurations.binaryCompatibilityBaseline.resolve() + if (resolvedBaseline.size() != 1) { + throw new GradleException( + "Expected one binary compatibility baseline JAR, found ${resolvedBaseline}") + } + File baselineJar = resolvedBaseline.iterator().next() + File currentJar = tasks.named('jar').get().archiveFile.get().asFile + Map> baselineApi = readBinaryApi(baselineJar) + Map> currentApi = readBinaryApi(currentJar) + def problems = new ArrayList() + + baselineApi.each { String className, Map oldClass -> + Map newClass = currentApi.get(className) + if (newClass == null) { + problems.add("${className}: public/protected class was removed") + return + } + int oldAccess = ((Integer) oldClass.access).intValue() + int newAccess = ((Integer) newClass.access).intValue() + if (!visibilityCompatible(oldAccess, newAccess)) { + problems.add("${className}: class visibility was reduced") + } + if (((oldAccess ^ newAccess) & (0x0200 | 0x2000 | 0x4000)) != 0) { + problems.add("${className}: class/interface/annotation/enum kind changed") + } + if ((oldAccess & 0x0010) == 0 && (newAccess & 0x0010) != 0) { + problems.add("${className}: class became final") + } + if ((oldAccess & 0x0400) == 0 && (newAccess & 0x0400) != 0) { + problems.add("${className}: class became abstract") + } + if (oldClass.superName != newClass.superName) { + problems.add("${className}: superclass changed from ${oldClass.superName} to ${newClass.superName}") + } + ((Set) oldClass.interfaces).each { String interfaceName -> + if (!((Set) newClass.interfaces).contains(interfaceName)) { + problems.add("${className}: directly implemented interface ${interfaceName} was removed") + } + } + + ['field', 'method'].each { String memberKind -> + Map oldMembers = memberKind == 'field' + ? (Map) oldClass.fields + : (Map) oldClass.methods + Map newMembers = memberKind == 'field' + ? (Map) newClass.fields + : (Map) newClass.methods + oldMembers.each { String signature, Integer oldMemberAccess -> + Integer newMemberAccess = newMembers.get(signature) + if (newMemberAccess == null) { + problems.add("${className}: ${memberKind} ${signature} was removed or changed descriptor") + return + } + if (!visibilityCompatible(oldMemberAccess.intValue(), + newMemberAccess.intValue())) { + problems.add("${className}: ${memberKind} ${signature} visibility was reduced") + } + incompatibleModifierChanges(oldMemberAccess.intValue(), + newMemberAccess.intValue(), memberKind == 'method').each { change -> + problems.add("${className}: ${memberKind} ${signature} ${change}") + } + } + } + } + + def digest = java.security.MessageDigest.getInstance('SHA-256') + baselineJar.withInputStream { stream -> + byte[] buffer = new byte[8192] + int read + while ((read = stream.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read) + } + } + } + String baselineSha256 = digest.digest() + .collect { String.format('%02x', it & 0xff) }.join() + File report = binaryCompatibilityReport.get().asFile + report.parentFile.mkdirs() + report.withWriter('UTF-8') { writer -> + writer.writeLine("baseline=blue.coordination:blue-coordination-java:${binaryCompatibilityBaselineVersion}") + writer.writeLine("baselineJar=${baselineJar.name}") + writer.writeLine("baselineSha256=${baselineSha256}") + writer.writeLine("currentJar=${currentJar.name}") + writer.writeLine("baselineApiClasses=${baselineApi.size()}") + writer.writeLine("currentApiClasses=${currentApi.size()}") + writer.writeLine("compatible=${problems.isEmpty()}") + problems.sort().each { writer.writeLine("problem=${it}") } + } + if (!problems.isEmpty()) { + throw new GradleException( + "Binary compatibility check failed with ${problems.size()} problem(s); see ${report}") + } + } +} + +def java8BytecodeReport = layout.buildDirectory.file( + 'reports/bytecode/java8-bytecode.txt') +def verifyJava8Bytecode = tasks.register('verifyJava8Bytecode') { + group = 'verification' + description = 'Verifies that every class in the published Coordination JAR targets Java 8 or earlier.' + dependsOn tasks.named('jar') + inputs.file(tasks.named('jar').flatMap { it.archiveFile }) + outputs.file(java8BytecodeReport) + doLast { + File currentJar = tasks.named('jar').get().archiveFile.get().asFile + def versions = new TreeMap() + def zip = new java.util.zip.ZipFile(currentJar) + try { + Collections.list(zip.entries()) + .findAll { entry -> !entry.directory && entry.name.endsWith('.class') } + .sort { left, right -> left.name <=> right.name } + .each { entry -> + Map parsed = zip.getInputStream(entry).withCloseable { stream -> + parseBinaryApiClass(stream.bytes) + } + versions.put(entry.name, (Integer) parsed.majorVersion) + } + } finally { + zip.close() + } + def incompatible = versions.findAll { String name, Integer major -> major > 52 } + File report = java8BytecodeReport.get().asFile + report.parentFile.mkdirs() + report.withWriter('UTF-8') { writer -> + writer.writeLine("jar=${currentJar.name}") + writer.writeLine("classCount=${versions.size()}") + writer.writeLine("maximumAllowedMajorVersion=52") + writer.writeLine("maximumObservedMajorVersion=${versions.values().max() ?: 0}") + writer.writeLine("compatible=${incompatible.isEmpty()}") + incompatible.each { name, major -> + writer.writeLine("problem=${name}: major version ${major}") + } + } + if (!incompatible.isEmpty()) { + throw new GradleException( + "Java 8 bytecode verification failed for ${incompatible.size()} class(es); see ${report}") + } + } +} + +tasks.named('check') { + dependsOn binaryCompatibilityCheck, verifyJava8Bytecode +} + jmh { jmhVersion = '1.37' warmupIterations = 3 @@ -78,18 +513,98 @@ jmh { iterations = 5 timeOnIteration = '1s' fork = 2 - profilers = ['gc'] + profilers = testJfrEnabled + ? ['gc', "jfr:dir=${file("$buildDir/reports/jfr/jmh").absolutePath}"] + : ['gc'] + jvmArgs = ['-Xms512m', '-Xmx2g'] resultFormat = 'JSON' - resultsFile = file("$buildDir/reports/jmh/resolved-processing-host-story.json") + resultsFile = file("$buildDir/reports/jmh/jmh-results.json") +} + +tasks.named('jmh') { + def jsonReport = file("$buildDir/reports/jmh/jmh-results.json") + def csvReport = file("$buildDir/reports/jmh/jmh-results.csv") + def markdownReport = file("$buildDir/reports/jmh/jmh-results.md") + def environmentReport = file("$buildDir/reports/jmh/environment.properties") + outputs.files(csvReport, markdownReport, environmentReport) + doLast { + if (!jsonReport.isFile()) { + throw new GradleException("JMH JSON report was not created: ${jsonReport}") + } + def results = new groovy.json.JsonSlurper().parse(jsonReport) + def csvCell = { Object value -> + '"' + String.valueOf(value == null ? '' : value).replace('"', '""') + '"' + } + def markdownCell = { Object value -> + String.valueOf(value == null ? '' : value).replace('|', '\\|').replace('\n', ' ') + } + + csvReport.parentFile.mkdirs() + csvReport.withWriter('UTF-8') { writer -> + writer.writeLine('benchmark,mode,threads,forks,score,scoreError,scoreUnit,gate') + results.each { result -> + def metric = result.primaryMetric ?: [:] + writer.writeLine([ + result.benchmark, + result.mode, + result.threads, + result.forks, + metric.score, + metric.scoreError, + metric.scoreUnit, + 'NOT_CONFIGURED' + ].collect(csvCell).join(',')) + } + } + + def environment = new TreeMap() + environment.put('blueCoordinationVersion', project.version.toString()) + environment.put('blueLanguageVersion', blueLanguageVersion) + environment.put('gradleVersion', gradle.gradleVersion) + environment.put('gradleJvmMaxHeapBytes', String.valueOf(Runtime.runtime.maxMemory())) + environment.put('javaVendor', System.getProperty('java.vendor', 'unknown')) + environment.put('javaVersion', System.getProperty('java.version', 'unknown')) + environment.put('jmhForkJvmArgs', '-Xms512m,-Xmx2g') + environment.put('osArch', System.getProperty('os.arch', 'unknown')) + environment.put('osName', System.getProperty('os.name', 'unknown')) + environment.put('osVersion', System.getProperty('os.version', 'unknown')) + environment.put('processors', String.valueOf(Runtime.runtime.availableProcessors())) + environmentReport.withWriter('UTF-8') { writer -> + environment.each { key, value -> writer.writeLine("${key}=${value}") } + } + + markdownReport.withWriter('UTF-8') { writer -> + writer.writeLine('# Coordination JMH results') + writer.writeLine('') + writer.writeLine('Environment metadata is recorded in `environment.properties`. Generic JMH hard gates are not configured; each row is marked `NOT_CONFIGURED`.') + writer.writeLine('') + writer.writeLine('| Benchmark | Mode | Threads | Forks | Score | Error | Unit | Gate |') + writer.writeLine('|---|---:|---:|---:|---:|---:|---|---|') + results.each { result -> + def metric = result.primaryMetric ?: [:] + writer.writeLine('| ' + [ + result.benchmark, + result.mode, + result.threads, + result.forks, + metric.score, + metric.scoreError, + metric.scoreUnit, + 'NOT_CONFIGURED' + ].collect(markdownCell).join(' | ') + ' |') + } + } + } } ext.genResourcesDir = file("$buildDir/generated-resources") +def buildPropertiesVersion = project.version.toString() task generateBuildProperties { ext.buildPropertiesFile = file("$genResourcesDir/blue/coordination/build.properties") - inputs.property('buildVersion', project.version.toString()) + inputs.property('buildVersion', buildPropertiesVersion) outputs.file(buildPropertiesFile) doLast { - buildPropertiesFile.text = ("blue-coordination-java.build.version=" + project.version + "\n" + buildPropertiesFile.text = ("blue-coordination-java.build.version=" + buildPropertiesVersion + "\n" + "blue-coordination-java.build.timestamp=" + new Date().format("yyyy-MM-dd'T'HH:mm:ssZ")) } } @@ -144,6 +659,68 @@ publishing { } } +tasks.register('sourceArchive', Zip) { + group = 'distribution' + description = 'Creates a reproducible source-only archive and SHA-256 checksum.' + def sourceArchiveName = "${rootProject.name}-${project.version}-source.zip" + def checksumFile = layout.buildDirectory.file("distributions/${sourceArchiveName}.sha256") + archiveFileName = sourceArchiveName + destinationDirectory = layout.buildDirectory.dir('distributions') + outputs.file(checksumFile) + preserveFileTimestamps = false + reproducibleFileOrder = true + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from(projectDir) { + into("${rootProject.name}-${project.version}") + include '.cz.toml' + include '.github/**' + include '.gitignore' + include 'LICENSE' + include 'README.md' + include 'build.gradle' + include 'settings.gradle' + include 'gradle.properties' + include 'gradle/**' + include 'gradlew' + include 'gradlew.bat' + include 'docs/**' + include 'src/**' + exclude '**/.DS_Store' + exclude '**/*.db' + exclude '**/*.hprof' + exclude '**/*.jfr' + exclude '**/*.log' + exclude '**/*.zip' + exclude '**/build/**' + exclude '**/dumps/**' + exclude '**/logs/**' + exclude '**/node_modules/**' + exclude '**/recordings/**' + } + 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)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read) + } + } + } + def checksum = digest.digest().collect { String.format('%02x', it & 0xff) }.join() + checksumFile.get().asFile.setText( + "${checksum} ${archive.name}\n", 'UTF-8') + } +} + +def stageLocalMaven = tasks.register('stageLocalMaven') { + group = 'publishing' + description = 'Publishes the current Coordination artifact into build/staging-deploy.' + dependsOn tasks.named('publishMavenPublicationToMavenRepository') +} + if (System.getenv('CI')) { jreleaser { signing { diff --git a/docs/coordination-v2-layered-delivery-plan.md b/docs/coordination-v2-layered-delivery-plan.md index 5015601..cdbc4c6 100644 --- a/docs/coordination-v2-layered-delivery-plan.md +++ b/docs/coordination-v2-layered-delivery-plan.md @@ -1370,13 +1370,10 @@ node ../blue-js/libs/repository-generator/dist/bin/blue-repo-generator.mjs \ ../blue-repository-java/gradlew -p ../blue-repository-java \ verifyGeneratedSources clean build -# Task 1: core Processing Event -../blue-language-java/gradlew -p ../blue-language-java test \ - --tests '*ProcessorProcessEventContextTest' - -# Task 2: core routed delivery -../blue-language-java/gradlew -p ../blue-language-java test \ - --tests '*RoutedChannelDeliveryTest' +# Tasks 1-2: released Blue Language dependency +./gradlew dependencyInsight \ + --dependency blue-language-java \ + --configuration runtimeClasspath # Task 3: Timeline V2 ./gradlew test --tests '*Timeline*ChannelProcessorTest' diff --git a/docs/performance/complex-operations-coordination.md b/docs/performance/complex-operations-coordination.md new file mode 100644 index 0000000..b88837a --- /dev/null +++ b/docs/performance/complex-operations-coordination.md @@ -0,0 +1,129 @@ +# Complex operations: Coordination verification + +Evidence refreshed on 2026-07-21. + +## Dependency baseline + +Coordination uses released artifacts from Maven Central: + +```text +blue.language:blue-language-java:3.1.0-rc.16 +blue.repo:blue-repo-java:3.0.0-rc.10 +blue.bex:blue-bex-java:1.1.0-rc.2 +``` + +The build pins the Blue Language version. It does not provide a source-composite, +custom-repository, or version override for that dependency. The staged +Coordination POM must contain the same rc16 coordinate. + +## Implemented processing path + +The optimized path preserves observable ordering and identity: + +```text +eligible Timeline Entry + -> channel and handler matching + -> cached SequentialWorkflowPlan + -> one workflow-owned WorkingDocument + -> ordered Compute / Update Document / Trigger Event steps + -> frozen patch preview and handoff + -> Language validation, gas, routing, handlers, and termination + -> strict published snapshot, events, gas, markers, and final BlueId +``` + +The implementation includes: + +- bounded access-order workflow and Compute plan caches; +- revisioned, read-only workflow result views instead of whole-prefix copies; +- immutable static Update Document templates; +- frozen Compute and Update Document patch handoff; +- failure-safe plan publication and explicit ownership/close paths; +- generic bounded Language metrics with immutable snapshots; +- deterministic differential, fixture, memory, artifact, and bytecode checks. + +## Required invariants + +The release-oriented checks require: + +- exact final BlueIds, status, gas, and event counts for representative static, + multi-patch Compute, PayNote, and Mandate scenarios; +- no mutable patch values frozen by built-in Coordination paths; +- no frozen patch-value materialization after handoff; +- frozen values accepted by Language equal frozen values handed off by + Coordination for the measured scenario delta; +- no singleton Language transactions, stale preview fallbacks, suffix rebases, + dropped metric names, or materialized workflow document views; +- Java 8 class-file compatibility and public binary compatibility; +- deterministic metrics artifacts and a reproducible source archive. + +## Verification commands + +```bash +./gradlew clean build \ + workflowPlanDifferentialTest \ + complexFixtureIntegrationTest \ + memoryIntegrationTest \ + languageAdoptionMetricsArtifactTest \ + sourceArchive \ + jmhClasses \ + stageLocalMaven \ + --rerun-tasks --no-daemon --no-parallel + +./gradlew dependencyInsight \ + --dependency blue-language-java \ + --configuration runtimeClasspath \ + --no-daemon + +git diff --check +``` + +## Evidence locations + +```text +build/reports/tests/ +build/reports/language-adoption/scenario-metrics.json +build/reports/language-adoption/scenario-metrics.csv +build/reports/binary-compatibility/blue-coordination-java.txt +build/reports/bytecode/java8-bytecode.txt +build/staging-deploy/ +build/distributions/ +``` + +Generated build output, caches, recordings, databases, logs, dumps, and ZIP +inputs are excluded from the source archive. + +## Current result + +The 2026-07-21 clean JDK 25 validation resolved +`blue.language:blue-language-java:3.1.0-rc.16` as an external Maven module and +completed with no test failures: + +| Suite | Tests | Failures | +| --- | ---: | ---: | +| Main test suite | 368 | 0 | +| Workflow-plan differential suite | 124 | 0 | +| Complex-fixture integration suite | 55 | 0 | +| Memory integration suite | 17 | 0 | +| Language-adoption metrics artifact | 1 | 0 | + +The four representative artifact scenarios produced these deterministic +semantic results: + +| Scenario | Status | Gas | Events | Final BlueId | +| --- | --- | ---: | ---: | --- | +| Mandate authority confirmation | SUCCESS | 9001 | 1 | `3HBTSrB2AZk9RjR4auvjkn9cdz8r6kH6SduBP4atq1co` | +| Multi-patch Compute | SUCCESS | 234 | 0 | `4LbNZzaixg8mw3g5U7rKknkAvmLkM8zZR7LP7XKEEJxi` | +| PayNote resale fixture | SUCCESS | 2268 | 3 | `HF2mzumBAMQSCfnSvdcziwkmu2jX1gf8KVMoBZrcxC8N` | +| Static Update Document | SUCCESS | 174 | 0 | `BEh1MRkKWKg2sG3c7JXDzX1LyMkiS2LEryfVjBrevYqG` | + +Every measured scenario had frozen workflow views, zero view misses, zero +singleton Language transactions, zero suffix rebases, zero stale-preview +fallbacks, zero Language patch-value materializations, zero dropped metric +names, and equal frozen patch values accepted and handed to Language. The +published rc16 strict-canonical counter was present for every scenario. + +Binary compatibility passed with 26 baseline and 26 current public API +classes. All 84 class files remained Java 8 compatible (maximum class-file +major version 52). The staged POM records rc16 with compile scope. Two +consecutive metrics-artifact and source-archive generations matched +byte-for-byte. diff --git a/src/jmh/java/blue/coordination/processor/workflow/WorkflowExecutionStateBenchmark.java b/src/jmh/java/blue/coordination/processor/workflow/WorkflowExecutionStateBenchmark.java new file mode 100644 index 0000000..20db362 --- /dev/null +++ b/src/jmh/java/blue/coordination/processor/workflow/WorkflowExecutionStateBenchmark.java @@ -0,0 +1,48 @@ +package blue.coordination.processor.workflow; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.infra.Blackhole; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** Allocation/scaling comparison for the per-step result-state boundary. */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +public class WorkflowExecutionStateBenchmark { + + @Param({"100", "500", "1000"}) + public int steps; + + @Benchmark + public void revisionedReadOnlyViews(Blackhole blackhole) { + WorkflowExecutionState state = new WorkflowExecutionState(); + for (int index = 0; index < steps; index++) { + WorkflowExecutionState.Snapshot view = state.snapshotView(); + blackhole.consume(view.size()); + state.record("Step" + index, Integer.valueOf(index), (index & 1) == 0); + } + blackhole.consume(state.snapshotView()); + } + + @Benchmark + public void legacyWholeMapCopies(Blackhole blackhole) { + Map results = new LinkedHashMap(); + for (int index = 0; index < steps; index++) { + Map snapshot = Collections.unmodifiableMap( + new LinkedHashMap(results)); + blackhole.consume(snapshot.size()); + results.put("Step" + index, Integer.valueOf(index)); + } + blackhole.consume(results); + } +} diff --git a/src/main/java/blue/coordination/processor/CoordinationProcessors.java b/src/main/java/blue/coordination/processor/CoordinationProcessors.java index 725e7a0..adc2d24 100644 --- a/src/main/java/blue/coordination/processor/CoordinationProcessors.java +++ b/src/main/java/blue/coordination/processor/CoordinationProcessors.java @@ -2,9 +2,11 @@ import blue.bex.api.BexEngine; import blue.coordination.processor.merge.CoordinationMerging; +import blue.coordination.processor.bex.BexProcessingMetrics; import blue.coordination.processor.workflow.SequentialWorkflowRunner; import blue.language.Blue; import blue.language.processor.DocumentProcessor; +import blue.language.processor.ProcessingMetricsSink; import blue.language.utils.TypeClassResolver; import blue.repo.BlueRepositoryModels; @@ -20,21 +22,19 @@ public static Blue registerWith(Blue blue, CoordinationProcessorOptions options) if (blue == null) { throw new IllegalArgumentException("blue must not be null"); } + BexProcessingMetrics metrics = processingMetrics(options); + if (metrics != null) { + installProcessingMetrics(blue.getDocumentProcessor(), metrics); + } SequentialWorkflowRunner runner = workflowRunner(options); BlueRepositoryModels.registerAll(blue.getDocumentProcessor().getContractTypeResolver()); blue.registerContractProcessor(new TimelineChannelProcessor()); blue.registerContractProcessor(new AllTimelinesChannelProcessor()); blue.registerContractProcessor(new CompositeTimelineChannelProcessor()); blue.registerContractProcessor(new OperationProcessor()); - blue.registerContractProcessor(runner != null - ? new ChatWorkflowOperationProcessor(runner) - : new ChatWorkflowOperationProcessor()); - blue.registerContractProcessor(runner != null - ? new SequentialWorkflowProcessor(runner) - : new SequentialWorkflowProcessor()); - blue.registerContractProcessor(runner != null - ? new SequentialWorkflowOperationProcessor(runner) - : new SequentialWorkflowOperationProcessor()); + blue.registerContractProcessor(new ChatWorkflowOperationProcessor(runner)); + blue.registerContractProcessor(new SequentialWorkflowProcessor(runner)); + blue.registerContractProcessor(new SequentialWorkflowOperationProcessor(runner)); CoordinationMerging.install(blue); return blue; } @@ -48,6 +48,10 @@ public static DocumentProcessor.Builder configure(DocumentProcessor.Builder buil if (builder == null) { throw new IllegalArgumentException("builder must not be null"); } + BexProcessingMetrics metrics = processingMetrics(options); + if (metrics != null) { + builder.withProcessingMetricsSink(metrics); + } SequentialWorkflowRunner runner = workflowRunner(options); TypeClassResolver resolver = BlueRepositoryModels.registerAll( new TypeClassResolver("blue.language.processor.model")); @@ -57,29 +61,148 @@ public static DocumentProcessor.Builder configure(DocumentProcessor.Builder buil .registerContractProcessor(new AllTimelinesChannelProcessor()) .registerContractProcessor(new CompositeTimelineChannelProcessor()) .registerContractProcessor(new OperationProcessor()) - .registerContractProcessor(runner != null - ? new ChatWorkflowOperationProcessor(runner) - : new ChatWorkflowOperationProcessor()) - .registerContractProcessor(runner != null - ? new SequentialWorkflowProcessor(runner) - : new SequentialWorkflowProcessor()) - .registerContractProcessor(runner != null - ? new SequentialWorkflowOperationProcessor(runner) - : new SequentialWorkflowOperationProcessor()); + .registerContractProcessor(new ChatWorkflowOperationProcessor(runner)) + .registerContractProcessor(new SequentialWorkflowProcessor(runner)) + .registerContractProcessor(new SequentialWorkflowOperationProcessor(runner)); } - private static SequentialWorkflowRunner workflowRunner(CoordinationProcessorOptions options) { - if (options == null) { - return null; + private static BexProcessingMetrics processingMetrics(CoordinationProcessorOptions options) { + return options != null ? options.processingMetrics() : null; + } + + private static void installProcessingMetrics(DocumentProcessor processor, + BexProcessingMetrics coordinationMetrics) { + ProcessingMetricsSink existing = processor.processingMetricsSink(); + if (existing == coordinationMetrics) { + return; + } + if (existing == null || existing == ProcessingMetricsSink.NOOP) { + processor.processingMetricsSink(coordinationMetrics); + return; } - if (options.sequentialWorkflowRunner() != null) { + processor.processingMetricsSink(new CompositeProcessingMetricsSink( + existing, coordinationMetrics)); + } + + private static SequentialWorkflowRunner workflowRunner(CoordinationProcessorOptions options) { + if (options != null && options.sequentialWorkflowRunner() != null) { return options.sequentialWorkflowRunner(); } - BexEngine bexEngine = options.bexEngine() != null + BexEngine bexEngine = options != null && options.bexEngine() != null ? options.bexEngine() : BexEngine.builder().build(); return SequentialWorkflowRunner.withBexEngine(bexEngine, - options.defaultComputeGasLimit(), - options.processingMetrics()); + options != null ? options.defaultComputeGasLimit() : 100_000L, + processingMetrics(options)); + } + + /** Static, allocation-free-per-sample fan-out for preserving an independently installed sink. */ + private static final class CompositeProcessingMetricsSink implements ProcessingMetricsSink { + private final ProcessingMetricsSink first; + private final ProcessingMetricsSink second; + + private CompositeProcessingMetricsSink(ProcessingMetricsSink first, + ProcessingMetricsSink second) { + this.first = first; + this.second = second; + } + + @Override public void addMetric(String name, long delta) { first.addMetric(name, delta); second.addMetric(name, delta); } + @Override public void setMetric(String name, long value) { first.setMetric(name, value); second.setMetric(name, value); } + @Override public void recordMetricHighWater(String name, long value) { first.recordMetricHighWater(name, value); second.recordMetricHighWater(name, value); } + @Override public void addProcessDocumentNanos(long value) { first.addProcessDocumentNanos(value); second.addProcessDocumentNanos(value); } + @Override public void addBlueProcessDocumentNanos(long value) { first.addBlueProcessDocumentNanos(value); second.addBlueProcessDocumentNanos(value); } + @Override public void addEventPreprocessNanos(long value) { first.addEventPreprocessNanos(value); second.addEventPreprocessNanos(value); } + @Override public void addResultSnapshotAttachNanos(long value) { first.addResultSnapshotAttachNanos(value); second.addResultSnapshotAttachNanos(value); } + @Override public void addBlueIdCalculationNanos(long value) { first.addBlueIdCalculationNanos(value); second.addBlueIdCalculationNanos(value); } + @Override public void addProcessingSnapshotCacheLookupNanos(long value) { first.addProcessingSnapshotCacheLookupNanos(value); second.addProcessingSnapshotCacheLookupNanos(value); } + @Override public void incrementProcessingSnapshotCacheHits() { first.incrementProcessingSnapshotCacheHits(); second.incrementProcessingSnapshotCacheHits(); } + @Override public void incrementProcessingSnapshotCacheMisses() { first.incrementProcessingSnapshotCacheMisses(); second.incrementProcessingSnapshotCacheMisses(); } + @Override public void addProcessingSnapshotFromDocumentNanos(long value) { first.addProcessingSnapshotFromDocumentNanos(value); second.addProcessingSnapshotFromDocumentNanos(value); } + @Override public void incrementProcessingSnapshotFromDocumentBuilds() { first.incrementProcessingSnapshotFromDocumentBuilds(); second.incrementProcessingSnapshotFromDocumentBuilds(); } + @Override public void incrementProcessEventSnapshotAttempts() { first.incrementProcessEventSnapshotAttempts(); second.incrementProcessEventSnapshotAttempts(); } + @Override public void incrementProcessEventSnapshotBuilds() { first.incrementProcessEventSnapshotBuilds(); second.incrementProcessEventSnapshotBuilds(); } + @Override public void incrementProcessEventSnapshotFailures() { first.incrementProcessEventSnapshotFailures(); second.incrementProcessEventSnapshotFailures(); } + @Override public void addProcessEventSnapshotConstructionNanos(long value) { first.addProcessEventSnapshotConstructionNanos(value); second.addProcessEventSnapshotConstructionNanos(value); } + @Override public void addBundleLoadNanos(long value) { first.addBundleLoadNanos(value); second.addBundleLoadNanos(value); } + @Override public void addBundleLoadCacheKeyBuildNanos(long value) { first.addBundleLoadCacheKeyBuildNanos(value); second.addBundleLoadCacheKeyBuildNanos(value); } + @Override public void addBundleLoadActualBuildNanos(long value) { first.addBundleLoadActualBuildNanos(value); second.addBundleLoadActualBuildNanos(value); } + @Override public void addBundleLoadReuseNanos(long value) { first.addBundleLoadReuseNanos(value); second.addBundleLoadReuseNanos(value); } + @Override public void incrementBundleLoadCacheHits() { first.incrementBundleLoadCacheHits(); second.incrementBundleLoadCacheHits(); } + @Override public void incrementBundleLoadCacheMisses() { first.incrementBundleLoadCacheMisses(); second.incrementBundleLoadCacheMisses(); } + @Override public void incrementBundlesBuilt() { first.incrementBundlesBuilt(); second.incrementBundlesBuilt(); } + @Override public void incrementBundlesReused() { first.incrementBundlesReused(); second.incrementBundlesReused(); } + @Override public void incrementBundleScopeLoadAttempts() { first.incrementBundleScopeLoadAttempts(); second.incrementBundleScopeLoadAttempts(); } + @Override public void incrementBundleScopeExecutionCacheHits() { first.incrementBundleScopeExecutionCacheHits(); second.incrementBundleScopeExecutionCacheHits(); } + @Override public void incrementBundleScopeRefreshes() { first.incrementBundleScopeRefreshes(); second.incrementBundleScopeRefreshes(); } + @Override public void addBundleScopeTerminationCheckNanos(long value) { first.addBundleScopeTerminationCheckNanos(value); second.addBundleScopeTerminationCheckNanos(value); } + @Override public void addBundleScopeResolvedLookupNanos(long value) { first.addBundleScopeResolvedLookupNanos(value); second.addBundleScopeResolvedLookupNanos(value); } + @Override public void addBundleScopeContractLoadNanos(long value) { first.addBundleScopeContractLoadNanos(value); second.addBundleScopeContractLoadNanos(value); } + @Override public void addChannelDiscoveryNanos(long value) { first.addChannelDiscoveryNanos(value); second.addChannelDiscoveryNanos(value); } + @Override public void addChannelMatchNanos(long value) { first.addChannelMatchNanos(value); second.addChannelMatchNanos(value); } + @Override public void incrementChannelEvaluations() { first.incrementChannelEvaluations(); second.incrementChannelEvaluations(); } + @Override public void incrementRoutedChannelDeliveries() { first.incrementRoutedChannelDeliveries(); second.incrementRoutedChannelDeliveries(); } + @Override public void incrementDeduplicatedChannelDeliveries() { first.incrementDeduplicatedChannelDeliveries(); second.incrementDeduplicatedChannelDeliveries(); } + @Override public void addHandlerDiscoveryNanos(long value) { first.addHandlerDiscoveryNanos(value); second.addHandlerDiscoveryNanos(value); } + @Override public void addHandlerMatchNanos(long value) { first.addHandlerMatchNanos(value); second.addHandlerMatchNanos(value); } + @Override public void incrementHandlerMatchAttempts() { first.incrementHandlerMatchAttempts(); second.incrementHandlerMatchAttempts(); } + @Override public void addHandlerExecutionNanos(long value) { first.addHandlerExecutionNanos(value); second.addHandlerExecutionNanos(value); } + @Override public void incrementHandlersExecuted() { first.incrementHandlersExecuted(); second.incrementHandlersExecuted(); } + @Override public void addTriggeredEventRoutingNanos(long value) { first.addTriggeredEventRoutingNanos(value); second.addTriggeredEventRoutingNanos(value); } + @Override public void incrementTriggeredEventsRouted() { first.incrementTriggeredEventsRouted(); second.incrementTriggeredEventsRouted(); } + @Override public void addCheckpointUpdateNanos(long value) { first.addCheckpointUpdateNanos(value); second.addCheckpointUpdateNanos(value); } + @Override public void addCheckpointEnsureNanos(long value) { first.addCheckpointEnsureNanos(value); second.addCheckpointEnsureNanos(value); } + @Override public void addCheckpointFindNanos(long value) { first.addCheckpointFindNanos(value); second.addCheckpointFindNanos(value); } + @Override public void addCheckpointCurrentIdentityNanos(long value) { first.addCheckpointCurrentIdentityNanos(value); second.addCheckpointCurrentIdentityNanos(value); } + @Override public void addCheckpointIsNewerNanos(long value) { first.addCheckpointIsNewerNanos(value); second.addCheckpointIsNewerNanos(value); } + @Override public void addCheckpointDuplicateNanos(long value) { first.addCheckpointDuplicateNanos(value); second.addCheckpointDuplicateNanos(value); } + @Override public void addCheckpointPersistNanos(long value) { first.addCheckpointPersistNanos(value); second.addCheckpointPersistNanos(value); } + @Override public void incrementCheckpointIdentityCacheHits() { first.incrementCheckpointIdentityCacheHits(); second.incrementCheckpointIdentityCacheHits(); } + @Override public void incrementCheckpointIdentityCacheMisses() { first.incrementCheckpointIdentityCacheMisses(); second.incrementCheckpointIdentityCacheMisses(); } + @Override public void incrementCheckpointStoredIdentityCacheHits() { first.incrementCheckpointStoredIdentityCacheHits(); second.incrementCheckpointStoredIdentityCacheHits(); } + @Override public void incrementCheckpointStoredIdentityCacheMisses() { first.incrementCheckpointStoredIdentityCacheMisses(); second.incrementCheckpointStoredIdentityCacheMisses(); } + @Override public void addCheckpointDirectBlueIdNanos(long value) { first.addCheckpointDirectBlueIdNanos(value); second.addCheckpointDirectBlueIdNanos(value); } + @Override public void addCheckpointContentBlueIdNanos(long value) { first.addCheckpointContentBlueIdNanos(value); second.addCheckpointContentBlueIdNanos(value); } + @Override public void addCheckpointFallbackNanos(long value) { first.addCheckpointFallbackNanos(value); second.addCheckpointFallbackNanos(value); } + @Override public void addSnapshotCommitNanos(long value) { first.addSnapshotCommitNanos(value); second.addSnapshotCommitNanos(value); } + @Override public void addPostProcessingNanos(long value) { first.addPostProcessingNanos(value); second.addPostProcessingNanos(value); } + @Override public void addPatchBoundaryNanos(long value) { first.addPatchBoundaryNanos(value); second.addPatchBoundaryNanos(value); } + @Override public void addPatchGasNanos(long value) { first.addPatchGasNanos(value); second.addPatchGasNanos(value); } + @Override public void addDocumentUpdateRoutingNanos(long value) { first.addDocumentUpdateRoutingNanos(value); second.addDocumentUpdateRoutingNanos(value); } + @Override public void incrementDocumentUpdateEventsBuilt() { first.incrementDocumentUpdateEventsBuilt(); second.incrementDocumentUpdateEventsBuilt(); } + @Override public void incrementDocumentUpdateEventsSkippedNoChannel() { first.incrementDocumentUpdateEventsSkippedNoChannel(); second.incrementDocumentUpdateEventsSkippedNoChannel(); } + @Override public void addBatchPatchPlanningNanos(long value) { first.addBatchPatchPlanningNanos(value); second.addBatchPatchPlanningNanos(value); } + @Override public void addBatchPatchConformanceNanos(long value) { first.addBatchPatchConformanceNanos(value); second.addBatchPatchConformanceNanos(value); } + @Override public void addBatchPatchBuildUpdatesNanos(long value) { first.addBatchPatchBuildUpdatesNanos(value); second.addBatchPatchBuildUpdatesNanos(value); } + @Override public void addBatchPatchCommitNanos(long value) { first.addBatchPatchCommitNanos(value); second.addBatchPatchCommitNanos(value); } + @Override public void incrementDocumentUpdateBeforeMaterializations() { first.incrementDocumentUpdateBeforeMaterializations(); second.incrementDocumentUpdateBeforeMaterializations(); } + @Override public void incrementDocumentUpdateAfterMaterializations() { first.incrementDocumentUpdateAfterMaterializations(); second.incrementDocumentUpdateAfterMaterializations(); } + @Override public void incrementPatchSequencesPrepared() { first.incrementPatchSequencesPrepared(); second.incrementPatchSequencesPrepared(); } + @Override public void addPatchesPrepared(long value) { first.addPatchesPrepared(value); second.addPatchesPrepared(value); } + @Override public void incrementSingletonPatchTransactions() { first.incrementSingletonPatchTransactions(); second.incrementSingletonPatchTransactions(); } + @Override public void addSequencePlanningNanos(long value) { first.addSequencePlanningNanos(value); second.addSequencePlanningNanos(value); } + @Override public void addSequenceConformanceNanos(long value) { first.addSequenceConformanceNanos(value); second.addSequenceConformanceNanos(value); } + @Override public void addSequenceCommitNanos(long value) { first.addSequenceCommitNanos(value); second.addSequenceCommitNanos(value); } + @Override public void addSequenceFinalCacheCommitNanos(long value) { first.addSequenceFinalCacheCommitNanos(value); second.addSequenceFinalCacheCommitNanos(value); } + @Override public void incrementSequenceIntermediateSnapshotAdvances() { first.incrementSequenceIntermediateSnapshotAdvances(); second.incrementSequenceIntermediateSnapshotAdvances(); } + @Override public void incrementSequenceSharedSnapshotCacheInserts() { first.incrementSequenceSharedSnapshotCacheInserts(); second.incrementSequenceSharedSnapshotCacheInserts(); } + @Override public void incrementSequenceFinalSnapshotCacheInserts() { first.incrementSequenceFinalSnapshotCacheInserts(); second.incrementSequenceFinalSnapshotCacheInserts(); } + @Override public void incrementSequenceSuffixRebases() { first.incrementSequenceSuffixRebases(); second.incrementSequenceSuffixRebases(); } + @Override public void incrementSequenceStalePreviewFallbacks() { first.incrementSequenceStalePreviewFallbacks(); second.incrementSequenceStalePreviewFallbacks(); } + @Override public void incrementSequenceFallbackPatches() { first.incrementSequenceFallbackPatches(); second.incrementSequenceFallbackPatches(); } + @Override public void incrementParsedPointerCacheHits() { first.incrementParsedPointerCacheHits(); second.incrementParsedPointerCacheHits(); } + @Override public void incrementParsedPointerCacheMisses() { first.incrementParsedPointerCacheMisses(); second.incrementParsedPointerCacheMisses(); } + @Override public void incrementFrozenPatchValueHits() { first.incrementFrozenPatchValueHits(); second.incrementFrozenPatchValueHits(); } + @Override public void incrementPatchValueMaterializations() { first.incrementPatchValueMaterializations(); second.incrementPatchValueMaterializations(); } + @Override public void incrementFrozenNodesCreated() { first.incrementFrozenNodesCreated(); second.incrementFrozenNodesCreated(); } + @Override public void incrementFrozenNodesReused() { first.incrementFrozenNodesReused(); second.incrementFrozenNodesReused(); } + @Override public void incrementCanonicalIdentityCalculations() { first.incrementCanonicalIdentityCalculations(); second.incrementCanonicalIdentityCalculations(); } + @Override public void incrementResolvedIdentityCalculations() { first.incrementResolvedIdentityCalculations(); second.incrementResolvedIdentityCalculations(); } + @Override public void addCanonicalBytesWritten(long value) { first.addCanonicalBytesWritten(value); second.addCanonicalBytesWritten(value); } + @Override public void incrementJcsFallbacks() { first.incrementJcsFallbacks(); second.incrementJcsFallbacks(); } + @Override public void addBase58EncodeNanos(long value) { first.addBase58EncodeNanos(value); second.addBase58EncodeNanos(value); } + @Override public void addBase58DecodeNanos(long value) { first.addBase58DecodeNanos(value); second.addBase58DecodeNanos(value); } + @Override public void addBlueIdDigestNanos(long value) { first.addBlueIdDigestNanos(value); second.addBlueIdDigestNanos(value); } + @Override public void incrementResolvedStructuralKeyBuilds() { first.incrementResolvedStructuralKeyBuilds(); second.incrementResolvedStructuralKeyBuilds(); } } } diff --git a/src/main/java/blue/coordination/processor/bex/BexProcessingMetrics.java b/src/main/java/blue/coordination/processor/bex/BexProcessingMetrics.java index c3e4f8b..13e0e6a 100644 --- a/src/main/java/blue/coordination/processor/bex/BexProcessingMetrics.java +++ b/src/main/java/blue/coordination/processor/bex/BexProcessingMetrics.java @@ -3,11 +3,46 @@ import blue.bex.result.BexMetrics; import blue.language.processor.ProcessingMetricsSink; +import java.util.Collections; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicLong; public final class BexProcessingMetrics implements ProcessingMetricsSink { + /** + * Language currently emits a fixed vocabulary, but keep the adapter safe if a future + * integration accidentally supplies data-derived names. + */ + public static final int MAX_LANGUAGE_METRIC_NAMES = 256; + + private final Object languageMetricNameLock = new Object(); + private final ConcurrentMap languageMetricNames = + new ConcurrentHashMap<>(); + private final ConcurrentMap languageCounters = + new ConcurrentHashMap<>(); + private final ConcurrentMap languageGauges = + new ConcurrentHashMap<>(); + private final ConcurrentMap languageHighWaterMarks = + new ConcurrentHashMap<>(); + private final AtomicLong droppedLanguageMetricNames = new AtomicLong(); + private final AtomicLong workflowStepsExecuted = new AtomicLong(); + private final AtomicLong workflowPlansBuilt = new AtomicLong(); + private final AtomicLong workflowPlanCacheHits = new AtomicLong(); + private final AtomicLong workflowPlanCacheMisses = new AtomicLong(); + private final AtomicLong workflowPlanCacheEvictions = new AtomicLong(); + private final AtomicLong workflowPlanWeightBytes = new AtomicLong(); + private final AtomicLong workflowExecutorLookups = new AtomicLong(); + private final AtomicLong workflowStepResultSnapshotsCreated = new AtomicLong(); + private final AtomicLong workflowStepResultViewHits = new AtomicLong(); private final AtomicLong computeStepsExecuted = new AtomicLong(); + private final AtomicLong computePlansBuilt = new AtomicLong(); + private final AtomicLong computePlanCacheHits = new AtomicLong(); + private final AtomicLong computePlanCacheMisses = new AtomicLong(); + private final AtomicLong computePlanCacheEvictions = new AtomicLong(); + private final AtomicLong computePlanWeightBytes = new AtomicLong(); private final AtomicLong updateDocumentStepsExecuted = new AtomicLong(); private final AtomicLong triggerEventStepsExecuted = new AtomicLong(); private final AtomicLong directBexChangesetHits = new AtomicLong(); @@ -19,6 +54,9 @@ public final class BexProcessingMetrics implements ProcessingMetricsSink { private final AtomicLong computeResultValidationFailures = new AtomicLong(); private final AtomicLong computeProgramNormalizations = new AtomicLong(); private final AtomicLong computeDefinitionNormalizations = new AtomicLong(); + private final AtomicLong computeDefinitionMaterializations = new AtomicLong(); + private final AtomicLong computeDefinitionFrozenDirectHits = new AtomicLong(); + private final AtomicLong computeProgramSourceBuilds = new AtomicLong(); private final AtomicLong computeDefinitionResolveHits = new AtomicLong(); private final AtomicLong computeDefinitionResolveMisses = new AtomicLong(); private final AtomicLong workflowRunnerNanos = new AtomicLong(); @@ -33,6 +71,9 @@ public final class BexProcessingMetrics implements ProcessingMetricsSink { private final AtomicLong updatePatchApplyNanos = new AtomicLong(); private final AtomicLong updateBatchPatchApplications = new AtomicLong(); private final AtomicLong updateIndividualPatchApplications = new AtomicLong(); + private final AtomicLong updateStaticTemplatesBuilt = new AtomicLong(); + private final AtomicLong updateStaticTemplateHits = new AtomicLong(); + private final AtomicLong updateReflectionFallbacks = new AtomicLong(); private final AtomicLong triggerStepNanos = new AtomicLong(); private final AtomicLong triggerEmitEventNanos = new AtomicLong(); private final AtomicLong bexCompileNanos = new AtomicLong(); @@ -42,6 +83,8 @@ public final class BexProcessingMetrics implements ProcessingMetricsSink { private final AtomicLong bexCompiledExecutions = new AtomicLong(); private final AtomicLong bexNodeWriterNanos = new AtomicLong(); private final AtomicLong directBexPatchEntryConversions = new AtomicLong(); + private final AtomicLong bexPatchFrozenDirectConversions = new AtomicLong(); + private final AtomicLong bexPatchNodeMaterializations = new AtomicLong(); private final AtomicLong processDocumentNanos = new AtomicLong(); private final AtomicLong blueProcessDocumentNanos = new AtomicLong(); private final AtomicLong eventPreprocessNanos = new AtomicLong(); @@ -107,6 +150,23 @@ public final class BexProcessingMetrics implements ProcessingMetricsSink { private final AtomicLong batchPatchCommitNanos = new AtomicLong(); private final AtomicLong documentUpdateBeforeMaterializations = new AtomicLong(); private final AtomicLong documentUpdateAfterMaterializations = new AtomicLong(); + private final AtomicLong patchSequencesPrepared = new AtomicLong(); + private final AtomicLong patchesPrepared = new AtomicLong(); + private final AtomicLong singletonPatchTransactions = new AtomicLong(); + private final AtomicLong sequencePlanningNanos = new AtomicLong(); + private final AtomicLong sequenceConformanceNanos = new AtomicLong(); + private final AtomicLong sequenceCommitNanos = new AtomicLong(); + private final AtomicLong sequenceFinalCacheCommitNanos = new AtomicLong(); + private final AtomicLong sequenceIntermediateSnapshotAdvances = new AtomicLong(); + private final AtomicLong sequenceSharedSnapshotCacheInserts = new AtomicLong(); + private final AtomicLong sequenceFinalSnapshotCacheInserts = new AtomicLong(); + private final AtomicLong sequenceSuffixRebases = new AtomicLong(); + private final AtomicLong sequenceStalePreviewFallbacks = new AtomicLong(); + private final AtomicLong sequenceFallbackPatches = new AtomicLong(); + private final AtomicLong parsedPointerCacheHits = new AtomicLong(); + private final AtomicLong parsedPointerCacheMisses = new AtomicLong(); + private final AtomicLong frozenPatchValueHits = new AtomicLong(); + private final AtomicLong patchValueMaterializations = new AtomicLong(); private final AtomicLong workflowDocumentViewsFromFrozen = new AtomicLong(); private final AtomicLong workflowDocumentViewsFromDocument = new AtomicLong(); private final AtomicLong workflowDocumentViewMisses = new AtomicLong(); @@ -119,10 +179,62 @@ public void incrementWorkflowStepsExecuted() { workflowStepsExecuted.incrementAndGet(); } + public void incrementWorkflowPlansBuilt() { + workflowPlansBuilt.incrementAndGet(); + } + + public void incrementWorkflowPlanCacheHits() { + workflowPlanCacheHits.incrementAndGet(); + } + + public void incrementWorkflowPlanCacheMisses() { + workflowPlanCacheMisses.incrementAndGet(); + } + + public void incrementWorkflowPlanCacheEvictions() { + workflowPlanCacheEvictions.incrementAndGet(); + } + + public void addWorkflowPlanWeightBytes(long delta) { + addToNonNegativeGauge(workflowPlanWeightBytes, delta); + } + + public void incrementWorkflowExecutorLookups() { + workflowExecutorLookups.incrementAndGet(); + } + + public void incrementWorkflowStepResultSnapshotsCreated() { + workflowStepResultSnapshotsCreated.incrementAndGet(); + } + + public void incrementWorkflowStepResultViewHits() { + workflowStepResultViewHits.incrementAndGet(); + } + public void incrementComputeStepsExecuted() { computeStepsExecuted.incrementAndGet(); } + public void incrementComputePlansBuilt() { + computePlansBuilt.incrementAndGet(); + } + + public void incrementComputePlanCacheHits() { + computePlanCacheHits.incrementAndGet(); + } + + public void incrementComputePlanCacheMisses() { + computePlanCacheMisses.incrementAndGet(); + } + + public void incrementComputePlanCacheEvictions() { + computePlanCacheEvictions.incrementAndGet(); + } + + public void addComputePlanWeightBytes(long delta) { + addToNonNegativeGauge(computePlanWeightBytes, delta); + } + public void incrementUpdateDocumentStepsExecuted() { updateDocumentStepsExecuted.incrementAndGet(); } @@ -167,6 +279,18 @@ public void incrementComputeDefinitionNormalizations() { computeDefinitionNormalizations.incrementAndGet(); } + public void incrementComputeDefinitionMaterializations() { + computeDefinitionMaterializations.incrementAndGet(); + } + + public void incrementComputeDefinitionFrozenDirectHits() { + computeDefinitionFrozenDirectHits.incrementAndGet(); + } + + public void incrementComputeProgramSourceBuilds() { + computeProgramSourceBuilds.incrementAndGet(); + } + public void incrementComputeDefinitionResolveHits() { computeDefinitionResolveHits.incrementAndGet(); } @@ -223,6 +347,18 @@ public void incrementUpdateIndividualPatchApplications() { updateIndividualPatchApplications.incrementAndGet(); } + public void incrementUpdateStaticTemplatesBuilt() { + updateStaticTemplatesBuilt.incrementAndGet(); + } + + public void incrementUpdateStaticTemplateHits() { + updateStaticTemplateHits.incrementAndGet(); + } + + public void incrementUpdateReflectionFallbacks() { + updateReflectionFallbacks.incrementAndGet(); + } + public void addTriggerStepNanos(long nanos) { triggerStepNanos.addAndGet(nonNegative(nanos)); } @@ -239,6 +375,14 @@ public void incrementDirectBexPatchEntryConversions() { directBexPatchEntryConversions.incrementAndGet(); } + public void incrementBexPatchFrozenDirectConversions() { + bexPatchFrozenDirectConversions.incrementAndGet(); + } + + public void incrementBexPatchNodeMaterializations() { + bexPatchNodeMaterializations.incrementAndGet(); + } + public void addBexMetrics(BexMetrics metrics) { if (metrics == null) { return; @@ -575,6 +719,91 @@ public void incrementDocumentUpdateAfterMaterializations() { documentUpdateAfterMaterializations.incrementAndGet(); } + @Override + public void incrementPatchSequencesPrepared() { + patchSequencesPrepared.incrementAndGet(); + } + + @Override + public void addPatchesPrepared(long count) { + patchesPrepared.addAndGet(count); + } + + @Override + public void incrementSingletonPatchTransactions() { + singletonPatchTransactions.incrementAndGet(); + } + + @Override + public void addSequencePlanningNanos(long nanos) { + sequencePlanningNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addSequenceConformanceNanos(long nanos) { + sequenceConformanceNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addSequenceCommitNanos(long nanos) { + sequenceCommitNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void addSequenceFinalCacheCommitNanos(long nanos) { + sequenceFinalCacheCommitNanos.addAndGet(nonNegative(nanos)); + } + + @Override + public void incrementSequenceIntermediateSnapshotAdvances() { + sequenceIntermediateSnapshotAdvances.incrementAndGet(); + } + + @Override + public void incrementSequenceSharedSnapshotCacheInserts() { + sequenceSharedSnapshotCacheInserts.incrementAndGet(); + } + + @Override + public void incrementSequenceFinalSnapshotCacheInserts() { + sequenceFinalSnapshotCacheInserts.incrementAndGet(); + } + + @Override + public void incrementSequenceSuffixRebases() { + sequenceSuffixRebases.incrementAndGet(); + } + + @Override + public void incrementSequenceStalePreviewFallbacks() { + sequenceStalePreviewFallbacks.incrementAndGet(); + } + + @Override + public void incrementSequenceFallbackPatches() { + sequenceFallbackPatches.incrementAndGet(); + } + + @Override + public void incrementParsedPointerCacheHits() { + parsedPointerCacheHits.incrementAndGet(); + } + + @Override + public void incrementParsedPointerCacheMisses() { + parsedPointerCacheMisses.incrementAndGet(); + } + + @Override + public void incrementFrozenPatchValueHits() { + frozenPatchValueHits.incrementAndGet(); + } + + @Override + public void incrementPatchValueMaterializations() { + patchValueMaterializations.incrementAndGet(); + } + public void incrementWorkflowDocumentViewsFromFrozen() { workflowDocumentViewsFromFrozen.incrementAndGet(); } @@ -607,10 +836,62 @@ public long workflowStepsExecuted() { return workflowStepsExecuted.get(); } + public long workflowPlansBuilt() { + return workflowPlansBuilt.get(); + } + + public long workflowPlanCacheHits() { + return workflowPlanCacheHits.get(); + } + + public long workflowPlanCacheMisses() { + return workflowPlanCacheMisses.get(); + } + + public long workflowPlanCacheEvictions() { + return workflowPlanCacheEvictions.get(); + } + + public long workflowPlanWeightBytes() { + return workflowPlanWeightBytes.get(); + } + + public long workflowExecutorLookups() { + return workflowExecutorLookups.get(); + } + + public long workflowStepResultSnapshotsCreated() { + return workflowStepResultSnapshotsCreated.get(); + } + + public long workflowStepResultViewHits() { + return workflowStepResultViewHits.get(); + } + public long computeStepsExecuted() { return computeStepsExecuted.get(); } + public long computePlansBuilt() { + return computePlansBuilt.get(); + } + + public long computePlanCacheHits() { + return computePlanCacheHits.get(); + } + + public long computePlanCacheMisses() { + return computePlanCacheMisses.get(); + } + + public long computePlanCacheEvictions() { + return computePlanCacheEvictions.get(); + } + + public long computePlanWeightBytes() { + return computePlanWeightBytes.get(); + } + public long updateDocumentStepsExecuted() { return updateDocumentStepsExecuted.get(); } @@ -655,6 +936,18 @@ public long computeDefinitionNormalizations() { return computeDefinitionNormalizations.get(); } + public long computeDefinitionMaterializations() { + return computeDefinitionMaterializations.get(); + } + + public long computeDefinitionFrozenDirectHits() { + return computeDefinitionFrozenDirectHits.get(); + } + + public long computeProgramSourceBuilds() { + return computeProgramSourceBuilds.get(); + } + public long computeDefinitionResolveHits() { return computeDefinitionResolveHits.get(); } @@ -711,6 +1004,18 @@ public long updateIndividualPatchApplications() { return updateIndividualPatchApplications.get(); } + public long updateStaticTemplatesBuilt() { + return updateStaticTemplatesBuilt.get(); + } + + public long updateStaticTemplateHits() { + return updateStaticTemplateHits.get(); + } + + public long updateReflectionFallbacks() { + return updateReflectionFallbacks.get(); + } + public long triggerStepNanos() { return triggerStepNanos.get(); } @@ -747,6 +1052,14 @@ public long directBexPatchEntryConversions() { return directBexPatchEntryConversions.get(); } + public long bexPatchFrozenDirectConversions() { + return bexPatchFrozenDirectConversions.get(); + } + + public long bexPatchNodeMaterializations() { + return bexPatchNodeMaterializations.get(); + } + public long processDocumentNanos() { return processDocumentNanos.get(); } @@ -1007,6 +1320,96 @@ public long documentUpdateAfterMaterializations() { return documentUpdateAfterMaterializations.get(); } + /** + * Number of reusable Language patch-sequence planning sessions. This is the raw value from + * {@link #incrementPatchSequencesPrepared()}. + */ + public long preparedPatchSequences() { + return patchSequencesPrepared.get(); + } + + /** Number of patches accepted by Language sequence sessions. */ + public long preparedPatches() { + return patchesPrepared.get(); + } + + /** + * Closest public proxy for a Language sequence transaction. + * + *

The callback identifies a reusable sequential planning session, + * not the Language runtime's internal transaction counter, so reports must + * retain this qualification.

+ */ + public long languageSequenceTransactions() { + return patchSequencesPrepared.get(); + } + + /** Number of legacy standalone one-patch Language transactions. */ + public long languageSingletonTransactions() { + return singletonPatchTransactions.get(); + } + + public long sequencePlanningNanos() { + return sequencePlanningNanos.get(); + } + + public long sequenceConformanceNanos() { + return sequenceConformanceNanos.get(); + } + + public long sequenceCommitNanos() { + return sequenceCommitNanos.get(); + } + + public long sequenceFinalCacheCommitNanos() { + return sequenceFinalCacheCommitNanos.get(); + } + + public long languageIntermediateSnapshotAdvances() { + return sequenceIntermediateSnapshotAdvances.get(); + } + + public long sequenceSharedSnapshotCacheInserts() { + return sequenceSharedSnapshotCacheInserts.get(); + } + + /** Maps to Language's final sequence snapshot-cache insertion callback. */ + public long languageFinalSnapshotPromotions() { + return sequenceFinalSnapshotCacheInserts.get(); + } + + public long sequenceFinalSnapshotCacheInserts() { + return sequenceFinalSnapshotCacheInserts.get(); + } + + public long languageSuffixRebases() { + return sequenceSuffixRebases.get(); + } + + public long sequenceStalePreviewFallbacks() { + return sequenceStalePreviewFallbacks.get(); + } + + public long languageFallbackPatches() { + return sequenceFallbackPatches.get(); + } + + public long parsedPointerCacheHits() { + return parsedPointerCacheHits.get(); + } + + public long parsedPointerCacheMisses() { + return parsedPointerCacheMisses.get(); + } + + public long frozenPatchValueHits() { + return frozenPatchValueHits.get(); + } + + public long languagePatchValueMaterializations() { + return patchValueMaterializations.get(); + } + public long workflowDocumentViewsFromFrozen() { return workflowDocumentViewsFromFrozen.get(); } @@ -1035,17 +1438,137 @@ public long bexDocumentViewUndefinedHits() { return bexDocumentViewUndefinedHits.get(); } + @Override + public void addMetric(String metricName, long delta) { + AtomicLong counter = languageMetric(languageCounters, metricName); + if (counter != null) { + counter.addAndGet(delta); + } + } + + @Override + public void setMetric(String metricName, long value) { + AtomicLong gauge = languageMetric(languageGauges, metricName); + if (gauge != null) { + gauge.set(value); + } + } + + @Override + public void recordMetricHighWater(String metricName, long value) { + AtomicLong highWater = languageMetric(languageHighWaterMarks, metricName); + if (highWater == null) { + return; + } + long current = highWater.get(); + while (value > current && !highWater.compareAndSet(current, value)) { + current = highWater.get(); + } + } + + /** Immutable, name-sorted snapshot of Language's generic additive counters. */ + public Map languageCounters() { + return immutableSortedValues(languageCounters); + } + + /** Immutable, name-sorted snapshot of Language's generic current-value gauges. */ + public Map languageGauges() { + return immutableSortedValues(languageGauges); + } + + /** Immutable, name-sorted snapshot of Language's generic high-water gauges. */ + public Map languageHighWaterMarks() { + return immutableSortedValues(languageHighWaterMarks); + } + + /** Number of generic metric samples dropped because their new name exceeded the cap. */ + public long droppedLanguageMetricNames() { + return droppedLanguageMetricNames.get(); + } + public Snapshot snapshot() { return new Snapshot(this); } + private AtomicLong languageMetric(ConcurrentMap metrics, + String metricName) { + requireMetricName(metricName); + if (!acceptLanguageMetricName(metricName)) { + return null; + } + AtomicLong current = metrics.get(metricName); + if (current != null) { + return current; + } + AtomicLong created = new AtomicLong(); + AtomicLong raced = metrics.putIfAbsent(metricName, created); + return raced != null ? raced : created; + } + + private boolean acceptLanguageMetricName(String metricName) { + if (languageMetricNames.containsKey(metricName)) { + return true; + } + synchronized (languageMetricNameLock) { + if (languageMetricNames.containsKey(metricName)) { + return true; + } + if (languageMetricNames.size() >= MAX_LANGUAGE_METRIC_NAMES) { + droppedLanguageMetricNames.incrementAndGet(); + return false; + } + languageMetricNames.put(metricName, Boolean.TRUE); + return true; + } + } + + private static void requireMetricName(String metricName) { + if (metricName == null || metricName.isEmpty()) { + throw new IllegalArgumentException("metricName must not be empty"); + } + } + + private static Map immutableSortedValues( + ConcurrentMap source) { + Map values = new TreeMap<>(); + for (Map.Entry entry : source.entrySet()) { + values.put(entry.getKey(), entry.getValue().get()); + } + return Collections.unmodifiableMap(values); + } + + private static void addToNonNegativeGauge(final AtomicLong gauge, final long delta) { + gauge.updateAndGet(current -> { + if (delta >= 0L) { + return current > Long.MAX_VALUE - delta ? Long.MAX_VALUE : current + delta; + } + if (delta == Long.MIN_VALUE || current < -delta) { + return 0L; + } + return current + delta; + }); + } + private static long nonNegative(long nanos) { return nanos > 0L ? nanos : 0L; } public static final class Snapshot { public final long workflowStepsExecuted; + public final long workflowPlansBuilt; + public final long workflowPlanCacheHits; + public final long workflowPlanCacheMisses; + public final long workflowPlanCacheEvictions; + public final long workflowPlanWeightBytes; + public final long workflowExecutorLookups; + public final long workflowStepResultSnapshotsCreated; + public final long workflowStepResultViewHits; public final long computeStepsExecuted; + public final long computePlansBuilt; + public final long computePlanCacheHits; + public final long computePlanCacheMisses; + public final long computePlanCacheEvictions; + public final long computePlanWeightBytes; public final long updateDocumentStepsExecuted; public final long triggerEventStepsExecuted; public final long directBexChangesetHits; @@ -1057,6 +1580,9 @@ public static final class Snapshot { public final long computeResultValidationFailures; public final long computeProgramNormalizations; public final long computeDefinitionNormalizations; + public final long computeDefinitionMaterializations; + public final long computeDefinitionFrozenDirectHits; + public final long computeProgramSourceBuilds; public final long computeDefinitionResolveHits; public final long computeDefinitionResolveMisses; public final long workflowRunnerNanos; @@ -1071,6 +1597,9 @@ public static final class Snapshot { public final long updatePatchApplyNanos; public final long updateBatchPatchApplications; public final long updateIndividualPatchApplications; + public final long updateStaticTemplatesBuilt; + public final long updateStaticTemplateHits; + public final long updateReflectionFallbacks; public final long triggerStepNanos; public final long triggerEmitEventNanos; public final long bexCompileNanos; @@ -1080,6 +1609,8 @@ public static final class Snapshot { public final long bexCompiledExecutions; public final long bexNodeWriterNanos; public final long directBexPatchEntryConversions; + public final long bexPatchFrozenDirectConversions; + public final long bexPatchNodeMaterializations; public final long processDocumentNanos; public final long blueProcessDocumentNanos; public final long eventPreprocessNanos; @@ -1145,6 +1676,25 @@ public static final class Snapshot { public final long batchPatchCommitNanos; public final long documentUpdateBeforeMaterializations; public final long documentUpdateAfterMaterializations; + public final long preparedPatchSequences; + public final long preparedPatches; + public final long languageSequenceTransactions; + public final long languageSingletonTransactions; + public final long sequencePlanningNanos; + public final long sequenceConformanceNanos; + public final long sequenceCommitNanos; + public final long sequenceFinalCacheCommitNanos; + public final long languageIntermediateSnapshotAdvances; + public final long sequenceSharedSnapshotCacheInserts; + public final long languageFinalSnapshotPromotions; + public final long sequenceFinalSnapshotCacheInserts; + public final long languageSuffixRebases; + public final long sequenceStalePreviewFallbacks; + public final long languageFallbackPatches; + public final long parsedPointerCacheHits; + public final long parsedPointerCacheMisses; + public final long frozenPatchValueHits; + public final long languagePatchValueMaterializations; public final long workflowDocumentViewsFromFrozen; public final long workflowDocumentViewsFromDocument; public final long workflowDocumentViewMisses; @@ -1152,10 +1702,27 @@ public static final class Snapshot { public final long bexDocumentViewFrozenDirectHits; public final long bexDocumentViewFrozenRootFallbackHits; public final long bexDocumentViewUndefinedHits; + public final Map languageCounters; + public final Map languageGauges; + public final Map languageHighWaterMarks; + public final long droppedLanguageMetricNames; private Snapshot(BexProcessingMetrics metrics) { this.workflowStepsExecuted = metrics.workflowStepsExecuted(); + this.workflowPlansBuilt = metrics.workflowPlansBuilt(); + this.workflowPlanCacheHits = metrics.workflowPlanCacheHits(); + this.workflowPlanCacheMisses = metrics.workflowPlanCacheMisses(); + this.workflowPlanCacheEvictions = metrics.workflowPlanCacheEvictions(); + this.workflowPlanWeightBytes = metrics.workflowPlanWeightBytes(); + this.workflowExecutorLookups = metrics.workflowExecutorLookups(); + this.workflowStepResultSnapshotsCreated = metrics.workflowStepResultSnapshotsCreated(); + this.workflowStepResultViewHits = metrics.workflowStepResultViewHits(); this.computeStepsExecuted = metrics.computeStepsExecuted(); + this.computePlansBuilt = metrics.computePlansBuilt(); + this.computePlanCacheHits = metrics.computePlanCacheHits(); + this.computePlanCacheMisses = metrics.computePlanCacheMisses(); + this.computePlanCacheEvictions = metrics.computePlanCacheEvictions(); + this.computePlanWeightBytes = metrics.computePlanWeightBytes(); this.updateDocumentStepsExecuted = metrics.updateDocumentStepsExecuted(); this.triggerEventStepsExecuted = metrics.triggerEventStepsExecuted(); this.directBexChangesetHits = metrics.directBexChangesetHits(); @@ -1167,6 +1734,9 @@ private Snapshot(BexProcessingMetrics metrics) { this.computeResultValidationFailures = metrics.computeResultValidationFailures(); this.computeProgramNormalizations = metrics.computeProgramNormalizations(); this.computeDefinitionNormalizations = metrics.computeDefinitionNormalizations(); + this.computeDefinitionMaterializations = metrics.computeDefinitionMaterializations(); + this.computeDefinitionFrozenDirectHits = metrics.computeDefinitionFrozenDirectHits(); + this.computeProgramSourceBuilds = metrics.computeProgramSourceBuilds(); this.computeDefinitionResolveHits = metrics.computeDefinitionResolveHits(); this.computeDefinitionResolveMisses = metrics.computeDefinitionResolveMisses(); this.workflowRunnerNanos = metrics.workflowRunnerNanos(); @@ -1181,6 +1751,9 @@ private Snapshot(BexProcessingMetrics metrics) { this.updatePatchApplyNanos = metrics.updatePatchApplyNanos(); this.updateBatchPatchApplications = metrics.updateBatchPatchApplications(); this.updateIndividualPatchApplications = metrics.updateIndividualPatchApplications(); + this.updateStaticTemplatesBuilt = metrics.updateStaticTemplatesBuilt(); + this.updateStaticTemplateHits = metrics.updateStaticTemplateHits(); + this.updateReflectionFallbacks = metrics.updateReflectionFallbacks(); this.triggerStepNanos = metrics.triggerStepNanos(); this.triggerEmitEventNanos = metrics.triggerEmitEventNanos(); this.bexCompileNanos = metrics.bexCompileNanos(); @@ -1190,6 +1763,8 @@ private Snapshot(BexProcessingMetrics metrics) { this.bexCompiledExecutions = metrics.bexCompiledExecutions(); this.bexNodeWriterNanos = metrics.bexNodeWriterNanos(); this.directBexPatchEntryConversions = metrics.directBexPatchEntryConversions(); + this.bexPatchFrozenDirectConversions = metrics.bexPatchFrozenDirectConversions(); + this.bexPatchNodeMaterializations = metrics.bexPatchNodeMaterializations(); this.processDocumentNanos = metrics.processDocumentNanos(); this.blueProcessDocumentNanos = metrics.blueProcessDocumentNanos(); this.eventPreprocessNanos = metrics.eventPreprocessNanos(); @@ -1255,6 +1830,25 @@ private Snapshot(BexProcessingMetrics metrics) { this.batchPatchCommitNanos = metrics.batchPatchCommitNanos(); this.documentUpdateBeforeMaterializations = metrics.documentUpdateBeforeMaterializations(); this.documentUpdateAfterMaterializations = metrics.documentUpdateAfterMaterializations(); + this.preparedPatchSequences = metrics.preparedPatchSequences(); + this.preparedPatches = metrics.preparedPatches(); + this.languageSequenceTransactions = metrics.languageSequenceTransactions(); + this.languageSingletonTransactions = metrics.languageSingletonTransactions(); + this.sequencePlanningNanos = metrics.sequencePlanningNanos(); + this.sequenceConformanceNanos = metrics.sequenceConformanceNanos(); + this.sequenceCommitNanos = metrics.sequenceCommitNanos(); + this.sequenceFinalCacheCommitNanos = metrics.sequenceFinalCacheCommitNanos(); + this.languageIntermediateSnapshotAdvances = metrics.languageIntermediateSnapshotAdvances(); + this.sequenceSharedSnapshotCacheInserts = metrics.sequenceSharedSnapshotCacheInserts(); + this.languageFinalSnapshotPromotions = metrics.languageFinalSnapshotPromotions(); + this.sequenceFinalSnapshotCacheInserts = metrics.sequenceFinalSnapshotCacheInserts(); + this.languageSuffixRebases = metrics.languageSuffixRebases(); + this.sequenceStalePreviewFallbacks = metrics.sequenceStalePreviewFallbacks(); + this.languageFallbackPatches = metrics.languageFallbackPatches(); + this.parsedPointerCacheHits = metrics.parsedPointerCacheHits(); + this.parsedPointerCacheMisses = metrics.parsedPointerCacheMisses(); + this.frozenPatchValueHits = metrics.frozenPatchValueHits(); + this.languagePatchValueMaterializations = metrics.languagePatchValueMaterializations(); this.workflowDocumentViewsFromFrozen = metrics.workflowDocumentViewsFromFrozen(); this.workflowDocumentViewsFromDocument = metrics.workflowDocumentViewsFromDocument(); this.workflowDocumentViewMisses = metrics.workflowDocumentViewMisses(); @@ -1262,6 +1856,10 @@ private Snapshot(BexProcessingMetrics metrics) { this.bexDocumentViewFrozenDirectHits = metrics.bexDocumentViewFrozenDirectHits(); this.bexDocumentViewFrozenRootFallbackHits = metrics.bexDocumentViewFrozenRootFallbackHits(); this.bexDocumentViewUndefinedHits = metrics.bexDocumentViewUndefinedHits(); + this.languageCounters = metrics.languageCounters(); + this.languageGauges = metrics.languageGauges(); + this.languageHighWaterMarks = metrics.languageHighWaterMarks(); + this.droppedLanguageMetricNames = metrics.droppedLanguageMetricNames(); } } } diff --git a/src/main/java/blue/coordination/processor/workflow/ComputeDefinitionResolver.java b/src/main/java/blue/coordination/processor/workflow/ComputeDefinitionResolver.java index cc42134..3f4eeed 100644 --- a/src/main/java/blue/coordination/processor/workflow/ComputeDefinitionResolver.java +++ b/src/main/java/blue/coordination/processor/workflow/ComputeDefinitionResolver.java @@ -3,12 +3,9 @@ import blue.coordination.processor.bex.BexProcessingMetrics; import blue.language.model.Node; import blue.language.snapshot.FrozenNode; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; final class ComputeDefinitionResolver { private final BexProcessingMetrics metrics; - private final ConcurrentMap cache = new ConcurrentHashMap(); ComputeDefinitionResolver() { this(null); @@ -19,6 +16,12 @@ final class ComputeDefinitionResolver { } FrozenNode resolve(FrozenNode stepNode, StepExecutionContext context) { + return resolve(stepNode, context, metrics); + } + + FrozenNode resolve(FrozenNode stepNode, + StepExecutionContext context, + BexProcessingMetrics invocationMetrics) { FrozenNode definition = FrozenNodeUtil.property(stepNode, "definition"); if (definition == null || FrozenNodeUtil.isEmpty(definition)) { return null; @@ -26,13 +29,19 @@ FrozenNode resolve(FrozenNode stepNode, StepExecutionContext context) { String text = FrozenNodeUtil.text(definition); if (text != null && !text.trim().isEmpty()) { String pointer = resolvePointer(text.trim(), context); - FrozenNode frozen = cachedFrozenAt(pointer, context); + // This lookup is deliberately performed against the current + // WorkingDocument on every invocation. The exact returned frozen + // identity participates in the Compute plan key, so a definition + // changed by an earlier step can never reuse a stale plan. + FrozenNode frozen = context.workingResolvedAt(pointer); if (frozen == null) { context.processorContext().throwFatal("Compute definition not found: " + text); return null; } + incrementFrozenDirectHit(invocationMetrics); return frozen; } + incrementFrozenDirectHit(invocationMetrics); return definition; } @@ -44,13 +53,17 @@ FrozenNode resolve(Node stepNode, StepExecutionContext context) { String text = NodeUtil.text(definition); if (text != null && !text.trim().isEmpty()) { String pointer = resolvePointer(text.trim(), context); - FrozenNode frozen = cachedFrozenAt(pointer, context); + FrozenNode frozen = context.workingResolvedAt(pointer); if (frozen == null) { context.processorContext().throwFatal("Compute definition not found: " + text); return null; } + incrementFrozenDirectHit(metrics); return frozen; } + if (metrics != null) { + metrics.incrementComputeDefinitionMaterializations(); + } return FrozenNode.fromResolvedNode(definition); } @@ -65,31 +78,13 @@ String resolvePointer(String reference, StepExecutionContext context) { return appendPointer(parent, reference); } - private FrozenNode cachedFrozenAt(String pointer, StepExecutionContext context) { - String key = cacheKey(pointer, context); - FrozenNode cached = cache.get(key); - if (cached != null) { - if (metrics != null) { - metrics.incrementComputeDefinitionResolveHits(); - } - return cached; + private void incrementFrozenDirectHit(BexProcessingMetrics invocationMetrics) { + BexProcessingMetrics activeMetrics = invocationMetrics != null + ? invocationMetrics + : metrics; + if (activeMetrics != null) { + activeMetrics.incrementComputeDefinitionFrozenDirectHits(); } - FrozenNode frozen = context.workingResolvedAt(pointer); - if (frozen != null) { - cache.putIfAbsent(key, frozen); - } - if (metrics != null) { - metrics.incrementComputeDefinitionResolveMisses(); - } - return frozen; - } - - private String cacheKey(String pointer, StepExecutionContext context) { - FrozenNode contract = context.currentContractFrozenNode(); - String contractId = contract != null && contract.blueId() != null - ? contract.blueId() - : String.valueOf(context.processorContext().scopePath()) + ":" + String.valueOf(context.processorContext().contractKey()); - return contractId + "|" + pointer; } private String currentContractPointer(StepExecutionContext context) { diff --git a/src/main/java/blue/coordination/processor/workflow/ComputeEffectPlan.java b/src/main/java/blue/coordination/processor/workflow/ComputeEffectPlan.java index c9fd1f3..758edd9 100644 --- a/src/main/java/blue/coordination/processor/workflow/ComputeEffectPlan.java +++ b/src/main/java/blue/coordination/processor/workflow/ComputeEffectPlan.java @@ -1,7 +1,7 @@ package blue.coordination.processor.workflow; import blue.language.model.Node; -import blue.language.processor.model.JsonPatch; +import blue.language.processor.model.FrozenJsonPatch; import blue.language.snapshot.FrozenNode; import java.util.ArrayList; @@ -16,21 +16,26 @@ * duplicate buffering when a caller retries delivery of the same plan.

*/ final class ComputeEffectPlan { - private final List patches; + private final List patches; private final List events; private final boolean terminationRequested; private final String terminationReason; private final boolean changesetHandled; private final AtomicBoolean bufferingClaimed = new AtomicBoolean(); - ComputeEffectPlan(List patches, + ComputeEffectPlan(List patches, List events, boolean terminationRequested, String terminationReason, boolean changesetHandled) { - List frozenPatches = new ArrayList(patches.size()); - for (JsonPatch patch : patches) { - frozenPatches.add(PlannedPatch.from(patch)); + List frozenPatches = new ArrayList(patches.size()); + for (FrozenJsonPatch patch : patches) { + if (patch == null) { + throw new IllegalArgumentException("Compute effect plan patch must not be null"); + } + // FrozenJsonPatch is immutable, so retaining the patch itself is safe. The + // list still needs a defensive copy because callers may reuse its storage. + frozenPatches.add(patch); } this.patches = Collections.unmodifiableList(frozenPatches); List frozenEvents = new ArrayList(events.size()); @@ -46,12 +51,8 @@ final class ComputeEffectPlan { this.changesetHandled = changesetHandled; } - List patches() { - List materialized = new ArrayList(patches.size()); - for (PlannedPatch patch : patches) { - materialized.add(patch.materialize()); - } - return Collections.unmodifiableList(materialized); + List patches() { + return patches; } List events() { @@ -76,35 +77,4 @@ void claimForBuffering() { } } - private static final class PlannedPatch { - private final JsonPatch.Op op; - private final String path; - private final FrozenNode value; - - private PlannedPatch(JsonPatch.Op op, String path, FrozenNode value) { - this.op = op; - this.path = path; - this.value = value; - } - - private static PlannedPatch from(JsonPatch patch) { - if (patch == null) { - throw new IllegalArgumentException("Compute effect plan patch must not be null"); - } - FrozenNode value = patch.getOp() == JsonPatch.Op.REMOVE - ? null - : FrozenNode.fromResolvedNode(patch.getVal()); - return new PlannedPatch(patch.getOp(), patch.getPath(), value); - } - - private JsonPatch materialize() { - if (op == JsonPatch.Op.ADD) { - return JsonPatch.add(path, value.toNode()); - } - if (op == JsonPatch.Op.REPLACE) { - return JsonPatch.replace(path, value.toNode()); - } - return JsonPatch.remove(path); - } - } } diff --git a/src/main/java/blue/coordination/processor/workflow/ComputeProgramNormalizer.java b/src/main/java/blue/coordination/processor/workflow/ComputeProgramNormalizer.java index c72847c..b8e208e 100644 --- a/src/main/java/blue/coordination/processor/workflow/ComputeProgramNormalizer.java +++ b/src/main/java/blue/coordination/processor/workflow/ComputeProgramNormalizer.java @@ -1,20 +1,65 @@ package blue.coordination.processor.workflow; import blue.coordination.processor.RepositoryTypeAliasPreprocessor; +import blue.coordination.processor.bex.BexProcessingMetrics; import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; import java.util.LinkedHashMap; import java.util.Map; final class ComputeProgramNormalizer { + private static final String NORMALIZATION_VERSION = + "compute-program-v2|repository-aliases-3.0.0-rc.10"; + private final RepositoryTypeAliasPreprocessor typeAliasPreprocessor; + private final BexProcessingMetrics metrics; ComputeProgramNormalizer() { - this(new RepositoryTypeAliasPreprocessor()); + this(new RepositoryTypeAliasPreprocessor(), null); + } + + ComputeProgramNormalizer(BexProcessingMetrics metrics) { + this(new RepositoryTypeAliasPreprocessor(), metrics); } ComputeProgramNormalizer(RepositoryTypeAliasPreprocessor typeAliasPreprocessor) { + this(typeAliasPreprocessor, null); + } + + private ComputeProgramNormalizer(RepositoryTypeAliasPreprocessor typeAliasPreprocessor, + BexProcessingMetrics metrics) { + if (typeAliasPreprocessor == null) { + throw new IllegalArgumentException("typeAliasPreprocessor must not be null"); + } this.typeAliasPreprocessor = typeAliasPreprocessor; + this.metrics = metrics; + } + + String normalizationVersion() { + return NORMALIZATION_VERSION; + } + + /** + * Normalizes only the authored Compute projection of a frozen step. This + * avoids materializing unrelated resolved-contract content. The selected + * subtrees still use the mutable alias preprocessor as a conservative, + * semantics-preserving cold-path fallback; the resulting frozen plan is + * reused on every warm invocation. + */ + FrozenNode program(FrozenNode stepNode) { + if (metrics != null) { + metrics.incrementComputeProgramNormalizations(); + } + return FrozenNode.fromResolvedNode(program(frozenProgramInput(stepNode))); + } + + FrozenNode definition(FrozenNode definitionNode) { + if (metrics != null) { + metrics.incrementComputeDefinitionNormalizations(); + metrics.incrementComputeDefinitionMaterializations(); + } + return FrozenNode.fromResolvedNode(definition(frozenDefinitionInput(definitionNode))); } Node program(Node stepNode) { @@ -48,6 +93,48 @@ Node definition(Node definitionNode) { return typeAliasPreprocessor.preprocess(definition); } + private Node frozenProgramInput(FrozenNode source) { + Node input = new Node(); + copyMetadata(input, source); + Map properties = new LinkedHashMap(); + copyFrozenProperty(properties, source, "expr"); + copyFrozenProperty(properties, source, "do"); + copyFrozenProperty(properties, source, "definition"); + copyFrozenProperty(properties, source, "entry"); + copyFrozenProperty(properties, source, "constants"); + copyFrozenProperty(properties, source, "functions"); + copyFrozenProperty(properties, source, "gasLimit"); + copyFrozenProperty(properties, source, "emitEvents"); + copyFrozenProperty(properties, source, "returnResult"); + if (!properties.isEmpty()) { + input.properties(properties); + } + return input; + } + + private Node frozenDefinitionInput(FrozenNode source) { + Node input = new Node(); + copyMetadata(input, source); + Map properties = new LinkedHashMap(); + copyFrozenProperty(properties, source, "constants"); + copyFrozenProperty(properties, source, "functions"); + if (!properties.isEmpty()) { + input.properties(properties); + } + return input; + } + + private void copyFrozenProperty(Map target, + FrozenNode source, + String key) { + FrozenNode value = source != null && source.getProperties() != null + ? source.getProperties().get(key) + : null; + if (value != null) { + target.put(key, value.toNode()); + } + } + private Node normalizeFunctions(Node functions) { if (functions == null || functions.getProperties() == null || functions.getProperties().isEmpty()) { return null; @@ -120,4 +207,13 @@ private void copyMetadata(Node target, Node source) { target.description(source.getDescription()); target.type(source.getType() != null ? source.getType().clone() : null); } + + private void copyMetadata(Node target, FrozenNode source) { + if (source == null) { + return; + } + target.name(source.getName()); + target.description(source.getDescription()); + target.type(source.getType() != null ? source.getType().toNode() : null); + } } diff --git a/src/main/java/blue/coordination/processor/workflow/ComputeProgramPlan.java b/src/main/java/blue/coordination/processor/workflow/ComputeProgramPlan.java new file mode 100644 index 0000000..5f01c08 --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/ComputeProgramPlan.java @@ -0,0 +1,200 @@ +package blue.coordination.processor.workflow; + +import blue.bex.api.BexProgramSource; +import blue.bex.compile.BexCompiledProgramKey; +import blue.language.snapshot.FrozenNode; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +/** + * Immutable, reusable input plan for one exact Compute program and definition. + * Compiled BEX artifacts deliberately remain owned by BEX's bounded cache. + */ +final class ComputeProgramPlan { + private static final long PLAN_OVERHEAD_BYTES = 256L; + private static final long NODE_OVERHEAD_BYTES = 96L; + private static final long COLLECTION_ENTRY_OVERHEAD_BYTES = 32L; + + private final FrozenNode programNode; + private final FrozenNode definitionNode; + private final BexProgramSource source; + private final BexCompiledProgramKey sourceIdentity; + private final String entry; + private final long gasLimit; + private final boolean emitEvents; + private final boolean returnResult; + private final long approximateWeightBytes; + + ComputeProgramPlan(FrozenNode programNode, + FrozenNode definitionNode, + BexProgramSource source, + String entry, + long gasLimit, + boolean emitEvents, + boolean returnResult, + FrozenNode rawStepNode, + FrozenNode rawDefinitionNode) { + if (programNode == null) { + throw new IllegalArgumentException("programNode must not be null"); + } + if (source == null) { + throw new IllegalArgumentException("source must not be null"); + } + if (gasLimit <= 0L) { + throw new IllegalArgumentException("gasLimit must be positive"); + } + this.programNode = programNode; + this.definitionNode = definitionNode; + this.source = source; + this.sourceIdentity = BexCompiledProgramKey.from(source); + this.entry = entry; + this.gasLimit = gasLimit; + this.emitEvents = emitEvents; + this.returnResult = returnResult; + this.approximateWeightBytes = approximateWeight(rawStepNode, + rawDefinitionNode, + programNode, + definitionNode, + entry, + sourceIdentity); + } + + FrozenNode programNode() { + return programNode; + } + + FrozenNode definitionNode() { + return definitionNode; + } + + BexProgramSource source() { + return source; + } + + BexCompiledProgramKey sourceIdentity() { + return sourceIdentity; + } + + String entry() { + return entry; + } + + long gasLimit() { + return gasLimit; + } + + boolean emitEvents() { + return emitEvents; + } + + boolean returnResult() { + return returnResult; + } + + long approximateWeightBytes() { + return approximateWeightBytes; + } + + private static long approximateWeight(FrozenNode rawStepNode, + FrozenNode rawDefinitionNode, + FrozenNode programNode, + FrozenNode definitionNode, + String entry, + BexCompiledProgramKey sourceIdentity) { + long weight = PLAN_OVERHEAD_BYTES; + // Raw nodes approximate the independently retained structural cache + // keys, while normalized nodes approximate the plan/source graph. + // Deduplicate sharing within each retained graph, not across them. + IdentityHashMap keyNodes = new IdentityHashMap(); + weight = saturatedAdd(weight, nodeWeight(rawStepNode, keyNodes)); + weight = saturatedAdd(weight, nodeWeight(rawDefinitionNode, keyNodes)); + IdentityHashMap planNodes = new IdentityHashMap(); + weight = saturatedAdd(weight, nodeWeight(programNode, planNodes)); + weight = saturatedAdd(weight, nodeWeight(definitionNode, planNodes)); + weight = saturatedAdd(weight, stringWeight(entry)); + weight = saturatedAdd(weight, stringWeight(sourceIdentity.programIdentity())); + weight = saturatedAdd(weight, stringWeight(sourceIdentity.definitionIdentity())); + weight = saturatedAdd(weight, stringWeight(sourceIdentity.entryName())); + return Math.max(PLAN_OVERHEAD_BYTES, weight); + } + + private static long nodeWeight(FrozenNode root, + IdentityHashMap visited) { + if (root == null) { + return 0L; + } + long weight = 0L; + Deque pending = new ArrayDeque(); + pending.push(root); + while (!pending.isEmpty()) { + FrozenNode node = pending.pop(); + if (visited.put(node, Boolean.TRUE) != null) { + continue; + } + weight = saturatedAdd(weight, NODE_OVERHEAD_BYTES); + weight = saturatedAdd(weight, stringWeight(node.getName())); + weight = saturatedAdd(weight, stringWeight(node.getDescription())); + weight = saturatedAdd(weight, stringWeight(node.getReferenceBlueId())); + weight = saturatedAdd(weight, stringWeight(node.getMergePolicy())); + weight = saturatedAdd(weight, stringWeight(node.getPreviousBlueId())); + Object value = node.getValue(); + if (value instanceof String) { + weight = saturatedAdd(weight, stringWeight((String) value)); + } else if (value != null) { + weight = saturatedAdd(weight, 32L); + } + pushIfPresent(pending, node.getType()); + pushIfPresent(pending, node.getItemType()); + pushIfPresent(pending, node.getKeyType()); + pushIfPresent(pending, node.getValueType()); + pushIfPresent(pending, node.getContracts()); + pushIfPresent(pending, node.getBlue()); + List items = node.getItems(); + if (items != null) { + weight = saturatedAdd(weight, + saturatedMultiply(COLLECTION_ENTRY_OVERHEAD_BYTES, items.size())); + for (FrozenNode item : items) { + pushIfPresent(pending, item); + } + } + Map properties = node.getProperties(); + if (properties != null) { + weight = saturatedAdd(weight, + saturatedMultiply(COLLECTION_ENTRY_OVERHEAD_BYTES, properties.size())); + for (Map.Entry property : properties.entrySet()) { + weight = saturatedAdd(weight, stringWeight(property.getKey())); + pushIfPresent(pending, property.getValue()); + } + } + } + return weight; + } + + private static void pushIfPresent(Deque pending, FrozenNode node) { + if (node != null) { + pending.push(node); + } + } + + private static long stringWeight(String value) { + return value == null ? 0L : 40L + (2L * value.length()); + } + + private static long saturatedAdd(long left, long right) { + if (right <= 0L) { + return left; + } + return left > Long.MAX_VALUE - right ? Long.MAX_VALUE : left + right; + } + + private static long saturatedMultiply(long left, int right) { + if (left <= 0L || right <= 0) { + return 0L; + } + return left > Long.MAX_VALUE / right ? Long.MAX_VALUE : left * right; + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/ComputeProgramPlanCache.java b/src/main/java/blue/coordination/processor/workflow/ComputeProgramPlanCache.java new file mode 100644 index 0000000..fc9df0a --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/ComputeProgramPlanCache.java @@ -0,0 +1,244 @@ +package blue.coordination.processor.workflow; + +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.snapshot.FrozenNode; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Thread-safe, entry- and weight-bounded LRU for immutable Compute plans. */ +final class ComputeProgramPlanCache implements AutoCloseable { + static final int DEFAULT_MAX_ENTRIES = 256; + static final long DEFAULT_MAX_WEIGHT_BYTES = 16L * 1024L * 1024L; + + interface PlanFactory { + ComputeProgramPlan create(); + } + + static final class Key { + private final FrozenNode.ResolvedStructuralKey rawStepIdentity; + private final FrozenNode.ResolvedStructuralKey definitionIdentity; + private final String effectiveEntry; + private final String normalizationVersion; + private final int hashCode; + + private Key(FrozenNode rawStepNode, + FrozenNode rawDefinitionNode, + String effectiveEntry, + String normalizationVersion) { + if (rawStepNode == null) { + throw new IllegalArgumentException("rawStepNode must not be null"); + } + if (normalizationVersion == null || normalizationVersion.isEmpty()) { + throw new IllegalArgumentException("normalizationVersion must not be empty"); + } + this.rawStepIdentity = rawStepNode.resolvedStructuralKey(); + this.definitionIdentity = rawDefinitionNode != null + ? rawDefinitionNode.resolvedStructuralKey() + : null; + this.effectiveEntry = effectiveEntry; + this.normalizationVersion = normalizationVersion; + this.hashCode = Objects.hash(rawStepIdentity, + definitionIdentity, + effectiveEntry, + normalizationVersion); + } + + static Key from(FrozenNode rawStepNode, + FrozenNode rawDefinitionNode, + String effectiveEntry, + String normalizationVersion) { + return new Key(rawStepNode, + rawDefinitionNode, + effectiveEntry, + normalizationVersion); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof Key)) { + return false; + } + Key that = (Key) other; + return rawStepIdentity.equals(that.rawStepIdentity) + && Objects.equals(definitionIdentity, that.definitionIdentity) + && Objects.equals(effectiveEntry, that.effectiveEntry) + && normalizationVersion.equals(that.normalizationVersion); + } + + @Override + public int hashCode() { + return hashCode; + } + } + + static final class Lookup { + private final Key key; + private final ComputeProgramPlan plan; + private final boolean cacheHit; + private final long generation; + + private Lookup(Key key, + ComputeProgramPlan plan, + boolean cacheHit, + long generation) { + this.key = key; + this.plan = plan; + this.cacheHit = cacheHit; + this.generation = generation; + } + + ComputeProgramPlan plan() { + return plan; + } + + boolean cacheHit() { + return cacheHit; + } + } + + private static final class Entry { + private final ComputeProgramPlan plan; + private final long weightBytes; + + private Entry(ComputeProgramPlan plan) { + this.plan = plan; + this.weightBytes = plan.approximateWeightBytes(); + } + } + + private final int maxEntries; + private final long maxWeightBytes; + private final BexProcessingMetrics metrics; + private final LinkedHashMap entries = + new LinkedHashMap(16, 0.75f, true); + private long weightBytes; + private long generation; + private boolean closed; + + ComputeProgramPlanCache() { + this(DEFAULT_MAX_ENTRIES, DEFAULT_MAX_WEIGHT_BYTES, null); + } + + ComputeProgramPlanCache(int maxEntries, + long maxWeightBytes, + BexProcessingMetrics metrics) { + if (maxEntries <= 0) { + throw new IllegalArgumentException("maxEntries must be positive"); + } + if (maxWeightBytes <= 0L) { + throw new IllegalArgumentException("maxWeightBytes must be positive"); + } + this.maxEntries = maxEntries; + this.maxWeightBytes = maxWeightBytes; + this.metrics = metrics; + } + + Lookup lookup(Key key, PlanFactory factory) { + if (key == null) { + throw new IllegalArgumentException("key must not be null"); + } + if (factory == null) { + throw new IllegalArgumentException("factory must not be null"); + } + long lookupGeneration; + synchronized (this) { + Entry cached = entries.get(key); + if (cached != null) { + if (metrics != null) { + metrics.incrementComputePlanCacheHits(); + } + return new Lookup(key, cached.plan, true, generation); + } + if (metrics != null) { + metrics.incrementComputePlanCacheMisses(); + } + lookupGeneration = generation; + } + + ComputeProgramPlan plan = factory.create(); + if (plan == null) { + throw new IllegalStateException("Compute plan factory returned null"); + } + if (metrics != null) { + metrics.incrementComputePlansBuilt(); + } + return new Lookup(key, plan, false, lookupGeneration); + } + + void publish(Lookup lookup) { + if (lookup == null || lookup.cacheHit) { + return; + } + Entry added = new Entry(lookup.plan); + if (added.weightBytes > maxWeightBytes) { + return; + } + synchronized (this) { + if (closed || lookup.generation != generation) { + return; + } + Entry existing = entries.get(lookup.key); + if (existing != null) { + return; + } + entries.put(lookup.key, added); + weightBytes = saturatedAdd(weightBytes, added.weightBytes); + if (metrics != null) { + metrics.addComputePlanWeightBytes(added.weightBytes); + } + evictIfNeeded(); + } + } + + synchronized int size() { + return entries.size(); + } + + synchronized long weightBytes() { + return weightBytes; + } + + synchronized boolean isClosed() { + return closed; + } + + synchronized void clear() { + if (weightBytes != 0L && metrics != null) { + metrics.addComputePlanWeightBytes(-weightBytes); + } + entries.clear(); + weightBytes = 0L; + generation++; + } + + @Override + public synchronized void close() { + clear(); + closed = true; + } + + private void evictIfNeeded() { + Iterator> iterator = entries.entrySet().iterator(); + while ((entries.size() > maxEntries || weightBytes > maxWeightBytes) + && iterator.hasNext()) { + Map.Entry eldest = iterator.next(); + long evictedWeight = eldest.getValue().weightBytes; + iterator.remove(); + weightBytes -= evictedWeight; + if (metrics != null) { + metrics.incrementComputePlanCacheEvictions(); + metrics.addComputePlanWeightBytes(-evictedWeight); + } + } + } + + private long saturatedAdd(long left, long right) { + return left > Long.MAX_VALUE - right ? Long.MAX_VALUE : left + right; + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/ComputeResultEmitter.java b/src/main/java/blue/coordination/processor/workflow/ComputeResultEmitter.java index bf0b022..433aca1 100644 --- a/src/main/java/blue/coordination/processor/workflow/ComputeResultEmitter.java +++ b/src/main/java/blue/coordination/processor/workflow/ComputeResultEmitter.java @@ -3,13 +3,14 @@ import blue.bex.result.BexChangeset; import blue.bex.result.BexExecutionResult; import blue.bex.result.BexPatchEntry; +import blue.bex.value.BexFrozenWriter; import blue.bex.value.BexNodeWriter; import blue.bex.value.BexValue; import blue.bex.value.BexValues; import blue.coordination.processor.bex.BexProcessingMetrics; import blue.language.model.Node; import blue.language.processor.WorkingDocument; -import blue.language.processor.model.JsonPatch; +import blue.language.processor.model.FrozenJsonPatch; import blue.language.snapshot.FrozenNode; import java.util.ArrayList; @@ -36,7 +37,7 @@ ComputeEffectPlan plan(BexExecutionResult result, } try { boolean returnedChangeset = hasReturnedChangeset(result); - List patches; + List patches; try { patches = changesetPatches(result, context); } catch (ComputeResultValidationException ex) { @@ -76,7 +77,7 @@ void buffer(ComputeEffectPlan plan, StepExecutionContext context) { throw new IllegalArgumentException("plan must not be null"); } plan.claimForBuffering(); - List patches = plan.patches(); + List patches = plan.patches(); if (!patches.isEmpty()) { applyPatches(patches, context); } @@ -154,7 +155,8 @@ private Termination termination(BexExecutionResult result) { return Termination.requested(reason.asText()); } - private List changesetPatches(BexExecutionResult result, StepExecutionContext context) { + private List changesetPatches(BexExecutionResult result, + StepExecutionContext context) { BexValue changeset = result.value() != null ? result.value().get("changeset") : BexValues.undefined(); BexChangeset accumulated = result.changeset(); if (changeset.isUndefined() || changeset.isNull()) { @@ -169,7 +171,7 @@ private List changesetPatches(BexExecutionResult result, StepExecutio if (isAccumulatedChangesetValue(changeset, accumulated)) { return patchesFromBexChangeset(accumulated, context); } - List patches = new ArrayList(changeset.size()); + List patches = new ArrayList(changeset.size()); for (int i = 0; i < changeset.size(); i++) { WorkflowPatchEntry entry = patchEntry(changeset.get(String.valueOf(i)), i); patches.add(toPatch(entry, context)); @@ -177,7 +179,8 @@ private List changesetPatches(BexExecutionResult result, StepExecutio return patches; } - private List patchesFromBexChangeset(BexChangeset changeset, StepExecutionContext context) { + private List patchesFromBexChangeset(BexChangeset changeset, + StepExecutionContext context) { if (changeset == null || changeset.entries().isEmpty()) { return Collections.emptyList(); } @@ -186,7 +189,8 @@ private List patchesFromBexChangeset(BexChangeset changeset, StepExec } long conversionStart = System.nanoTime(); try { - List patches = new ArrayList(changeset.entries().size()); + List patches = + new ArrayList(changeset.entries().size()); for (BexPatchEntry entry : changeset.entries()) { patches.add(toPatch(entry, context)); if (metrics != null) { @@ -213,38 +217,33 @@ private WorkflowPatchEntry patchEntry(BexValue item, int index) { if (path == null || path.trim().isEmpty()) { throw invalid("Compute result changeset entry " + index + " missing path"); } - Node nodeValue = null; + FrozenNode nodeValue = null; if (!"remove".equals(op)) { BexValue val = item.get("val"); if (val.isUndefined()) { throw invalid("Compute result changeset entry " + index + " missing val"); } - long writerStart = System.nanoTime(); - try { - nodeValue = BexNodeWriter.toNode(val); - } finally { - if (metrics != null) { - metrics.addBexNodeWriterNanos(System.nanoTime() - writerStart); - } - } + nodeValue = freezePatchValue(val); } return new WorkflowPatchEntry(op, path, nodeValue); } - private JsonPatch toPatch(WorkflowPatchEntry entry, StepExecutionContext context) { + private FrozenJsonPatch toPatch(WorkflowPatchEntry entry, + StepExecutionContext context) { String normalizedOp = entry.op().trim().toLowerCase(); String path = resolvedPointer(entry.path(), context); if ("remove".equals(normalizedOp)) { - return JsonPatch.remove(path); + return FrozenJsonPatch.remove(path); } if ("add".equals(normalizedOp)) { - return JsonPatch.add(path, entry.val()); + return FrozenJsonPatch.add(path, entry.val()); } // patchEntry has already restricted this branch to replace. - return JsonPatch.replace(path, entry.val()); + return FrozenJsonPatch.replace(path, entry.val()); } - private JsonPatch toPatch(BexPatchEntry entry, StepExecutionContext context) { + private FrozenJsonPatch toPatch(BexPatchEntry entry, + StepExecutionContext context) { if (entry == null) { throw invalid("Compute result accumulated patch is incomplete"); } @@ -255,22 +254,14 @@ private JsonPatch toPatch(BexPatchEntry entry, StepExecutionContext context) { } String path = resolvedPointer(entry.authoredPath(), context); if (remove) { - return JsonPatch.remove(path); - } - long writerStart = System.nanoTime(); - Node value; - try { - value = BexNodeWriter.toNode(entry.val()); - } finally { - if (metrics != null) { - metrics.addBexNodeWriterNanos(System.nanoTime() - writerStart); - } + return FrozenJsonPatch.remove(path); } + FrozenNode value = freezePatchValue(entry.val()); if ("add".equals(normalizedOp)) { - return JsonPatch.add(path, value); + return FrozenJsonPatch.add(path, value); } // BexPatchEntry has already restricted this branch to replace. - return JsonPatch.replace(path, value); + return FrozenJsonPatch.replace(path, value); } private String resolvedPointer(String authoredPath, StepExecutionContext context) { @@ -281,14 +272,32 @@ private String resolvedPointer(String authoredPath, StepExecutionContext context } } - private void applyPatches(List patches, StepExecutionContext context) { + private void applyPatches(List patches, + StepExecutionContext context) { long applyStart = System.nanoTime(); boolean applied = false; + boolean previewTransferred = false; + WorkingDocument.Preview preview = null; + long frozenValueCount = frozenValueCount(patches); try { - WorkingDocument.Preview preview = context.advanceWorkingDocument(patches); - context.processorContext().applyPreviewedPatches(patches, preview); + preview = context.advanceWorkingDocumentFrozen(patches); + if (preview == null) { + return; + } + if (metrics != null) { + metrics.addMetric("frozenPatchesHandedToLanguage", patches.size()); + metrics.addMetric("frozenPatchValuesHandedToLanguage", frozenValueCount); + } + context.processorContext().applyPreviewedFrozenPatches(patches, preview); + previewTransferred = true; + if (metrics != null) { + metrics.addMetric("frozenPatchValuesHandedToLanguage", frozenValueCount); + } applied = true; } finally { + if (!previewTransferred && preview != null) { + preview.close(); + } if (metrics != null) { metrics.addUpdatePatchApplyNanos(System.nanoTime() - applyStart); if (applied) { @@ -299,6 +308,16 @@ private void applyPatches(List patches, StepExecutionContext context) } } + private long frozenValueCount(List patches) { + long count = 0L; + for (FrozenJsonPatch patch : patches) { + if (patch.getValue() != null) { + count++; + } + } + return count; + } + private boolean isAccumulatedChangesetValue(BexValue value, BexChangeset changeset) { if (value == null || !value.isList() || changeset == null) { return false; @@ -324,13 +343,52 @@ private boolean isAccumulatedChangesetValue(BexValue value, BexChangeset changes if (val != null && !val.isUndefined() && !val.isNull()) { return false; } - } else if (val == null || val.isUndefined() || !Objects.equals(entry.val().toSimple(), val.toSimple())) { + } else if (val == null || val.isUndefined()) { + return false; + } else if (entry.val() != val + && !Objects.equals(entry.val().toSimple(), val.toSimple())) { + // BEX's accumulated changeset view preserves the exact value object + // held by each BexPatchEntry. The identity branch is therefore the + // normal path; deep conversion remains only for an independently + // authored result that happens to be semantically equivalent. return false; } } return true; } + FrozenNode freezePatchValue(BexValue value) { + // BEX exposes the exact FrozenNode only through BexFrozenWriter. Avoid + // invoking that writer for ordinary values because rc2's fallback factory + // itself performs Node round trips. A non-null frozen BlueId identifies the + // zero-materialization FrozenNode-backed lane. + if (BexValues.frozenBlueId(value) != null) { + FrozenNode frozen = BexFrozenWriter.toFrozen(value); + if (frozen.isStrictCanonical()) { + if (metrics != null) { + metrics.incrementBexPatchFrozenDirectConversions(); + } + return frozen; + } + } + return materializePatchValue(value); + } + + private FrozenNode materializePatchValue(BexValue value) { + long writerStart = System.nanoTime(); + try { + // This is the one unavoidable rc2 boundary for newly computed values: + // take a mutable BEX rendering and immediately freeze it as authored + // canonical content. No mutable value crosses into Language. + return FrozenNode.fromNode(BexNodeWriter.toNode(value)); + } finally { + if (metrics != null) { + metrics.addBexNodeWriterNanos(System.nanoTime() - writerStart); + metrics.incrementBexPatchNodeMaterializations(); + } + } + } + private String textValue(BexValue value) { if (value == null || value.isUndefined() || value.isNull()) { return null; diff --git a/src/main/java/blue/coordination/processor/workflow/ComputeStepExecutor.java b/src/main/java/blue/coordination/processor/workflow/ComputeStepExecutor.java index acc9b80..168be75 100644 --- a/src/main/java/blue/coordination/processor/workflow/ComputeStepExecutor.java +++ b/src/main/java/blue/coordination/processor/workflow/ComputeStepExecutor.java @@ -13,13 +13,14 @@ import blue.repo.coordination.Compute; import blue.repo.coordination.SequentialWorkflowStep; -public final class ComputeStepExecutor implements WorkflowStepExecutor { +public final class ComputeStepExecutor implements WorkflowStepExecutor, AutoCloseable { private final BexEngine bexEngine; private final long defaultGasLimit; private final ComputeDefinitionResolver definitionResolver; private final BexWorkflowContextFactory contextFactory; private final ComputeResultEmitter resultEmitter; private final ComputeProgramNormalizer normalizer; + private final ComputeProgramPlanCache planCache; private final BexProcessingMetrics metrics; public ComputeStepExecutor() { @@ -61,7 +62,11 @@ public ComputeStepExecutor(BexEngine bexEngine, long defaultGasLimit) { this.definitionResolver = definitionResolver; this.contextFactory = contextFactory; this.resultEmitter = resultEmitter; - this.normalizer = new ComputeProgramNormalizer(); + this.normalizer = new ComputeProgramNormalizer(metrics); + this.planCache = new ComputeProgramPlanCache( + ComputeProgramPlanCache.DEFAULT_MAX_ENTRIES, + ComputeProgramPlanCache.DEFAULT_MAX_WEIGHT_BYTES, + metrics); this.metrics = metrics; } @@ -77,35 +82,46 @@ public WorkflowStepResult execute(Compute step, StepExecutionContext context) { if (metrics != null) { metrics.incrementComputeStepsExecuted(); } - Node rawStepNode = context.stepNodeRef(); + FrozenNode rawStepNode = context.stepFrozenNode(); if (rawStepNode == null) { - context.processorContext().throwFatal("Compute step must have a raw step node"); - return WorkflowStepResult.none(); + Node mutableStepNode = context.stepNodeRef(); + if (mutableStepNode == null) { + context.processorContext().throwFatal("Compute step must have a raw step node"); + return WorkflowStepResult.none(); + } + rawStepNode = FrozenNode.fromResolvedNode(mutableStepNode); } - FrozenNode programNode = FrozenNode.fromResolvedNode(normalizer.program(rawStepNode)); long resolveStart = System.nanoTime(); - FrozenNode definitionNode = definitionResolver.resolve(programNode, context); - if (definitionNode != null) { - definitionNode = FrozenNode.fromResolvedNode(normalizer.definition(definitionNode.toNode())); - } + final FrozenNode resolvedDefinitionNode = definitionResolver.resolve(rawStepNode, + context, + metrics); if (metrics != null) { metrics.addComputeDefinitionResolveNanos(System.nanoTime() - resolveStart); } - String entry = FrozenNodeUtil.textProperty(programNode, "entry"); - long sourceStart = System.nanoTime(); - BexProgramSource source = definitionNode != null - ? BexProgramSource.withDefinition(programNode, definitionNode, entry) - : BexProgramSource.inline(programNode); - if (metrics != null) { - metrics.addComputeProgramSourceBuildNanos(System.nanoTime() - sourceStart); - } + final FrozenNode exactRawStepNode = rawStepNode; + final String effectiveEntry = FrozenNodeUtil.textProperty(rawStepNode, "entry"); + ComputeProgramPlanCache.Key planKey = ComputeProgramPlanCache.Key.from( + rawStepNode, + resolvedDefinitionNode, + effectiveEntry, + normalizer.normalizationVersion()); + ComputeProgramPlanCache.Lookup lookup = planCache.lookup(planKey, + new ComputeProgramPlanCache.PlanFactory() { + @Override + public ComputeProgramPlan create() { + return buildPlan(exactRawStepNode, + resolvedDefinitionNode, + effectiveEntry); + } + }); + ComputeProgramPlan computePlan = lookup.plan(); long contextStart = System.nanoTime(); - BexExecutionContext bexContext = contextFactory.create(context, computeGasLimit(programNode)); + BexExecutionContext bexContext = contextFactory.create(context, computePlan.gasLimit()); if (metrics != null) { metrics.addComputeContextBuildNanos(System.nanoTime() - contextStart); } long executeStart = System.nanoTime(); - BexExecutionResult result = bexEngine.compileAndExecute(source, bexContext); + BexExecutionResult result = bexEngine.compileAndExecute(computePlan.source(), bexContext); if (metrics != null) { metrics.addComputeCompileExecuteNanos(System.nanoTime() - executeStart); metrics.addBexMetrics(result.metrics()); @@ -113,19 +129,24 @@ public WorkflowStepResult execute(Compute step, StepExecutionContext context) { if (result.gasUsed() > 0L) { context.processorContext().consumeGas(result.gasUsed()); } - ComputeEffectPlan plan = resultEmitter.plan(result, + ComputeEffectPlan effectPlan = resultEmitter.plan(result, context, - FrozenNodeUtil.booleanProperty(programNode, "emitEvents", true)); - resultEmitter.buffer(plan, context); - boolean returnResult = FrozenNodeUtil.booleanProperty(programNode, "returnResult", true); - if (plan.terminationRequested()) { - return returnResult - ? WorkflowStepResult.terminalValue(result, plan.changesetHandled()) + computePlan.emitEvents()); + resultEmitter.buffer(effectPlan, context); + WorkflowStepResult stepResult; + if (effectPlan.terminationRequested()) { + stepResult = computePlan.returnResult() + ? WorkflowStepResult.terminalValue(result, effectPlan.changesetHandled()) : WorkflowStepResult.terminal(); + } else { + stepResult = computePlan.returnResult() + ? WorkflowStepResult.value(result, effectPlan.changesetHandled()) + : WorkflowStepResult.none(); } - return returnResult - ? WorkflowStepResult.value(result, plan.changesetHandled()) - : WorkflowStepResult.none(); + // Failed compilation, execution, result validation, gas handling, + // or effect buffering never publishes a candidate plan. + planCache.publish(lookup); + return stepResult; } catch (ComputeResultValidationException ex) { if (metrics != null) { metrics.incrementComputeResultValidationFailures(); @@ -158,4 +179,58 @@ private long computeGasLimit(FrozenNode stepNode) { return parsed.longValue(); } + /** Clears reusable Compute plans while keeping this executor usable. */ + public void clearPlanCache() { + planCache.clear(); + } + + @Override + public void close() { + planCache.close(); + } + + int cachedPlanCount() { + return planCache.size(); + } + + long cachedPlanWeightBytes() { + return planCache.weightBytes(); + } + + boolean isPlanCacheClosed() { + return planCache.isClosed(); + } + + private ComputeProgramPlan buildPlan(FrozenNode rawStepNode, + FrozenNode rawDefinitionNode, + String effectiveEntry) { + FrozenNode programNode = normalizer.program(rawStepNode); + FrozenNode definitionNode = rawDefinitionNode != null + ? normalizer.definition(rawDefinitionNode) + : null; + String normalizedEntry = FrozenNodeUtil.textProperty(programNode, "entry"); + // The key is built from the authored effective entry. Retain the + // normalized value in the source to preserve the pre-cache behavior. + if (effectiveEntry == null ? normalizedEntry != null : !effectiveEntry.equals(normalizedEntry)) { + throw new BexException("Compute entry changed during normalization"); + } + long sourceStart = System.nanoTime(); + BexProgramSource source = definitionNode != null + ? BexProgramSource.withDefinition(programNode, definitionNode, normalizedEntry) + : BexProgramSource.inline(programNode); + if (metrics != null) { + metrics.incrementComputeProgramSourceBuilds(); + metrics.addComputeProgramSourceBuildNanos(System.nanoTime() - sourceStart); + } + return new ComputeProgramPlan(programNode, + definitionNode, + source, + normalizedEntry, + computeGasLimit(programNode), + FrozenNodeUtil.booleanProperty(programNode, "emitEvents", true), + FrozenNodeUtil.booleanProperty(programNode, "returnResult", true), + rawStepNode, + rawDefinitionNode); + } + } diff --git a/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowPlan.java b/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowPlan.java new file mode 100644 index 0000000..0295de4 --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowPlan.java @@ -0,0 +1,283 @@ +package blue.coordination.processor.workflow; + +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.snapshot.FrozenNode; +import blue.repo.coordination.Compute; +import blue.repo.coordination.SequentialWorkflowStep; +import blue.repo.coordination.TerminateProcessing; +import blue.repo.coordination.TriggerEvent; +import blue.repo.coordination.UpdateDocument; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +/** Immutable execution structure for one exact frozen workflow contract. */ +final class SequentialWorkflowPlan { + private static final long PLAN_BASE_BYTES = 128L; + private static final long STEP_PLAN_BYTES = 96L; + + private final FrozenNode.ResolvedStructuralKey contractIdentity; + private final List steps; + private final long approximateWeightBytes; + + private SequentialWorkflowPlan(FrozenNode contractNode, List steps) { + this.contractIdentity = contractNode != null ? contractNode.resolvedStructuralKey() : null; + this.steps = Collections.unmodifiableList(new ArrayList(steps)); + this.approximateWeightBytes = estimateWeight(contractNode, this.steps); + } + + static SequentialWorkflowPlan build(FrozenNode contractNode, + List workflowSteps, + List> executors, + BexProcessingMetrics metrics) { + List frozenSteps = stepNodes(contractNode); + int plannedStepCount = Math.max(workflowSteps.size(), frozenSteps.size()); + List planned = new ArrayList(plannedStepCount); + for (int i = 0; i < plannedStepCount; i++) { + FrozenNode frozenStep = i < frozenSteps.size() ? frozenSteps.get(i) : null; + SequentialWorkflowStep workflowStep = i < workflowSteps.size() ? workflowSteps.get(i) : null; + planned.add(planStep(workflowStep, frozenStep, i, executors, metrics)); + } + return new SequentialWorkflowPlan(contractNode, planned); + } + + static StepPlan planStep(SequentialWorkflowStep step, + FrozenNode frozenStep, + int index, + List> executors, + BexProcessingMetrics metrics) { + WorkflowStepExecutor selected = null; + if (step != null) { + for (WorkflowStepExecutor executor : executors) { + if (metrics != null) { + metrics.incrementWorkflowExecutorLookups(); + } + if (executor.supports(step)) { + selected = executor; + break; + } + } + } + StaticUpdatePlan staticUpdatePlan = null; + if (step instanceof UpdateDocument && frozenStep != null) { + StaticUpdatePlan candidate = StaticUpdatePlan.compile( + FrozenNodeUtil.property(frozenStep, "changeset"), metrics); + if (candidate.valid()) { + staticUpdatePlan = candidate; + if (metrics != null) { + metrics.incrementUpdateStaticTemplatesBuilt(); + } + } + } + return new StepPlan(index, + stepKey(frozenStep, index), + stepName(step), + frozenStep, + step != null ? step.getClass() : null, + selected, + step instanceof TerminateProcessing, + staticUpdatePlan); + } + + FrozenNode.ResolvedStructuralKey contractIdentity() { + return contractIdentity; + } + + int stepCount() { + return steps.size(); + } + + StepPlan step(int index) { + return steps.get(index); + } + + long approximateWeightBytes() { + return approximateWeightBytes; + } + + private static List stepNodes(FrozenNode contractNode) { + if (contractNode == null || contractNode.getProperties() == null) { + return Collections.emptyList(); + } + FrozenNode stepsNode = contractNode.getProperties().get("steps"); + if (stepsNode == null || stepsNode.getItems() == null) { + return Collections.emptyList(); + } + return stepsNode.getItems(); + } + + private static String stepKey(FrozenNode stepNode, int index) { + if (stepNode != null && stepNode.getName() != null && !stepNode.getName().trim().isEmpty()) { + return stepNode.getName().trim(); + } + return "Step" + (index + 1); + } + + private static String stepName(SequentialWorkflowStep step) { + if (step == null) { + return "null sequential workflow step"; + } + if (step instanceof TriggerEvent) { + return "Coordination/Trigger Event"; + } + if (step instanceof Compute) { + return "Coordination/Compute"; + } + if (step instanceof TerminateProcessing) { + return "Coordination/Terminate Processing"; + } + return step.getClass().getName(); + } + + private static long estimateWeight(FrozenNode contractNode, List steps) { + WeightEstimator identityEstimator = new WeightEstimator(); + WeightEstimator retainedStepEstimator = new WeightEstimator(); + long weight = PLAN_BASE_BYTES; + // The exact structural key retains representation data comparable to + // one traversal of the frozen graph. + weight = saturatedAdd(weight, identityEstimator.node(contractNode)); + for (StepPlan step : steps) { + weight = saturatedAdd(weight, STEP_PLAN_BYTES); + weight = saturatedAdd(weight, retainedStepEstimator.string(step.key)); + weight = saturatedAdd(weight, retainedStepEstimator.string(step.kind)); + weight = saturatedAdd(weight, retainedStepEstimator.node(step.frozenStep)); + if (step.staticUpdatePlan != null) { + weight = saturatedAdd(weight, step.staticUpdatePlan.approximateWeightBytes()); + } + } + return Math.max(1L, weight); + } + + private static long saturatedAdd(long left, long right) { + if (right > 0L && left > Long.MAX_VALUE - right) { + return Long.MAX_VALUE; + } + return left + right; + } + + static final class StepPlan { + private final int index; + private final String key; + private final String kind; + private final FrozenNode frozenStep; + private final Class runtimeStepClass; + private final WorkflowStepExecutor executor; + private final boolean declarativelyTerminal; + private final StaticUpdatePlan staticUpdatePlan; + + private StepPlan(int index, + String key, + String kind, + FrozenNode frozenStep, + Class runtimeStepClass, + WorkflowStepExecutor executor, + boolean declarativelyTerminal, + StaticUpdatePlan staticUpdatePlan) { + this.index = index; + this.key = key; + this.kind = kind; + this.frozenStep = frozenStep; + this.runtimeStepClass = runtimeStepClass; + this.executor = executor; + this.declarativelyTerminal = declarativelyTerminal; + this.staticUpdatePlan = staticUpdatePlan; + } + + int index() { + return index; + } + + String key() { + return key; + } + + String kind() { + return kind; + } + + FrozenNode frozenStep() { + return frozenStep; + } + + WorkflowStepExecutor executor() { + return executor; + } + + boolean matches(SequentialWorkflowStep step) { + return step == null ? runtimeStepClass == null : step.getClass() == runtimeStepClass; + } + + boolean declarativelyTerminal() { + return declarativelyTerminal; + } + + StaticUpdatePlan staticUpdatePlan() { + return staticUpdatePlan; + } + } + + private static final class WeightEstimator { + private final IdentityHashMap visited = new IdentityHashMap(); + + private long node(FrozenNode node) { + if (node == null || visited.put(node, Boolean.TRUE) != null) { + return 0L; + } + long weight = 160L; + weight = saturatedAdd(weight, string(node.getName())); + weight = saturatedAdd(weight, string(node.getDescription())); + weight = saturatedAdd(weight, string(node.getReferenceBlueId())); + weight = saturatedAdd(weight, string(node.getMergePolicy())); + weight = saturatedAdd(weight, string(node.getPreviousBlueId())); + weight = saturatedAdd(weight, scalar(node.getValue())); + weight = saturatedAdd(weight, node(node.getType())); + weight = saturatedAdd(weight, node(node.getItemType())); + weight = saturatedAdd(weight, node(node.getKeyType())); + weight = saturatedAdd(weight, node(node.getValueType())); + weight = saturatedAdd(weight, node(node.getContracts())); + weight = saturatedAdd(weight, node(node.getBlue())); + if (node.getSchema() != null) { + weight = saturatedAdd(weight, 256L); + } + if (node.getItems() != null) { + weight = saturatedAdd(weight, 24L + 8L * node.getItems().size()); + for (FrozenNode item : node.getItems()) { + weight = saturatedAdd(weight, node(item)); + } + } + if (node.getProperties() != null) { + weight = saturatedAdd(weight, 48L + 48L * node.getProperties().size()); + for (Map.Entry entry : node.getProperties().entrySet()) { + weight = saturatedAdd(weight, string(entry.getKey())); + weight = saturatedAdd(weight, node(entry.getValue())); + } + } + return weight; + } + + private long string(String value) { + return value == null ? 0L : 40L + 2L * value.length(); + } + + private long scalar(Object value) { + if (value == null) { + return 0L; + } + if (value instanceof String) { + return string((String) value); + } + if (value instanceof BigInteger) { + return 48L + ((BigInteger) value).bitLength() / 8L; + } + if (value instanceof BigDecimal) { + return 64L; + } + return 24L; + } + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowPlanCache.java b/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowPlanCache.java new file mode 100644 index 0000000..01c641f --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowPlanCache.java @@ -0,0 +1,149 @@ +package blue.coordination.processor.workflow; + +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.snapshot.FrozenNode; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Per-runner, entry- and weight-bounded LRU for immutable workflow plans. */ +final class SequentialWorkflowPlanCache implements AutoCloseable { + static final int DEFAULT_MAX_ENTRIES = 512; + static final long DEFAULT_MAX_WEIGHT_BYTES = 32L * 1024L * 1024L; + private static final long CACHE_ENTRY_BYTES = 64L; + + private final int maxEntries; + private final long maxWeightBytes; + private final BexProcessingMetrics metrics; + private final LinkedHashMap entries = + new LinkedHashMap(16, 0.75f, true); + private long weightBytes; + private boolean closed; + + SequentialWorkflowPlanCache(BexProcessingMetrics metrics) { + this(DEFAULT_MAX_ENTRIES, DEFAULT_MAX_WEIGHT_BYTES, metrics); + } + + SequentialWorkflowPlanCache(int maxEntries, + long maxWeightBytes, + BexProcessingMetrics metrics) { + if (maxEntries <= 0) { + throw new IllegalArgumentException("maxEntries must be positive"); + } + if (maxWeightBytes <= 0L) { + throw new IllegalArgumentException("maxWeightBytes must be positive"); + } + this.maxEntries = maxEntries; + this.maxWeightBytes = maxWeightBytes; + this.metrics = metrics; + } + + synchronized SequentialWorkflowPlan getOrBuild(FrozenNode.ResolvedStructuralKey identity, + PlanFactory factory) { + if (identity == null) { + throw new IllegalArgumentException("identity must not be null"); + } + if (factory == null) { + throw new IllegalArgumentException("factory must not be null"); + } + CacheEntry cached = entries.get(identity); + if (cached != null) { + if (metrics != null) { + metrics.incrementWorkflowPlanCacheHits(); + } + return cached.plan; + } + if (metrics != null) { + metrics.incrementWorkflowPlanCacheMisses(); + } + SequentialWorkflowPlan plan = factory.build(); + if (plan == null) { + throw new IllegalStateException("workflow plan factory returned null"); + } + if (!identity.equals(plan.contractIdentity())) { + throw new IllegalStateException("workflow plan identity does not match cache key"); + } + if (metrics != null) { + metrics.incrementWorkflowPlansBuilt(); + } + long entryWeight = entryWeight(plan); + if (closed || entryWeight > maxWeightBytes) { + return plan; + } + entries.put(identity, new CacheEntry(plan, entryWeight)); + adjustWeight(entryWeight); + evictToBounds(); + return plan; + } + + synchronized int size() { + return entries.size(); + } + + synchronized long weightBytes() { + return weightBytes; + } + + synchronized boolean isClosed() { + return closed; + } + + synchronized void clear() { + if (entries.isEmpty()) { + return; + } + entries.clear(); + long removed = weightBytes; + weightBytes = 0L; + if (metrics != null && removed != 0L) { + metrics.addWorkflowPlanWeightBytes(-removed); + } + } + + @Override + public synchronized void close() { + closed = true; + clear(); + } + + private void evictToBounds() { + Iterator> iterator = entries.entrySet().iterator(); + while ((entries.size() > maxEntries || weightBytes > maxWeightBytes) && iterator.hasNext()) { + CacheEntry eldest = iterator.next().getValue(); + iterator.remove(); + adjustWeight(-eldest.weightBytes); + if (metrics != null) { + metrics.incrementWorkflowPlanCacheEvictions(); + } + } + } + + private long entryWeight(SequentialWorkflowPlan plan) { + long planWeight = plan.approximateWeightBytes(); + return planWeight > Long.MAX_VALUE - CACHE_ENTRY_BYTES + ? Long.MAX_VALUE + : planWeight + CACHE_ENTRY_BYTES; + } + + private void adjustWeight(long delta) { + weightBytes += delta; + if (metrics != null) { + metrics.addWorkflowPlanWeightBytes(delta); + } + } + + interface PlanFactory { + SequentialWorkflowPlan build(); + } + + private static final class CacheEntry { + private final SequentialWorkflowPlan plan; + private final long weightBytes; + + private CacheEntry(SequentialWorkflowPlan plan, long weightBytes) { + this.plan = plan; + this.weightBytes = weightBytes; + } + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowRunner.java b/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowRunner.java index 472063c..510259a 100644 --- a/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowRunner.java +++ b/src/main/java/blue/coordination/processor/workflow/SequentialWorkflowRunner.java @@ -14,15 +14,12 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.Set; -public final class SequentialWorkflowRunner { +public final class SequentialWorkflowRunner implements AutoCloseable { private final List> executors; private final BexProcessingMetrics metrics; + private final SequentialWorkflowPlanCache planCache; public SequentialWorkflowRunner() { this(defaultExecutors()); @@ -34,11 +31,24 @@ public SequentialWorkflowRunner(List> executors, BexProcessingMetrics metrics) { + this(executors, + metrics, + SequentialWorkflowPlanCache.DEFAULT_MAX_ENTRIES, + SequentialWorkflowPlanCache.DEFAULT_MAX_WEIGHT_BYTES); + } + + SequentialWorkflowRunner(List> executors, + BexProcessingMetrics metrics, + int planCacheMaxEntries, + long planCacheMaxWeightBytes) { if (executors == null) { throw new IllegalArgumentException("executors must not be null"); } this.executors = Collections.unmodifiableList(new ArrayList>(executors)); this.metrics = metrics; + this.planCache = new SequentialWorkflowPlanCache(planCacheMaxEntries, + planCacheMaxWeightBytes, + metrics); } public void execute(SequentialWorkflow workflow, ProcessorExecutionContext context) { @@ -47,36 +57,43 @@ public void execute(SequentialWorkflow workflow, ProcessorExecutionContext conte if (workflow.getSteps() == null) { return; } - Map stepResults = new LinkedHashMap(); - Set handledChangesetSteps = new LinkedHashSet(); FrozenNode contractNode = rawContractNode(context); - List stepNodes = stepNodes(contractNode); List steps = workflow.getSteps(); - WorkingDocument workingDocument = rootWorkingDocument(context); - for (int i = 0; i < steps.size(); i++) { - SequentialWorkflowStep step = steps.get(i); - FrozenNode stepNode = i < stepNodes.size() ? stepNodes.get(i) : null; - if (metrics != null) { - metrics.incrementWorkflowStepsExecuted(); - } - WorkflowStepResult result = executeStep(workflow, - step, - stepNode, - contractNode, - i, - stepResults, - handledChangesetSteps, - context, - workingDocument); - if (result != null && result.hasValue()) { - String key = stepKey(stepNode, i); - stepResults.put(key, result.value()); - if (result.changesetHandled()) { - handledChangesetSteps.add(key); + SequentialWorkflowPlan plan = workflowPlan(contractNode, steps); + WorkflowExecutionState executionState = new WorkflowExecutionState(); + try (WorkingDocument workingDocument = rootWorkingDocument(context)) { + for (int i = 0; i < steps.size(); i++) { + SequentialWorkflowStep step = steps.get(i); + SequentialWorkflowPlan.StepPlan stepPlan = i < plan.stepCount() + ? plan.step(i) + : SequentialWorkflowPlan.planStep(step, + null, + i, + executors, + metrics); + if (!stepPlan.matches(step)) { + stepPlan = SequentialWorkflowPlan.planStep(step, + stepPlan.frozenStep(), + i, + executors, + metrics); + } + if (metrics != null) { + metrics.incrementWorkflowStepsExecuted(); + } + WorkflowStepResult result = executeStep(workflow, + step, + stepPlan, + contractNode, + executionState, + context, + workingDocument); + if (result != null && result.hasValue()) { + executionState.record(stepPlan.key(), result.value(), result.changesetHandled()); + } + if (result != null && result.isTerminal()) { + break; } - } - if (result != null && result.isTerminal()) { - break; } } } finally { @@ -88,33 +105,34 @@ public void execute(SequentialWorkflow workflow, ProcessorExecutionContext conte private WorkflowStepResult executeStep(SequentialWorkflow workflow, SequentialWorkflowStep step, - FrozenNode stepNode, + SequentialWorkflowPlan.StepPlan stepPlan, FrozenNode contractNode, - int stepIndex, - Map stepResults, - Set handledChangesetSteps, + WorkflowExecutionState executionState, ProcessorExecutionContext context, WorkingDocument workingDocument) { if (step == null) { context.throwFatal("Unsupported null sequential workflow step"); return WorkflowStepResult.none(); } - for (WorkflowStepExecutor executor : executors) { - if (executor.supports(step)) { - StepExecutionContext stepContext = new StepExecutionContext(context, - workflow, - step, - stepNode, - contractNode, - stepIndex, - stepResults, - handledChangesetSteps, - workingDocument); - return executeSupported(executor, step, stepContext); - } + WorkflowStepExecutor executor = stepPlan.executor(); + if (executor == null) { + context.throwFatal("Unsupported sequential workflow step: " + stepPlan.kind()); + return WorkflowStepResult.none(); + } + WorkflowExecutionState.Snapshot stateView = executionState.snapshotView(); + if (metrics != null) { + metrics.incrementWorkflowStepResultViewHits(); } - context.throwFatal("Unsupported sequential workflow step: " + stepName(step)); - return WorkflowStepResult.none(); + StepExecutionContext stepContext = new StepExecutionContext(context, + workflow, + step, + stepPlan.frozenStep(), + contractNode, + stepPlan.index(), + stateView, + stepPlan.staticUpdatePlan(), + workingDocument); + return executeSupported(executor, step, stepContext); } @SuppressWarnings({"unchecked", "rawtypes"}) @@ -124,19 +142,6 @@ private WorkflowStepResult executeSupported(WorkflowStepExecutor executor, return executor.execute(step, context); } - private String stepName(SequentialWorkflowStep step) { - if (step instanceof TriggerEvent) { - return "Coordination/Trigger Event"; - } - if (step instanceof Compute) { - return "Coordination/Compute"; - } - if (step instanceof TerminateProcessing) { - return "Coordination/Terminate Processing"; - } - return step.getClass().getName(); - } - private static List> defaultExecutors() { return executorsFor(BexEngine.builder().build(), 100_000L); } @@ -186,22 +191,55 @@ private static List> exec new UpdateDocumentStepExecutor(metrics)); } - private List stepNodes(FrozenNode contractNode) { - if (contractNode == null || contractNode.getProperties() == null) { - return Collections.emptyList(); + private SequentialWorkflowPlan workflowPlan(final FrozenNode contractNode, + final List steps) { + if (contractNode == null) { + if (metrics != null) { + metrics.incrementWorkflowPlanCacheMisses(); + } + SequentialWorkflowPlan plan = SequentialWorkflowPlan.build(null, steps, executors, metrics); + if (metrics != null) { + metrics.incrementWorkflowPlansBuilt(); + } + return plan; } - FrozenNode steps = contractNode.getProperties().get("steps"); - if (steps == null || steps.getItems() == null) { - return Collections.emptyList(); + return planCache.getOrBuild(contractNode.resolvedStructuralKey(), + new SequentialWorkflowPlanCache.PlanFactory() { + @Override + public SequentialWorkflowPlan build() { + return SequentialWorkflowPlan.build(contractNode, steps, executors, metrics); + } + }); + } + + /** Clears all immutable workflow plans retained by this runner. */ + public void clearCaches() { + planCache.clear(); + for (WorkflowStepExecutor executor : executors) { + if (executor instanceof ComputeStepExecutor) { + ((ComputeStepExecutor) executor).clearPlanCache(); + } } - return steps.getItems(); } - private String stepKey(FrozenNode stepNode, int index) { - if (stepNode != null && stepNode.getName() != null && !stepNode.getName().trim().isEmpty()) { - return stepNode.getName().trim(); + /** Current number of retained workflow plans. */ + public int workflowPlanCacheSize() { + return planCache.size(); + } + + /** Current approximate retained workflow-plan weight. */ + public long workflowPlanCacheWeightBytes() { + return planCache.weightBytes(); + } + + @Override + public void close() { + planCache.close(); + for (WorkflowStepExecutor executor : executors) { + if (executor instanceof ComputeStepExecutor) { + ((ComputeStepExecutor) executor).close(); + } } - return "Step" + (index + 1); } private FrozenNode rawContractNode(ProcessorExecutionContext context) { diff --git a/src/main/java/blue/coordination/processor/workflow/StaticPayloadValidator.java b/src/main/java/blue/coordination/processor/workflow/StaticPayloadValidator.java index c9c9989..31bad7d 100644 --- a/src/main/java/blue/coordination/processor/workflow/StaticPayloadValidator.java +++ b/src/main/java/blue/coordination/processor/workflow/StaticPayloadValidator.java @@ -17,7 +17,7 @@ static boolean rejectBexOperators(FrozenNode node, StepExecutionContext context, return true; } - private static String firstBexOperatorPath(FrozenNode node, String path) { + static String firstBexOperatorPath(FrozenNode node, String path) { if (node == null) { return null; } diff --git a/src/main/java/blue/coordination/processor/workflow/StaticUpdatePlan.java b/src/main/java/blue/coordination/processor/workflow/StaticUpdatePlan.java new file mode 100644 index 0000000..fe3a85d --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/StaticUpdatePlan.java @@ -0,0 +1,231 @@ +package blue.coordination.processor.workflow; + +import blue.coordination.processor.bex.BexProcessingMetrics; +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.IdentityHashMap; +import java.util.List; +import java.util.Map; + +/** + * Immutable, scope-independent form of one literal Update Document changeset. + * + *

Authored pointers are resolved only when the step executes. Patch values + * remain frozen through the Language boundary. Resolved-construction fallback + * inputs are normalized once while the plan is compiled, never once per + * execution.

+ */ +final class StaticUpdatePlan { + private final List patches; + private final String validationFailure; + private final long approximateWeightBytes; + + private StaticUpdatePlan(List patches, + String validationFailure, + long approximateWeightBytes) { + this.patches = Collections.unmodifiableList(new ArrayList(patches)); + this.validationFailure = validationFailure; + this.approximateWeightBytes = approximateWeightBytes; + } + + static StaticUpdatePlan compile(FrozenNode changeset) { + return compile(changeset, null); + } + + static StaticUpdatePlan compile(FrozenNode changeset, BexProcessingMetrics metrics) { + String bexPath = StaticPayloadValidator.firstBexOperatorPath(changeset, ""); + if (bexPath != null) { + return invalid("Update Document changeset must be static; BEX operator object is not allowed at " + + bexPath); + } + if (changeset == null || changeset.getItems() == null) { + return invalid("Update Document changeset must be a static patch list"); + } + List templates = new ArrayList(changeset.getItems().size()); + long weight = 96L; + for (int index = 0; index < changeset.getItems().size(); index++) { + FrozenNode item = changeset.getItems().get(index); + Map properties = item != null ? item.getProperties() : null; + if (properties == null) { + return invalid("Update Document changeset entry " + index + + " must be a static patch object"); + } + ScalarText op = scalarText(properties.get("op")); + if (op.invalidType) { + return invalid("Update Document changeset entry " + index + + " field 'op' must be text"); + } + ScalarText path = scalarText(properties.get("path")); + if (path.invalidType) { + return invalid("Update Document changeset entry " + index + + " field 'path' must be text"); + } + if (op.value == null || op.value.trim().isEmpty()) { + return invalid("Update Document patch operation is required"); + } + if (path.value == null || path.value.trim().isEmpty()) { + return invalid("Update Document patch path is required"); + } + String normalizedOp = op.value.trim().toLowerCase(java.util.Locale.ROOT); + JsonPatch.Op patchOp; + if ("add".equals(normalizedOp)) { + patchOp = JsonPatch.Op.ADD; + } else if ("replace".equals(normalizedOp)) { + patchOp = JsonPatch.Op.REPLACE; + } else if ("remove".equals(normalizedOp)) { + patchOp = JsonPatch.Op.REMOVE; + } else { + return invalid("Unsupported Update Document patch operation: " + op.value); + } + FrozenNode value = null; + if (patchOp != JsonPatch.Op.REMOVE) { + value = properties.get("val"); + if (value == null) { + return invalid("Update Document patch value is required for operation: " + + op.value); + } + if (!value.isStrictCanonical()) { + // Compatibility-only boundary for callers that compiled the + // workflow graph in resolved construction mode. Preserve the + // exact visible authored shape and pay this conversion once. + value = FrozenNode.fromNode(value.toNode()); + if (metrics != null) { + metrics.addMetric("staticUpdateResolvedValueCanonicalizations", 1L); + } + } + } + templates.add(new PatchTemplate(patchOp, path.value, value)); + weight += 72L + stringWeight(path.value) + frozenWeight(value); + } + return new StaticUpdatePlan(templates, null, weight); + } + + private static StaticUpdatePlan invalid(String message) { + return new StaticUpdatePlan(Collections.emptyList(), message, 64L + stringWeight(message)); + } + + boolean valid() { + return validationFailure == null; + } + + String validationFailure() { + return validationFailure; + } + + List patches() { + return patches; + } + + long approximateWeightBytes() { + return approximateWeightBytes; + } + + private static ScalarText scalarText(FrozenNode node) { + if (node == null) { + return ScalarText.absent(); + } + Object value = node.getValue(); + if (value == null) { + return ScalarText.absent(); + } + return value instanceof String + ? ScalarText.value((String) value) + : ScalarText.invalid(); + } + + private static long stringWeight(String value) { + return value != null ? 40L + (long) value.length() * 2L : 0L; + } + + private static long frozenWeight(FrozenNode node) { + return frozenWeight(node, new IdentityHashMap()); + } + + private static long frozenWeight(FrozenNode node, + IdentityHashMap visited) { + if (node == null || visited.put(node, Boolean.TRUE) != null) { + return 0L; + } + long weight = 96L + + stringWeight(node.getName()) + + stringWeight(node.getDescription()) + + stringWeight(node.getReferenceBlueId()); + if (node.getValue() instanceof String) { + weight += stringWeight((String) node.getValue()); + } else if (node.getValue() != null) { + weight += 32L; + } + if (node.getItems() != null) { + weight += 16L + (long) node.getItems().size() * 8L; + for (FrozenNode item : node.getItems()) { + weight += frozenWeight(item, visited); + } + } + if (node.getProperties() != null) { + weight += 32L + (long) node.getProperties().size() * 40L; + for (Map.Entry entry : node.getProperties().entrySet()) { + weight += stringWeight(entry.getKey()) + frozenWeight(entry.getValue(), visited); + } + } + weight += frozenWeight(node.getType(), visited); + weight += frozenWeight(node.getItemType(), visited); + weight += frozenWeight(node.getKeyType(), visited); + weight += frozenWeight(node.getValueType(), visited); + return weight; + } + + static final class PatchTemplate { + private final JsonPatch.Op op; + private final String authoredPath; + private final FrozenNode value; + + private PatchTemplate(JsonPatch.Op op, String authoredPath, FrozenNode value) { + this.op = op; + this.authoredPath = authoredPath; + this.value = value; + } + + FrozenJsonPatch bind(String absolutePath) { + if (op == JsonPatch.Op.ADD) { + return FrozenJsonPatch.add(absolutePath, value); + } + if (op == JsonPatch.Op.REPLACE) { + return FrozenJsonPatch.replace(absolutePath, value); + } + return FrozenJsonPatch.remove(absolutePath); + } + + String authoredPath() { + return authoredPath; + } + } + + private static final class ScalarText { + private static final ScalarText ABSENT = new ScalarText(null, false); + private static final ScalarText INVALID = new ScalarText(null, true); + + private final String value; + private final boolean invalidType; + + private ScalarText(String value, boolean invalidType) { + this.value = value; + this.invalidType = invalidType; + } + + private static ScalarText absent() { + return ABSENT; + } + + private static ScalarText invalid() { + return INVALID; + } + + private static ScalarText value(String value) { + return new ScalarText(value, false); + } + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/StepExecutionContext.java b/src/main/java/blue/coordination/processor/workflow/StepExecutionContext.java index ec036eb..4898624 100644 --- a/src/main/java/blue/coordination/processor/workflow/StepExecutionContext.java +++ b/src/main/java/blue/coordination/processor/workflow/StepExecutionContext.java @@ -3,13 +3,11 @@ import blue.language.model.Node; import blue.language.processor.ProcessorExecutionContext; import blue.language.processor.WorkingDocument; +import blue.language.processor.model.FrozenJsonPatch; import blue.language.processor.model.JsonPatch; import blue.language.snapshot.FrozenNode; import blue.repo.coordination.SequentialWorkflow; import blue.repo.coordination.SequentialWorkflowStep; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -23,8 +21,8 @@ public final class StepExecutionContext { private final FrozenNode stepFrozenNode; private final FrozenNode currentContractFrozenNode; private final int stepIndex; - private final Map stepResults; - private final Set handledChangesetSteps; + private final WorkflowExecutionState.Snapshot workflowStateView; + private final StaticUpdatePlan staticUpdatePlan; private final Node eventRef; private WorkingDocument workingDocument; @@ -111,6 +109,51 @@ public StepExecutionContext(ProcessorExecutionContext processorContext, workingDocument); } + StepExecutionContext(ProcessorExecutionContext processorContext, + SequentialWorkflow workflow, + SequentialWorkflowStep step, + FrozenNode stepFrozenNode, + FrozenNode currentContractFrozenNode, + int stepIndex, + WorkflowExecutionState.Snapshot workflowStateView, + WorkingDocument workingDocument) { + this(processorContext, + workflow, + step, + null, + null, + stepFrozenNode, + currentContractFrozenNode, + stepIndex, + workflowStateView, + null, + true, + workingDocument); + } + + StepExecutionContext(ProcessorExecutionContext processorContext, + SequentialWorkflow workflow, + SequentialWorkflowStep step, + FrozenNode stepFrozenNode, + FrozenNode currentContractFrozenNode, + int stepIndex, + WorkflowExecutionState.Snapshot workflowStateView, + StaticUpdatePlan staticUpdatePlan, + WorkingDocument workingDocument) { + this(processorContext, + workflow, + step, + null, + null, + stepFrozenNode, + currentContractFrozenNode, + stepIndex, + workflowStateView, + staticUpdatePlan, + true, + workingDocument); + } + private StepExecutionContext(ProcessorExecutionContext processorContext, SequentialWorkflow workflow, SequentialWorkflowStep step, @@ -122,6 +165,32 @@ private StepExecutionContext(ProcessorExecutionContext processorContext, Map stepResults, Set handledChangesetSteps, WorkingDocument workingDocument) { + this(processorContext, + workflow, + step, + stepNode, + currentContractNode, + stepFrozenNode, + currentContractFrozenNode, + stepIndex, + WorkflowExecutionState.snapshotOf(stepResults, handledChangesetSteps), + null, + true, + workingDocument); + } + + private StepExecutionContext(ProcessorExecutionContext processorContext, + SequentialWorkflow workflow, + SequentialWorkflowStep step, + Node stepNode, + Node currentContractNode, + FrozenNode stepFrozenNode, + FrozenNode currentContractFrozenNode, + int stepIndex, + WorkflowExecutionState.Snapshot workflowStateView, + StaticUpdatePlan staticUpdatePlan, + boolean useSnapshotView, + WorkingDocument workingDocument) { if (processorContext == null) { throw new IllegalArgumentException("processorContext must not be null"); } @@ -136,10 +205,10 @@ private StepExecutionContext(ProcessorExecutionContext processorContext, this.stepFrozenNode = stepFrozenNode; this.currentContractFrozenNode = currentContractFrozenNode; this.stepIndex = stepIndex; - this.stepResults = Collections.unmodifiableMap(new LinkedHashMap( - stepResults != null ? stepResults : Collections.emptyMap())); - this.handledChangesetSteps = Collections.unmodifiableSet(new LinkedHashSet( - handledChangesetSteps != null ? handledChangesetSteps : Collections.emptySet())); + this.workflowStateView = workflowStateView != null + ? workflowStateView + : new WorkflowExecutionState().snapshotView(); + this.staticUpdatePlan = staticUpdatePlan; this.eventRef = processorContext.event(); this.workingDocument = workingDocument; } @@ -197,11 +266,15 @@ public int stepIndex() { } public Map stepResults() { - return stepResults; + return workflowStateView.results(); } boolean wasChangesetHandled(String stepKey) { - return stepKey != null && handledChangesetSteps.contains(stepKey); + return workflowStateView.wasChangesetHandled(stepKey); + } + + StaticUpdatePlan staticUpdatePlan() { + return staticUpdatePlan; } public Node event() { @@ -245,4 +318,16 @@ WorkingDocument.Preview advanceWorkingDocument(List patches) { return null; } } + + WorkingDocument.Preview advanceWorkingDocumentFrozen(List patches) { + if (patches == null || patches.isEmpty()) { + return null; + } + try { + return workingDocument().previewAndApplyFrozenPatches(patches); + } catch (RuntimeException ex) { + processorContext.throwFatal("Working document preview failed: " + ex.getMessage()); + return null; + } + } } diff --git a/src/main/java/blue/coordination/processor/workflow/UpdateDocumentStepExecutor.java b/src/main/java/blue/coordination/processor/workflow/UpdateDocumentStepExecutor.java index 3662e68..976923a 100644 --- a/src/main/java/blue/coordination/processor/workflow/UpdateDocumentStepExecutor.java +++ b/src/main/java/blue/coordination/processor/workflow/UpdateDocumentStepExecutor.java @@ -3,7 +3,7 @@ import blue.coordination.processor.bex.BexProcessingMetrics; import blue.language.model.Node; import blue.language.processor.WorkingDocument; -import blue.language.processor.model.JsonPatch; +import blue.language.processor.model.FrozenJsonPatch; import blue.language.snapshot.FrozenNode; import blue.language.utils.MergeReverser; import blue.repo.coordination.SequentialWorkflowStep; @@ -11,6 +11,7 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; +import java.util.Locale; public final class UpdateDocumentStepExecutor implements WorkflowStepExecutor { private final BexProcessingMetrics metrics; @@ -35,6 +36,14 @@ public WorkflowStepResult execute(UpdateDocument step, StepExecutionContext cont if (metrics != null) { metrics.incrementUpdateDocumentStepsExecuted(); } + StaticUpdatePlan staticPlan = context.staticUpdatePlan(); + if (staticPlan != null) { + if (metrics != null) { + metrics.incrementUpdateStaticTemplateHits(); + } + applyStaticPlan(staticPlan, context); + return WorkflowStepResult.none(); + } FrozenNode rawFrozenChangeset = FrozenNodeUtil.property(context.stepFrozenNode(), "changeset"); if (StaticPayloadValidator.rejectBexOperators(rawFrozenChangeset, context, @@ -52,7 +61,7 @@ public WorkflowStepResult execute(UpdateDocument step, StepExecutionContext cont return WorkflowStepResult.none(); } long conversionStart = System.nanoTime(); - List patches = new ArrayList(changeset.size()); + List patches = new ArrayList(changeset.size()); for (WorkflowPatchEntry entry : changeset) { patches.add(toPatch(entry, context)); } @@ -106,10 +115,24 @@ private WorkflowPatchEntry literalPatchEntry(Object item, int index, StepExecuti return literalPatchEntry((Node) item, index, context); } try { + if (metrics != null) { + metrics.incrementUpdateReflectionFallbacks(); + } String op = (String) invokeNoArg(item, "getOp"); String path = (String) invokeNoArg(item, "getPath"); - Node val = (Node) invokeNoArg(item, "getVal"); - return new WorkflowPatchEntry(op, path, val); + if (isRemove(op)) { + return new WorkflowPatchEntry(op, path, (FrozenNode) null); + } + Object val = invokeNoArg(item, "getVal"); + if (val == null || val instanceof Node) { + return new WorkflowPatchEntry(op, path, (Node) val); + } + if (val instanceof FrozenNode) { + return new WorkflowPatchEntry(op, path, (FrozenNode) val); + } + context.processorContext().throwFatal("Update Document changeset entry " + index + + " field 'val' must be a node"); + return null; } catch (ReflectiveOperationException ex) { context.processorContext().throwFatal("Update Document changeset entry " + index + " cannot be read as a patch entry: " + ex.getMessage()); @@ -129,8 +152,8 @@ private WorkflowPatchEntry literalPatchEntry(Node item, int index, StepExecution } String op = stringProperty(item, "op", index, context); String path = stringProperty(item, "path", index, context); - Node val = item.getProperties().get("val"); - return new WorkflowPatchEntry(op, path, val != null ? val.clone() : null); + Node val = isRemove(op) ? null : item.getProperties().get("val"); + return new WorkflowPatchEntry(op, path, val); } private String stringProperty(Node item, String key, int index, StepExecutionContext context) { @@ -152,7 +175,7 @@ private Object invokeNoArg(Object target, String methodName) throws ReflectiveOp return method.invoke(target); } - private JsonPatch toPatch(WorkflowPatchEntry entry, StepExecutionContext context) { + private FrozenJsonPatch toPatch(WorkflowPatchEntry entry, StepExecutionContext context) { if (entry == null) { context.processorContext().throwFatal("Update Document changeset contains a null patch entry"); return null; @@ -168,36 +191,56 @@ private JsonPatch toPatch(WorkflowPatchEntry entry, StepExecutionContext context return null; } String absolutePath = context.processorContext().resolvePointer(path); - String normalizedOp = op.trim().toLowerCase(); + String normalizedOp = op.trim().toLowerCase(Locale.ROOT); if ("remove".equals(normalizedOp)) { - return JsonPatch.remove(absolutePath); + return FrozenJsonPatch.remove(absolutePath); } - Node value = entry.val(); + FrozenNode value = entry.val(); if (value == null) { context.processorContext().throwFatal("Update Document patch value is required for operation: " + op); return null; } if ("add".equals(normalizedOp)) { - return JsonPatch.add(absolutePath, value); + return FrozenJsonPatch.add(absolutePath, value); } if ("replace".equals(normalizedOp)) { - return JsonPatch.replace(absolutePath, value); + return FrozenJsonPatch.replace(absolutePath, value); } context.processorContext().throwFatal("Unsupported Update Document patch operation: " + op); return null; } - private void applyPatches(List patches, StepExecutionContext context) { + private static boolean isRemove(String op) { + return op != null && "remove".equals(op.trim().toLowerCase(Locale.ROOT)); + } + + private void applyPatches(List patches, StepExecutionContext context) { if (patches == null || patches.isEmpty()) { return; } long applyStart = System.nanoTime(); boolean applied = false; + boolean previewTransferred = false; + WorkingDocument.Preview preview = null; try { - WorkingDocument.Preview preview = context.advanceWorkingDocument(patches); - context.processorContext().applyPreviewedPatches(patches, preview); + preview = context.advanceWorkingDocumentFrozen(patches); + if (preview == null) { + return; + } + if (metrics != null) { + metrics.addMetric("frozenPatchValuesHandedToLanguage", valuePatchCount(patches)); + } + context.processorContext().applyPreviewedFrozenPatches(patches, preview); + previewTransferred = true; + if (metrics != null) { + metrics.addMetric("frozenPatchesHandedToLanguage", patches.size()); + metrics.addMetric("frozenPatchValuesHandedToLanguage", valuePatchCount(patches)); + } applied = true; } finally { + if (!previewTransferred && preview != null) { + preview.close(); + } if (metrics != null) { metrics.addUpdatePatchApplyNanos(System.nanoTime() - applyStart); if (applied) { @@ -207,4 +250,30 @@ private void applyPatches(List patches, StepExecutionContext context) } } } + + private static long valuePatchCount(List patches) { + long count = 0L; + for (FrozenJsonPatch patch : patches) { + if (patch != null && patch.getOp() != blue.language.processor.model.JsonPatch.Op.REMOVE) { + count++; + } + } + return count; + } + + private void applyStaticPlan(StaticUpdatePlan plan, StepExecutionContext context) { + if (plan.patches().isEmpty()) { + return; + } + long conversionStart = System.nanoTime(); + List patches = new ArrayList(plan.patches().size()); + for (StaticUpdatePlan.PatchTemplate template : plan.patches()) { + String absolutePath = context.processorContext().resolvePointer(template.authoredPath()); + patches.add(template.bind(absolutePath)); + } + if (metrics != null) { + metrics.addUpdatePatchConversionNanos(System.nanoTime() - conversionStart); + } + applyPatches(patches, context); + } } diff --git a/src/main/java/blue/coordination/processor/workflow/WorkflowExecutionState.java b/src/main/java/blue/coordination/processor/workflow/WorkflowExecutionState.java new file mode 100644 index 0000000..87bde74 --- /dev/null +++ b/src/main/java/blue/coordination/processor/workflow/WorkflowExecutionState.java @@ -0,0 +1,209 @@ +package blue.coordination.processor.workflow; + +import java.util.AbstractMap; +import java.util.AbstractSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; + +/** + * Per-workflow append-only result state. + * + *

Each read-only view captures a revision rather than copying all preceding + * entries. This keeps workflow allocation linear while retaining the stable + * snapshot behavior previously provided by {@code StepExecutionContext}.

+ */ +final class WorkflowExecutionState { + private final List orderedSlots = new ArrayList(); + private final Map slotsByKey = new HashMap(); + private final Map firstHandledRevision = new HashMap(); + private long revision; + + WorkflowExecutionState() { + } + + static Snapshot snapshotOf(Map results, Set handledChangesets) { + WorkflowExecutionState state = new WorkflowExecutionState(); + if (results != null) { + for (Map.Entry entry : results.entrySet()) { + state.record(entry.getKey(), entry.getValue(), false); + } + } + if (handledChangesets != null) { + for (String key : handledChangesets) { + state.markHandledAtCurrentRevision(key); + } + } + return state.snapshotView(); + } + + Snapshot snapshotView() { + return new Snapshot(this, revision, orderedSlots.size()); + } + + void record(String key, Object value, boolean changesetHandled) { + revision++; + ResultSlot slot = slotsByKey.get(key); + if (slot == null) { + slot = new ResultSlot(key); + slotsByKey.put(key, slot); + orderedSlots.add(slot); + } + slot.latest = new ResultVersion(revision, value, slot.latest); + if (changesetHandled && !firstHandledRevision.containsKey(key)) { + firstHandledRevision.put(key, Long.valueOf(revision)); + } + } + + private void markHandledAtCurrentRevision(String key) { + if (!firstHandledRevision.containsKey(key)) { + firstHandledRevision.put(key, Long.valueOf(revision)); + } + } + + private Object valueAt(String key, long visibleRevision) { + ResultSlot slot = slotsByKey.get(key); + if (slot == null) { + return null; + } + ResultVersion version = slot.latest; + while (version != null && version.revision > visibleRevision) { + version = version.previous; + } + return version != null ? version.value : null; + } + + private boolean containsAt(String key, long visibleRevision) { + ResultSlot slot = slotsByKey.get(key); + if (slot == null) { + return false; + } + ResultVersion version = slot.latest; + while (version != null && version.revision > visibleRevision) { + version = version.previous; + } + return version != null; + } + + private boolean wasChangesetHandledAt(String key, long visibleRevision) { + Long handledRevision = firstHandledRevision.get(key); + return handledRevision != null && handledRevision.longValue() <= visibleRevision; + } + + static final class Snapshot extends AbstractMap { + private final WorkflowExecutionState state; + private final long visibleRevision; + private final int visibleSlotCount; + private final Set> entries; + private final Map readOnlyResults; + + private Snapshot(WorkflowExecutionState state, + long visibleRevision, + int visibleSlotCount) { + this.state = state; + this.visibleRevision = visibleRevision; + this.visibleSlotCount = visibleSlotCount; + this.entries = new SnapshotEntrySet(); + this.readOnlyResults = Collections.unmodifiableMap(this); + } + + Map results() { + return readOnlyResults; + } + + boolean wasChangesetHandled(String stepKey) { + return stepKey != null && state.wasChangesetHandledAt(stepKey, visibleRevision); + } + + @Override + public Object get(Object key) { + if (key != null && !(key instanceof String)) { + return null; + } + return state.valueAt((String) key, visibleRevision); + } + + @Override + public boolean containsKey(Object key) { + if (key != null && !(key instanceof String)) { + return false; + } + return state.containsAt((String) key, visibleRevision); + } + + @Override + public int size() { + return visibleSlotCount; + } + + @Override + public Set> entrySet() { + return entries; + } + + private final class SnapshotEntrySet extends AbstractSet> { + @Override + public Iterator> iterator() { + return new Iterator>() { + private int index; + + @Override + public boolean hasNext() { + return index < visibleSlotCount; + } + + @Override + public Map.Entry next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + ResultSlot slot = state.orderedSlots.get(index++); + return new SimpleImmutableEntry(slot.key, + state.valueAt(slot.key, visibleRevision)); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("workflow state is read-only"); + } + }; + } + + @Override + public int size() { + return visibleSlotCount; + } + + @Override + public void clear() { + throw new UnsupportedOperationException("workflow state is read-only"); + } + } + } + + private static final class ResultSlot { + private final String key; + private ResultVersion latest; + + private ResultSlot(String key) { + this.key = key; + } + } + + private static final class ResultVersion { + private final long revision; + private final Object value; + private final ResultVersion previous; + + private ResultVersion(long revision, Object value, ResultVersion previous) { + this.revision = revision; + this.value = value; + this.previous = previous; + } + } +} diff --git a/src/main/java/blue/coordination/processor/workflow/WorkflowPatchEntry.java b/src/main/java/blue/coordination/processor/workflow/WorkflowPatchEntry.java index f4f4e22..4dc9c82 100644 --- a/src/main/java/blue/coordination/processor/workflow/WorkflowPatchEntry.java +++ b/src/main/java/blue/coordination/processor/workflow/WorkflowPatchEntry.java @@ -1,16 +1,25 @@ package blue.coordination.processor.workflow; import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; + +import java.util.Locale; final class WorkflowPatchEntry { private final String op; private final String path; - private final Node val; + private final FrozenNode val; WorkflowPatchEntry(String op, String path, Node val) { this.op = op; this.path = path; - this.val = val; + this.val = isRemove(op) || val == null ? null : FrozenNode.fromNode(val); + } + + WorkflowPatchEntry(String op, String path, FrozenNode val) { + this.op = op; + this.path = path; + this.val = isRemove(op) ? null : canonicalSnapshot(val); } String op() { @@ -21,7 +30,18 @@ String path() { return path; } - Node val() { + FrozenNode val() { return val; } + + private static FrozenNode canonicalSnapshot(FrozenNode value) { + if (value == null || value.isStrictCanonical()) { + return value; + } + return FrozenNode.fromNode(value.toNode()); + } + + private static boolean isRemove(String op) { + return op != null && "remove".equals(op.trim().toLowerCase(Locale.ROOT)); + } } diff --git a/src/test/java/blue/coordination/processor/CoordinationProcessorsTest.java b/src/test/java/blue/coordination/processor/CoordinationProcessorsTest.java index 7b83f03..ef3d123 100644 --- a/src/test/java/blue/coordination/processor/CoordinationProcessorsTest.java +++ b/src/test/java/blue/coordination/processor/CoordinationProcessorsTest.java @@ -1,5 +1,6 @@ package blue.coordination.processor; +import blue.coordination.processor.bex.BexProcessingMetrics; import blue.language.Blue; import blue.language.model.Node; import blue.language.processor.ContractProcessorRegistry; @@ -29,6 +30,7 @@ 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.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; class CoordinationProcessorsTest { @@ -48,6 +50,113 @@ void configureBuilderRegistersCoordinationProcessors() { assertCoordinationProcessorsRegistered(processor); } + @Test + void registerWithBlueInstallsOptionsMetricsAsLanguageSink() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + Blue blue = CoordinationTestResources.configuredBlue(BlueRepository.latest()); + + CoordinationProcessors.registerWith(blue, CoordinationProcessorOptions.builder() + .processingMetrics(metrics) + .build()); + + assertSame(metrics, blue.getDocumentProcessor().processingMetricsSink()); + } + + @Test + void registerWithBluePreservesAndFansOutToIndependentLanguageSink() { + BexProcessingMetrics existing = new BexProcessingMetrics(); + BexProcessingMetrics coordination = new BexProcessingMetrics(); + Blue blue = CoordinationTestResources.configuredBlue(BlueRepository.latest()); + blue.getDocumentProcessor().processingMetricsSink(existing); + + CoordinationProcessors.registerWith(blue, CoordinationProcessorOptions.builder() + .processingMetrics(coordination) + .build()); + blue.getDocumentProcessor().processingMetricsSink().incrementPatchSequencesPrepared(); + blue.getDocumentProcessor().processingMetricsSink().addPatchesPrepared(3L); + + assertEquals(1L, existing.preparedPatchSequences()); + assertEquals(3L, existing.preparedPatches()); + assertEquals(1L, coordination.preparedPatchSequences()); + assertEquals(3L, coordination.preparedPatches()); + } + + @Test + void registerWithBlueFansOutGenericLanguageMetricsToBothSinks() { + BexProcessingMetrics existing = new BexProcessingMetrics(); + BexProcessingMetrics coordination = new BexProcessingMetrics(); + Blue blue = CoordinationTestResources.configuredBlue(BlueRepository.latest()); + blue.getDocumentProcessor().processingMetricsSink(existing); + + CoordinationProcessors.registerWith(blue, CoordinationProcessorOptions.builder() + .processingMetrics(coordination) + .build()); + blue.getDocumentProcessor().processingMetricsSink() + .incrementFullSnapshotFallback("compositeTest"); + blue.getDocumentProcessor().processingMetricsSink() + .incrementNodeCloneCalls("compositeTest"); + blue.getDocumentProcessor().processingMetricsSink() + .setCacheEntries("compositeTest", 4L); + blue.getDocumentProcessor().processingMetricsSink() + .recordCacheHighWaterBytes("compositeTest", 12L); + + assertEquals(existing.languageCounters(), coordination.languageCounters()); + assertEquals(existing.languageGauges(), coordination.languageGauges()); + assertEquals(existing.languageHighWaterMarks(), coordination.languageHighWaterMarks()); + assertEquals(1L, existing.languageCounters().get("fullSnapshotFallbacks")); + assertEquals(1L, existing.languageCounters() + .get("fullSnapshotFallbackReason.compositeTest")); + assertEquals(1L, existing.languageCounters() + .get("nodeCloneCallsByPurpose.compositeTest")); + assertEquals(4L, existing.languageGauges().get("cache.compositeTest.entries")); + assertEquals(12L, existing.languageHighWaterMarks() + .get("cache.compositeTest.highWaterBytes")); + } + + @Test + void configureBuilderInstallsOptionsMetricsAsLanguageSink() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + DocumentProcessor processor = CoordinationProcessors.configure( + DocumentProcessor.builder(), + CoordinationProcessorOptions.builder().processingMetrics(metrics).build()) + .build(); + + assertSame(metrics, processor.processingMetricsSink()); + } + + @Test + void optionsMetricsReceiveRealLanguageMultiPatchSequenceCallbacks() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + Fixture fixture = configuredFixture(CoordinationProcessorOptions.builder() + .processingMetrics(metrics) + .build()); + Node preprocessed = fixture.blue.preprocess(multiPatchCounterDocument(fixture.repository)); + DocumentProcessingResult initialized = fixture.blue.initializeDocument(preprocessed); + BexProcessingMetrics.Snapshot before = metrics.snapshot(); + + DocumentProcessingResult processed = fixture.blue.processDocument(initialized.document(), + TestTimelineProvider.timelineEntry(fixture.blue, + fixture.repository, + "owner", + 1, + CoordinationTestResources.operationRequest( + "increment", "ownerChannel", new Node().value(7)))); + BexProcessingMetrics.Snapshot after = metrics.snapshot(); + + assertFalse(processed.capabilityFailure(), processed.failureReason()); + assertEquals(BigInteger.valueOf(3), processed.document().get("/counter")); + assertEquals(1L, after.preparedPatchSequences - before.preparedPatchSequences); + assertEquals(3L, after.preparedPatches - before.preparedPatches); + assertEquals(1L, after.languageSequenceTransactions - before.languageSequenceTransactions); + assertEquals(0L, after.languageSingletonTransactions - before.languageSingletonTransactions); + assertEquals(0L, after.languageSuffixRebases - before.languageSuffixRebases); + assertEquals(0L, after.languageFallbackPatches - before.languageFallbackPatches); + assertEquals(2L, after.languageIntermediateSnapshotAdvances + - before.languageIntermediateSnapshotAdvances); + assertEquals(1L, after.languageFinalSnapshotPromotions + - before.languageFinalSnapshotPromotions); + } + @Test void realRepositoryCoordinationContractsLoadAndInitialize() { Fixture fixture = configuredFixture(); @@ -141,12 +250,44 @@ private static void assertCoordinationProcessorsRegistered(DocumentProcessor pro } private static Fixture configuredFixture() { + return configuredFixture(null); + } + + private static Fixture configuredFixture(CoordinationProcessorOptions options) { BlueRepository repository = BlueRepository.latest(); Blue blue = CoordinationTestResources.configuredBlue(repository); - CoordinationProcessors.registerWith(blue); + CoordinationProcessors.registerWith(blue, options); return new Fixture(repository, blue); } + private static Node multiPatchCounterDocument(BlueRepository repository) { + Node update = new Node() + .type("Coordination/Update Document") + .properties("changeset", new Node().items( + replacePatch("/counter", 1), + replacePatch("/counter", 2), + replacePatch("/counter", 3))); + Map contracts = new LinkedHashMap<>(); + contracts.put("ownerChannel", TestTimelineProvider.channel("owner")); + contracts.put("increment", new Node() + .type("Coordination/Sequential Workflow Operation") + .properties("channel", new Node().value("ownerChannel")) + .properties("request", new Node().type("Integer")) + .properties("steps", new Node().items(update))); + return new Node() + .blue(repository.typeAliasBlue()) + .name("MultiPatchCounter") + .properties("counter", new Node().value(0)) + .properties("contracts", new Node().properties(contracts)); + } + + private static Node replacePatch(String path, int value) { + return new Node() + .properties("op", new Node().value("replace")) + .properties("path", new Node().value(path)) + .properties("val", new Node().value(value)); + } + private static Node counterDocument(BlueRepository repository, String operationChannel) { Map contracts = new LinkedHashMap<>(); contracts.put("ownerChannel", TestTimelineProvider.channel("owner")); diff --git a/src/test/java/blue/coordination/processor/SequentialWorkflowExecutionTest.java b/src/test/java/blue/coordination/processor/SequentialWorkflowExecutionTest.java index 1e6abb2..7cfb201 100644 --- a/src/test/java/blue/coordination/processor/SequentialWorkflowExecutionTest.java +++ b/src/test/java/blue/coordination/processor/SequentialWorkflowExecutionTest.java @@ -21,6 +21,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -342,6 +343,50 @@ public WorkflowStepResult execute(TriggerEvent step, StepExecutionContext contex assertEquals(Boolean.TRUE, sawNullResult.get()); } + @Test + void workflowPlanReusesExactContractAndReplansChangedContract() { + AtomicInteger supportsCalls = new AtomicInteger(); + WorkflowStepExecutor executor = new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + supportsCalls.incrementAndGet(); + return step instanceof TriggerEvent; + } + + @Override + public WorkflowStepResult execute(TriggerEvent step, StepExecutionContext context) { + return WorkflowStepResult.none(); + } + }; + SequentialWorkflowRunner runner = new SequentialWorkflowRunner( + Arrays.>asList(executor)); + Fixture fixture = configuredFixture(null, runner); + + Node first = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, + 0, + "same contract", + triggerEventStep("ignored"))); + Node afterFirst = processChat(fixture, first, "owner", 1, "run").document(); + processChat(fixture, afterFirst, "owner", 2, "run"); + Node equivalent = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, + 0, + "same contract", + triggerEventStep("ignored"))); + processChat(fixture, equivalent, "owner", 1, "run"); + Node changed = initializedDocument(fixture, directWorkflowStepsDocument(fixture.repository, + 0, + "changed contract", + triggerEventStep("ignored"))); + processChat(fixture, changed, "owner", 1, "run"); + + assertEquals(2, supportsCalls.get()); + assertEquals(2, runner.workflowPlanCacheSize()); + assertTrue(runner.workflowPlanCacheWeightBytes() > 0L); + runner.clearCaches(); + assertEquals(0, runner.workflowPlanCacheSize()); + assertEquals(0L, runner.workflowPlanCacheWeightBytes()); + } + private static Node processOperationRequest(Fixture fixture, Node document, String timelineId, diff --git a/src/test/java/blue/coordination/processor/Task9PublishedArtifactTest.java b/src/test/java/blue/coordination/processor/Task9PublishedArtifactTest.java index 85159c1..0b0515e 100644 --- a/src/test/java/blue/coordination/processor/Task9PublishedArtifactTest.java +++ b/src/test/java/blue/coordination/processor/Task9PublishedArtifactTest.java @@ -26,7 +26,7 @@ class Task9PublishedArtifactTest { private static final String TERMINATE_PROCESSING_BLUE_ID = "DacNQ6C6PgsEiE4QfUHmaWBztpEvo2YyXxUcP86ze77w"; private static final String CONTRACTS_FIXTURE_IDENTITY = - "sha256:22713df4d50a38b91762aea1e1a360019c1d16c2584ca1bac022305edb4c66d1"; + "sha256:e6d4895fa007837aa1b54a92d1e4cb3133e2e7daa5cc5bb22e7443b6009a244a"; @Test void repositoryRc10ContainsGeneratedTerminateProcessingContract() { diff --git a/src/test/java/blue/coordination/processor/bex/BexProcessingMetricsTest.java b/src/test/java/blue/coordination/processor/bex/BexProcessingMetricsTest.java index c75c4ba..d08f857 100644 --- a/src/test/java/blue/coordination/processor/bex/BexProcessingMetricsTest.java +++ b/src/test/java/blue/coordination/processor/bex/BexProcessingMetricsTest.java @@ -2,10 +2,130 @@ import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; class BexProcessingMetricsTest { + @Test + void genericLanguageMetricsAreThreadSafeSortedAndImmutable() throws Exception { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + int workers = 8; + int additionsPerWorker = 2_000; + ExecutorService executor = Executors.newFixedThreadPool(workers); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + try { + for (int worker = 0; worker < workers; worker++) { + futures.add(executor.submit(() -> { + start.await(); + for (int addition = 0; addition < additionsPerWorker; addition++) { + metrics.addMetric("concurrent.additions", 1L); + } + return null; + })); + } + start.countDown(); + for (Future future : futures) { + future.get(); + } + } finally { + executor.shutdownNow(); + } + + metrics.addMetric("alpha", 2L); + metrics.addMetric("zulu", 3L); + metrics.setMetric("cache.plan.entries", 1L); + metrics.setMetric("cache.plan.entries", 7L); + metrics.recordMetricHighWater("cache.plan.highWaterBytes", 5L); + metrics.recordMetricHighWater("cache.plan.highWaterBytes", 11L); + metrics.recordMetricHighWater("cache.plan.highWaterBytes", 9L); + + BexProcessingMetrics.Snapshot snapshot = metrics.snapshot(); + assertEquals((long) workers * additionsPerWorker, + snapshot.languageCounters.get("concurrent.additions")); + assertEquals(7L, snapshot.languageGauges.get("cache.plan.entries")); + assertEquals(11L, + snapshot.languageHighWaterMarks.get("cache.plan.highWaterBytes")); + assertEquals(Arrays.asList("alpha", "concurrent.additions", "zulu"), + new ArrayList<>(snapshot.languageCounters.keySet())); + + assertThrows(UnsupportedOperationException.class, + () -> snapshot.languageCounters.put("later", 1L)); + assertThrows(UnsupportedOperationException.class, + () -> snapshot.languageGauges.clear()); + assertThrows(UnsupportedOperationException.class, + () -> snapshot.languageHighWaterMarks.remove("cache.plan.highWaterBytes")); + + metrics.addMetric("alpha", 5L); + metrics.setMetric("cache.plan.entries", 9L); + metrics.recordMetricHighWater("cache.plan.highWaterBytes", 13L); + assertEquals(2L, snapshot.languageCounters.get("alpha")); + assertEquals(7L, snapshot.languageGauges.get("cache.plan.entries")); + assertEquals(11L, + snapshot.languageHighWaterMarks.get("cache.plan.highWaterBytes")); + } + + @Test + void genericLanguageMetricsRetainSuffixesAndCacheMetricKinds() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + + metrics.incrementFullSnapshotFallback("stalePreview"); + metrics.incrementNodeCloneCalls("patchValue"); + metrics.incrementNodeCloneCalls("patchValue"); + metrics.incrementCacheHits("processingSnapshot"); + metrics.setCacheCurrentWeightBytes("processingSnapshot", 40L); + metrics.setCacheEntries("processingSnapshot", 3L); + metrics.recordCacheHighWaterBytes("processingSnapshot", 40L); + metrics.recordCacheHighWaterBytes("processingSnapshot", 35L); + metrics.recordCacheHighWaterBytes("processingSnapshot", 52L); + + Map counters = metrics.languageCounters(); + assertEquals(1L, counters.get("fullSnapshotFallbacks")); + assertEquals(1L, counters.get("fullSnapshotFallbackReason.stalePreview")); + assertEquals(2L, counters.get("nodeCloneCallsByPurpose.patchValue")); + assertEquals(1L, counters.get("cache.processingSnapshot.hits")); + assertEquals(40L, + metrics.languageGauges().get("cache.processingSnapshot.currentWeightBytes")); + assertEquals(3L, + metrics.languageGauges().get("cache.processingSnapshot.entries")); + assertEquals(52L, metrics.languageHighWaterMarks() + .get("cache.processingSnapshot.highWaterBytes")); + } + + @Test + void genericLanguageMetricNamesAreCappedAcrossMetricKinds() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + for (int index = 0; index < BexProcessingMetrics.MAX_LANGUAGE_METRIC_NAMES; index++) { + metrics.addMetric("bounded." + index, 1L); + } + + metrics.setMetric("bounded.0", 7L); + metrics.setMetric("overflow.gauge", 9L); + metrics.recordMetricHighWater("overflow.highWater", 11L); + metrics.addMetric("overflow.counter", 1L); + BexProcessingMetrics.Snapshot snapshot = metrics.snapshot(); + + assertEquals(BexProcessingMetrics.MAX_LANGUAGE_METRIC_NAMES, + snapshot.languageCounters.size()); + assertEquals(7L, snapshot.languageGauges.get("bounded.0")); + assertFalse(snapshot.languageGauges.containsKey("overflow.gauge")); + assertFalse(snapshot.languageHighWaterMarks.containsKey("overflow.highWater")); + assertFalse(snapshot.languageCounters.containsKey("overflow.counter")); + assertEquals(3L, snapshot.droppedLanguageMetricNames); + assertEquals(3L, metrics.droppedLanguageMetricNames()); + } + @Test void bexProcessingMetricsExposeProcessEventSnapshotCounters() { BexProcessingMetrics metrics = new BexProcessingMetrics(); @@ -57,6 +177,107 @@ void terminationMetricsAreBoundedCountersAndSnapshotsAreImmutable() { assertTerminationSnapshot(second, 2L); } + @Test + void languageSequenceCallbacksExposeProofAliasesAndImmutableSnapshots() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + metrics.incrementPatchSequencesPrepared(); + metrics.addPatchesPrepared(3L); + metrics.incrementSingletonPatchTransactions(); + metrics.addSequencePlanningNanos(-1L); + metrics.addSequenceConformanceNanos(2L); + metrics.addSequenceCommitNanos(3L); + metrics.addSequenceFinalCacheCommitNanos(4L); + metrics.incrementSequenceIntermediateSnapshotAdvances(); + metrics.incrementSequenceSharedSnapshotCacheInserts(); + metrics.incrementSequenceFinalSnapshotCacheInserts(); + metrics.incrementSequenceSuffixRebases(); + metrics.incrementSequenceStalePreviewFallbacks(); + metrics.incrementSequenceFallbackPatches(); + metrics.incrementParsedPointerCacheHits(); + metrics.incrementParsedPointerCacheMisses(); + metrics.incrementFrozenPatchValueHits(); + metrics.incrementPatchValueMaterializations(); + BexProcessingMetrics.Snapshot first = metrics.snapshot(); + + metrics.incrementPatchSequencesPrepared(); + metrics.addPatchesPrepared(2L); + metrics.incrementSequenceFinalSnapshotCacheInserts(); + metrics.incrementPatchValueMaterializations(); + BexProcessingMetrics.Snapshot second = metrics.snapshot(); + + assertLanguageSnapshot(first, 1L, 3L, 1L, 1L); + assertEquals(0L, first.sequencePlanningNanos); + assertEquals(2L, first.sequenceConformanceNanos); + assertEquals(3L, first.sequenceCommitNanos); + assertEquals(4L, first.sequenceFinalCacheCommitNanos); + assertEquals(1L, first.languageIntermediateSnapshotAdvances); + assertEquals(1L, first.sequenceSharedSnapshotCacheInserts); + assertEquals(1L, first.languageSuffixRebases); + assertEquals(1L, first.sequenceStalePreviewFallbacks); + assertEquals(1L, first.languageFallbackPatches); + assertEquals(1L, first.parsedPointerCacheHits); + assertEquals(1L, first.parsedPointerCacheMisses); + assertEquals(1L, first.frozenPatchValueHits); + assertLanguageSnapshot(second, 2L, 5L, 2L, 2L); + } + + @Test + void planAndConversionMetricsUseImmutableSnapshotsAndNonNegativeWeightGauges() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + metrics.incrementWorkflowPlansBuilt(); + metrics.incrementWorkflowPlanCacheHits(); + metrics.incrementWorkflowPlanCacheMisses(); + metrics.incrementWorkflowPlanCacheEvictions(); + metrics.addWorkflowPlanWeightBytes(100L); + metrics.incrementWorkflowExecutorLookups(); + metrics.incrementWorkflowStepResultSnapshotsCreated(); + metrics.incrementWorkflowStepResultViewHits(); + metrics.incrementComputePlansBuilt(); + metrics.incrementComputePlanCacheHits(); + metrics.incrementComputePlanCacheMisses(); + metrics.incrementComputePlanCacheEvictions(); + metrics.addComputePlanWeightBytes(200L); + metrics.incrementComputeDefinitionMaterializations(); + metrics.incrementComputeDefinitionFrozenDirectHits(); + metrics.incrementComputeProgramSourceBuilds(); + metrics.incrementBexPatchFrozenDirectConversions(); + metrics.incrementBexPatchNodeMaterializations(); + metrics.incrementUpdateStaticTemplatesBuilt(); + metrics.incrementUpdateStaticTemplateHits(); + metrics.incrementUpdateReflectionFallbacks(); + BexProcessingMetrics.Snapshot first = metrics.snapshot(); + + metrics.addWorkflowPlanWeightBytes(-150L); + metrics.addComputePlanWeightBytes(-50L); + metrics.incrementWorkflowPlansBuilt(); + BexProcessingMetrics.Snapshot second = metrics.snapshot(); + + assertEquals(1L, first.workflowPlansBuilt); + assertEquals(1L, first.workflowPlanCacheHits); + assertEquals(1L, first.workflowPlanCacheMisses); + assertEquals(1L, first.workflowPlanCacheEvictions); + assertEquals(100L, first.workflowPlanWeightBytes); + assertEquals(1L, first.workflowExecutorLookups); + assertEquals(1L, first.workflowStepResultSnapshotsCreated); + assertEquals(1L, first.workflowStepResultViewHits); + assertEquals(1L, first.computePlansBuilt); + assertEquals(1L, first.computePlanCacheHits); + assertEquals(1L, first.computePlanCacheMisses); + assertEquals(1L, first.computePlanCacheEvictions); + assertEquals(200L, first.computePlanWeightBytes); + assertEquals(1L, first.computeDefinitionMaterializations); + assertEquals(1L, first.computeDefinitionFrozenDirectHits); + assertEquals(1L, first.computeProgramSourceBuilds); + assertEquals(1L, first.bexPatchFrozenDirectConversions); + assertEquals(1L, first.bexPatchNodeMaterializations); + assertEquals(1L, first.updateStaticTemplatesBuilt); + assertEquals(1L, first.updateStaticTemplateHits); + assertEquals(1L, first.updateReflectionFallbacks); + assertEquals(2L, second.workflowPlansBuilt); + assertEquals(0L, second.workflowPlanWeightBytes); + assertEquals(150L, second.computePlanWeightBytes); + } + private static void assertSnapshot(BexProcessingMetrics.Snapshot snapshot, long attempts, long builds, @@ -74,4 +295,19 @@ private static void assertTerminationSnapshot(BexProcessingMetrics.Snapshot snap assertEquals(expected, snapshot.declarativeTerminationSteps); assertEquals(expected, snapshot.computeResultValidationFailures); } + + private static void assertLanguageSnapshot(BexProcessingMetrics.Snapshot snapshot, + long sequences, + long patches, + long finalPromotions, + long materializations) { + assertEquals(sequences, snapshot.preparedPatchSequences); + assertEquals(patches, snapshot.preparedPatches); + assertEquals(sequences, snapshot.languageSequenceTransactions, + "the prepared-session count is the closest public transaction proxy"); + assertEquals(1L, snapshot.languageSingletonTransactions); + assertEquals(finalPromotions, snapshot.languageFinalSnapshotPromotions); + assertEquals(finalPromotions, snapshot.sequenceFinalSnapshotCacheInserts); + assertEquals(materializations, snapshot.languagePatchValueMaterializations); + } } diff --git a/src/test/java/blue/coordination/processor/compute/ComputeFrozenPatchHandoffIntegrationTest.java b/src/test/java/blue/coordination/processor/compute/ComputeFrozenPatchHandoffIntegrationTest.java new file mode 100644 index 0000000..0beddf5 --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/ComputeFrozenPatchHandoffIntegrationTest.java @@ -0,0 +1,191 @@ +package blue.coordination.processor.compute; + +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.ProcessorStatus; +import blue.language.processor.registry.RuntimeBlueIds; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Focused proof that built-in Compute effects use the frozen patch boundary. */ +class ComputeFrozenPatchHandoffIntegrationTest { + + @Test + void accumulatedChangesetRetainsCanonicalFrozenBindingWithoutNodeMaterialization() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport support = support(metrics); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: CopyChannel", + " type: Coordination/Compute", + " do:", + " - $appendChange:", + " op: add", + " path: /copiedChannel", + " val:", + " $currentContract: /channel", + " - $return:", + " changeset:", + " $changeset: true")); + Counters before = Counters.capture(metrics); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertEquals("ownerChannel", result.document().get("/copiedChannel")); + assertEquals(1L, metrics.directBexChangesetHits()); + assertEquals(1L, metrics.bexPatchFrozenDirectConversions()); + assertEquals(0L, metrics.bexPatchNodeMaterializations()); + assertEquals(1L, delta(metrics, before, "frozenPatchesHandedToLanguage")); + assertEquals(2L, delta(metrics, before, "frozenPatchValuesAccepted"), + "the frozen value is accepted during preview and runtime consumption"); + assertEquals(2L, delta(metrics, before, "frozenPatchValuesHandedToLanguage")); + assertEquals(0L, delta(metrics, before, "mutablePatchValuesFrozen")); + assertEquals(0L, delta(metrics, before, "frozenPatchValuesMaterialized")); + } + + @Test + void independentlyReturnedChangesetEventsAndTerminationKeepEffectOrder() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport support = support(metrics); + Node document = support.initialize(support.yaml( + support.operationWorkflowDocumentWithStatus("removeMe: old", String.join("\n", + " steps:", + " - name: ReturnEffects", + " type: Coordination/Compute", + " do:", + " - $return:", + " changeset:", + " - op: add", + " path: /added", + " val:", + " nested: value", + " - op: replace", + " path: /status", + " val: changed", + " - op: remove", + " path: /removeMe", + " events:", + " - type: Coordination/Event", + " kind: first", + " - type: Coordination/Event", + " kind: second", + " termination:", + " reason: complete", + " - name: MustNotRun", + " type: Coordination/Update Document", + " changeset:", + " - op: replace", + " path: /status", + " val: forbidden")))).document(); + Counters before = Counters.capture(metrics); + + DocumentProcessingResult result = support.processRun(document); + + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertEquals("changed", result.document().get("/status")); + assertEquals("value", result.document().get("/added/nested")); + assertFalse(hasPath(result.document(), "/removeMe")); + assertEquals("graceful", result.document().get("/contracts/terminated/cause")); + assertEquals("complete", result.document().get("/contracts/terminated/reason")); + assertEquals(Arrays.asList("first", "second"), selectedKinds(result)); + assertTrue(indexOfKind(result, "second") < indexOfType(result, + RuntimeBlueIds.DOCUMENT_PROCESSING_TERMINATED)); + + assertEquals(0L, metrics.directBexChangesetHits(), + "the returned list is independent of BEX's accumulated changeset"); + assertEquals(0L, metrics.bexPatchFrozenDirectConversions()); + assertEquals(2L, metrics.bexPatchNodeMaterializations()); + assertEquals(3L, delta(metrics, before, "frozenPatchesHandedToLanguage")); + assertEquals(4L, delta(metrics, before, "frozenPatchValuesAccepted"), + "each add/replace value is accepted by preview and runtime consumption"); + assertEquals(4L, delta(metrics, before, "frozenPatchValuesHandedToLanguage")); + assertEquals(0L, delta(metrics, before, "mutablePatchValuesFrozen")); + assertEquals(0L, delta(metrics, before, "frozenPatchValuesMaterialized")); + assertEquals(2L, metrics.eventsEmitted()); + assertEquals(1L, metrics.successfulComputeTerminationRequests()); + } + + private static ComputeWorkflowTestSupport support(BexProcessingMetrics metrics) { + return ComputeWorkflowTestSupport.create(CoordinationProcessorOptions.builder() + .processingMetrics(metrics) + .build()); + } + + private static long delta(BexProcessingMetrics metrics, Counters before, String name) { + return metric(metrics.languageCounters(), name) - metric(before.values, name); + } + + private static long metric(Map values, String name) { + Long value = values.get(name); + return value != null ? value.longValue() : 0L; + } + + private static boolean hasPath(Node document, String path) { + try { + return document.getNode(path) != null; + } catch (RuntimeException ex) { + return false; + } + } + + private static List selectedKinds(DocumentProcessingResult result) { + List selected = new ArrayList(); + for (Node event : result.triggeredEvents()) { + Object kind = valueAt(event, "/kind"); + if ("first".equals(kind) || "second".equals(kind)) { + selected.add((String) kind); + } + } + return selected; + } + + private static int indexOfKind(DocumentProcessingResult result, String kind) { + for (int index = 0; index < result.triggeredEvents().size(); index++) { + if (kind.equals(valueAt(result.triggeredEvents().get(index), "/kind"))) { + return index; + } + } + return -1; + } + + private static Object valueAt(Node node, String path) { + try { + return node.get(path); + } catch (RuntimeException ex) { + return null; + } + } + + private static int indexOfType(DocumentProcessingResult result, String typeBlueId) { + for (int index = 0; index < result.triggeredEvents().size(); index++) { + Node event = result.triggeredEvents().get(index); + if (event.getType() != null && typeBlueId.equals(event.getType().getBlueId())) { + return index; + } + } + return -1; + } + + private static final class Counters { + private final Map values; + + private Counters(Map values) { + this.values = values; + } + + private static Counters capture(BexProcessingMetrics metrics) { + return new Counters(metrics.languageCounters()); + } + } +} diff --git a/src/test/java/blue/coordination/processor/compute/ComputeProgramPlanIntegrationTest.java b/src/test/java/blue/coordination/processor/compute/ComputeProgramPlanIntegrationTest.java new file mode 100644 index 0000000..a488a58 --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/ComputeProgramPlanIntegrationTest.java @@ -0,0 +1,195 @@ +package blue.coordination.processor.compute; + +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.ProcessorStatus; +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.assertTrue; + +class ComputeProgramPlanIntegrationTest { + @Test + void unchangedInlineComputeMissesOnceThenReusesItsFrozenPlan() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport support = support(metrics); + Node document = support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $return:", + " value: warm")); + + DocumentProcessingResult first = support.processRun(document); + DocumentProcessingResult second = support.processRun(first.document()); + + assertFalse(first.capabilityFailure(), first.failureReason()); + assertFalse(second.capabilityFailure(), second.failureReason()); + assertEquals(1L, metrics.computePlanCacheMisses()); + assertEquals(1L, metrics.computePlanCacheHits()); + assertEquals(1L, metrics.computePlansBuilt()); + assertEquals(1L, metrics.computeProgramNormalizations()); + assertEquals(1L, metrics.computeProgramSourceBuilds()); + assertEquals(0L, metrics.computeDefinitionNormalizations()); + assertTrue(metrics.computePlanWeightBytes() > 0L); + } + + @Test + void referencedDefinitionIsReadFrozenEveryTimeButNormalizedOnlyOnMiss() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport support = support(metrics); + Node document = definitionDocument(support, "Warm Definition"); + + DocumentProcessingResult first = support.processRun(document); + DocumentProcessingResult second = support.processRun(first.document()); + + assertEquals("Warm Definition", onlyEvent(first).get("/kind")); + assertEquals("Warm Definition", onlyEvent(second).get("/kind")); + assertEquals(1L, metrics.computePlanCacheMisses()); + assertEquals(1L, metrics.computePlanCacheHits()); + assertEquals(1L, metrics.computePlansBuilt()); + assertEquals(1L, metrics.computeProgramNormalizations()); + assertEquals(1L, metrics.computeDefinitionNormalizations()); + assertEquals(1L, metrics.computeDefinitionMaterializations()); + assertEquals(2L, metrics.computeDefinitionFrozenDirectHits()); + assertEquals(1L, metrics.computeProgramSourceBuilds()); + } + + @Test + void sameContractAndStepNamesAcrossDocumentsUseExactDefinitionIdentity() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport support = support(metrics); + Node documentA = definitionDocument(support, "Definition A"); + Node documentB = definitionDocument(support, "Definition B"); + + DocumentProcessingResult firstA = support.processRun(documentA); + DocumentProcessingResult firstB = support.processRun(documentB); + DocumentProcessingResult warmA = support.processRun(firstA.document()); + DocumentProcessingResult warmB = support.processRun(firstB.document()); + + assertEquals("Definition A", onlyEvent(firstA).get("/kind")); + assertEquals("Definition B", onlyEvent(firstB).get("/kind")); + assertEquals("Definition A", onlyEvent(warmA).get("/kind")); + assertEquals("Definition B", onlyEvent(warmB).get("/kind")); + assertEquals(2L, metrics.computePlanCacheMisses()); + assertEquals(2L, metrics.computePlanCacheHits()); + assertEquals(2L, metrics.computePlansBuilt()); + assertEquals(2L, metrics.computeDefinitionNormalizations()); + assertEquals(2L, metrics.computeProgramSourceBuilds()); + } + + @Test + void changedStepContentBuildsASeparatePlan() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport support = support(metrics); + Node documentA = inlineDocument(support, "A"); + Node documentB = inlineDocument(support, "B"); + + assertFalse(support.processRun(documentA).capabilityFailure()); + assertFalse(support.processRun(documentB).capabilityFailure()); + + assertEquals(2L, metrics.computePlanCacheMisses()); + assertEquals(0L, metrics.computePlanCacheHits()); + assertEquals(2L, metrics.computePlansBuilt()); + assertEquals(2L, metrics.computeProgramNormalizations()); + assertEquals(2L, metrics.computeProgramSourceBuilds()); + } + + @Test + void malformedProgramAndFatalResultNeverPoisonPlanCache() { + BexProcessingMetrics malformedMetrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport malformedSupport = support(malformedMetrics); + Node malformed = malformedSupport.initialize(malformedSupport.yaml( + malformedSupport.operationWorkflowDocumentWithContracts(String.join("\n", + " computeLogic:", + " type: Coordination/Compute Definition", + " functions:", + " build:", + " do:", + " - $return: {}"), + String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " definition: computeLogic", + " entry: missing")))).document(); + + assertRuntimeFatal(malformedSupport.processRun(malformed), "Unknown entry function"); + assertRuntimeFatal(malformedSupport.processRun(malformed), "Unknown entry function"); + assertEquals(2L, malformedMetrics.computePlanCacheMisses()); + assertEquals(0L, malformedMetrics.computePlanCacheHits()); + assertEquals(2L, malformedMetrics.computePlansBuilt()); + + BexProcessingMetrics fatalMetrics = new BexProcessingMetrics(); + ComputeWorkflowTestSupport fatalSupport = support(fatalMetrics); + Node fatal = fatalSupport.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $return:", + " events: malformed")); + + assertRuntimeFatal(fatalSupport.processRun(fatal), "Compute result events must be a list"); + assertRuntimeFatal(fatalSupport.processRun(fatal), "Compute result events must be a list"); + assertEquals(2L, fatalMetrics.computePlanCacheMisses()); + assertEquals(0L, fatalMetrics.computePlanCacheHits()); + assertEquals(2L, fatalMetrics.computePlansBuilt()); + } + + private static ComputeWorkflowTestSupport support(BexProcessingMetrics metrics) { + return ComputeWorkflowTestSupport.create(CoordinationProcessorOptions.builder() + .processingMetrics(metrics) + .build()); + } + + private static Node inlineDocument(ComputeWorkflowTestSupport support, String value) { + return support.initializedOperationWorkflow(String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " do:", + " - $return:", + " value: " + value)); + } + + private static Node definitionDocument(ComputeWorkflowTestSupport support, String kind) { + return support.initialize(support.yaml(support.operationWorkflowDocumentWithContracts( + String.join("\n", + " computeLogic:", + " type: Coordination/Compute Definition", + " constants:", + " kind: " + kind, + " functions:", + " build:", + " do:", + " - $appendEvent:", + " type: Coordination/Event", + " kind:", + " $const: kind", + " - $return: {}"), + String.join("\n", + " steps:", + " - name: Build", + " type: Coordination/Compute", + " definition: computeLogic", + " entry: build")))).document(); + } + + private static Node onlyEvent(DocumentProcessingResult result) { + assertEquals(1, result.triggeredEvents().size(), result.failureReason()); + return result.triggeredEvents().get(0); + } + + private static void assertRuntimeFatal(DocumentProcessingResult result, + String expectedMessage) { + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertTrue(result.failureReason() != null + && result.failureReason().contains(expectedMessage), + result.failureReason()); + } +} diff --git a/src/test/java/blue/coordination/processor/compute/ComputeWorkflowExecutionTest.java b/src/test/java/blue/coordination/processor/compute/ComputeWorkflowExecutionTest.java index 816e467..530da4f 100644 --- a/src/test/java/blue/coordination/processor/compute/ComputeWorkflowExecutionTest.java +++ b/src/test/java/blue/coordination/processor/compute/ComputeWorkflowExecutionTest.java @@ -817,13 +817,23 @@ public void accept(BexMetrics item) { " $document: /status")); Node afterFirst = support.processRun(document).document(); + long hitsAfterWarmup = 0L; + long missesAfterWarmup = 0L; + for (BexMetrics item : metrics) { + hitsAfterWarmup += item.compileCacheHits(); + missesAfterWarmup += item.compileCacheMisses(); + } + support.processRun(afterFirst); - long hits = 0L; + long totalHits = 0L; + long totalMisses = 0L; for (BexMetrics item : metrics) { - hits += item.compileCacheHits(); + totalHits += item.compileCacheHits(); + totalMisses += item.compileCacheMisses(); } - assertTrue(hits > 0L); + assertTrue(totalHits - hitsAfterWarmup > 0L); + assertEquals(0L, totalMisses - missesAfterWarmup); } @Test diff --git a/src/test/java/blue/coordination/processor/compute/LanguageAdoptionMetricsArtifactTest.java b/src/test/java/blue/coordination/processor/compute/LanguageAdoptionMetricsArtifactTest.java new file mode 100644 index 0000000..87c43b3 --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/LanguageAdoptionMetricsArtifactTest.java @@ -0,0 +1,367 @@ +package blue.coordination.processor.compute; + +import blue.bex.api.BexEngine; +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.CoordinationTestResources; +import blue.coordination.processor.RepositoryTypeAliasPreprocessor; +import blue.coordination.processor.TestTimelineProvider; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.coordination.processor.workflow.SequentialWorkflowRunner; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.ProcessorStatus; +import blue.language.snapshot.ResolvedSnapshot; +import blue.repo.coordination.StatusPending; +import blue.repo.mandate.Mandate; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; + +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Produces deterministic machine-readable evidence for the Language-adoption scenarios. */ +class LanguageAdoptionMetricsArtifactTest { + private static final String PAYNOTE_RESOURCE = + "/processor-delay/paynote-resale-reduced-bex.yaml"; + private static final Path REPORT_DIRECTORY = Paths.get(System.getProperty("user.dir"), + "build", "reports", "language-adoption"); + + @Test + void writesJsonAndCsvForRequiredRepresentativeScenarios() throws Exception { + List scenarios = Arrays.asList( + staticUpdateDocumentScenario(), + multiPatchComputeScenario(), + payNoteFixtureScenario(), + mandateFixtureScenario()); + + LanguageAdoptionMetricsArtifactWriter.write(REPORT_DIRECTORY, scenarios); + + Path json = REPORT_DIRECTORY.resolve(LanguageAdoptionMetricsArtifactWriter.JSON_FILE_NAME); + Path csv = REPORT_DIRECTORY.resolve(LanguageAdoptionMetricsArtifactWriter.CSV_FILE_NAME); + assertTrue(Files.isRegularFile(json)); + assertTrue(Files.isRegularFile(csv)); + assertJsonScenarios(json); + assertCsvScenarios(csv); + } + + private static LanguageAdoptionMetricsArtifactWriter.Scenario staticUpdateDocumentScenario() { + OwnedScenario fixture = new OwnedScenario(); + try { + DocumentProcessingResult initialized = fixture.support.initialize(fixture.support.yaml( + fixture.support.operationWorkflowDocumentWithStatus("count: 0", String.join("\n", + " steps:", + " - name: ApplyStaticChanges", + " type: Coordination/Update Document", + " changeset:", + " - op: replace", + " path: /status", + " val: static-updated", + " - op: replace", + " path: /count", + " val: 1")))); + Node document = initialized.document(); + BexProcessingMetrics.Snapshot baseline = fixture.metrics.snapshot(); + + DocumentProcessingResult result = fixture.support.processRun(document); + assertSuccess(result); + assertEquals("static-updated", result.document().get("/status")); + assertEquals(BigInteger.ONE, result.document().get("/count")); + assertEquals(2L, fixture.metrics.patchesApplied()); + assertTrue(metric(fixture.metrics, "frozenPatchesHandedToLanguage") >= 2L); + return LanguageAdoptionMetricsArtifactWriter.capture( + "static-update-document", + "static-update-document", + "inline Coordination/Update Document with two authored patches", + result, + fixture.metrics, + baseline); + } finally { + fixture.close(); + } + } + + private static LanguageAdoptionMetricsArtifactWriter.Scenario multiPatchComputeScenario() { + OwnedScenario fixture = new OwnedScenario(); + try { + DocumentProcessingResult initialized = fixture.support.initialize(fixture.support.yaml( + fixture.support.operationWorkflowDocumentWithStatus("count: 0", String.join("\n", + " steps:", + " - name: BuildMultiPatchChangeset", + " type: Coordination/Compute", + " do:", + " - $appendChange:", + " op: replace", + " path: /status", + " val: first", + " - $appendChange:", + " op: replace", + " path: /count", + " val: 2", + " - $appendChange:", + " op: replace", + " path: /status", + " val: computed", + " - $return:", + " changeset:", + " $changeset: true")))); + Node document = initialized.document(); + BexProcessingMetrics.Snapshot baseline = fixture.metrics.snapshot(); + + DocumentProcessingResult result = fixture.support.processRun(document); + assertSuccess(result); + assertEquals("computed", result.document().get("/status")); + assertEquals(BigInteger.valueOf(2L), result.document().get("/count")); + assertEquals(3L, fixture.metrics.patchesApplied()); + assertEquals(1L, fixture.metrics.updateBatchPatchApplications()); + assertEquals(0L, fixture.metrics.updateIndividualPatchApplications()); + return LanguageAdoptionMetricsArtifactWriter.capture( + "multi-patch-compute", + "multi-patch-compute", + "inline Coordination/Compute accumulated three-patch BEX changeset", + result, + fixture.metrics, + baseline); + } finally { + fixture.close(); + } + } + + private static LanguageAdoptionMetricsArtifactWriter.Scenario payNoteFixtureScenario() { + OwnedScenario fixture = new OwnedScenario(); + try { + DocumentProcessingResult initialized = fixture.support.blue.initializeDocument( + fixture.support.yamlResource(PAYNOTE_RESOURCE)); + assertSuccess(initialized); + BexProcessingMetrics.Snapshot baseline = fixture.metrics.snapshot(); + + Node event = fixture.support.operationRequest( + "hotel-participant", + 1_700_000_100, + "hotelResaleOrderPlaced", + "hotelParticipantChannel", + subscriptionUpdate()); + DocumentProcessingResult result = fixture.support.blue.processDocument( + initialized.snapshot(), event); + + assertSuccess(result); + assertEquals(Boolean.TRUE, + result.document().get("/orders/package-order-a/hotelOrder/resalePlaced")); + return LanguageAdoptionMetricsArtifactWriter.capture( + "paynote-resale-fixture", + "paynote-fixture", + "classpath:" + PAYNOTE_RESOURCE, + result, + fixture.metrics, + baseline); + } finally { + fixture.close(); + } + } + + private static LanguageAdoptionMetricsArtifactWriter.Scenario mandateFixtureScenario() { + OwnedScenario fixture = new OwnedScenario(); + try { + Node mandate = mandateDocument(); + mandate.blue(fixture.support.repository.typeAliasBlue()); + Node aliasesResolved = new RepositoryTypeAliasPreprocessor( + fixture.support.repository).preprocess(mandate); + ResolvedSnapshot resolved = fixture.support.blue.resolveToSnapshot( + fixture.support.blue.preprocess(aliasesResolved)); + DocumentProcessingResult initialized = + fixture.support.blue.initializeDocument(resolved); + assertSuccess(initialized); + assertEquals(StatusPending.blueId(), + initialized.canonicalDocument().getAsText("/status/type/blueId")); + BexProcessingMetrics.Snapshot baseline = fixture.metrics.snapshot(); + + Node event = TestTimelineProvider.timelineEntry( + fixture.support.blue, + fixture.support.repository, + "guarantor", + "guarantor", + BigInteger.valueOf(7_000_001L), + CoordinationTestResources.operationRequest( + "confirmMandateAuthority", + "mandateGuarantorChannel", + new Node())); + DocumentProcessingResult result = fixture.support.blue.processDocument( + initialized.snapshot(), event); + + assertSuccess(result); + assertEquals(BigInteger.valueOf(7_000_001L), + result.document().get("/authorityConfirmedAt")); + return LanguageAdoptionMetricsArtifactWriter.capture( + "mandate-authority-confirmation", + "mandate-fixture", + "generated Mandate authority-confirmation lifecycle fixture", + result, + fixture.metrics, + baseline); + } finally { + fixture.close(); + } + } + + private static Node subscriptionUpdate() { + return new Node() + .type("Sample/Subscription Update") + .properties("subscriptionId", new Node().value("hotel-resale-agreement")) + .properties("targetSessionId", new Node().value("hotel-agreement-session")) + .properties("update", new Node() + .properties("kind", new Node().value("Resale Order Placed")) + .properties("inResponseTo", new Node() + .properties("requestId", new Node().value("hotel-request-a"))) + .properties("orderSessionId", new Node().value("hotel-order-session-a"))); + } + + private static Node mandateDocument() { + return new Node() + .name("Metrics artifact mandate") + .type(Mandate.qualifiedName()) + .properties("activateOnAuthorityConfirmation", new Node().value(false)) + .properties("contracts", new Node() + .properties("mandateGuarantorChannel", + TestTimelineProvider.channel("guarantor")) + .properties("authorityHolderChannel", + TestTimelineProvider.channel("holder")) + .properties("authorizedActorChannel", + TestTimelineProvider.channel("authorized"))); + } + + private static long metric(BexProcessingMetrics metrics, String name) { + Long value = metrics.languageCounters().get(name); + return value != null ? value.longValue() : 0L; + } + + private static void assertSuccess(DocumentProcessingResult result) { + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertNotNull(result.snapshot()); + assertNotNull(result.blueId()); + } + + private static void assertJsonScenarios(Path json) throws IOException { + JsonNode root = new ObjectMapper().readTree(Files.newInputStream(json)); + assertEquals(2, root.path("schemaVersion").asInt()); + assertEquals("after-scenario-before-runtime-and-runner-close", + root.path("capturePhase").asText()); + assertEquals("runtime-construction-through-scenario", + root.path("cumulativeMetricScope").asText()); + assertEquals("after-document-initialization-before-scenario", + root.path("proofCounterDeltaBaseline").asText()); + assertTrue(root.path("coordinationElapsedTimingMetricsExcluded").asBoolean()); + assertTrue(root.path("genericElapsedTimingMetricsExcluded").asBoolean()); + assertEquals(4, root.path("scenarios").size()); + + Set ids = new HashSet(); + for (JsonNode scenario : root.path("scenarios")) { + ids.add(scenario.path("scenarioId").asText()); + assertEquals("SUCCESS", scenario.path("result").path("status").asText()); + assertTrue(scenario.path("result").path("totalGas").asLong() > 0L); + assertTrue(scenario.path("metrics").path("coordinationStrong").size() > 0); + assertTrue(scenario.path("metrics").path("language").path("counters").size() > 0); + assertTrue(scenario.path("metrics").path("language").path("gauges").size() > 0); + assertTrue(scenario.path("metrics").path("language") + .path("highWaterMarks").size() > 0); + assertNoTimingMetrics(scenario.path("metrics").path("language").path("counters")); + assertNoTimingMetrics(scenario.path("metrics").path("language").path("gauges")); + assertNoTimingMetrics(scenario.path("metrics").path("language") + .path("highWaterMarks")); + + JsonNode proof = scenario.path("metrics") + .path("proofCounterDeltasSinceInitialization"); + assertEquals(6, proof.size()); + assertEquals(expectedPatches(scenario.path("scenarioId").asText()), + proof.path("frozenPatchesHandedToLanguage").asLong()); + assertTrue(proof.path("frozenPatchValuesHandedToLanguage").asLong() > 0L); + assertEquals(proof.path("frozenPatchValuesHandedToLanguage").asLong(), + proof.path("frozenPatchValuesAccepted").asLong()); + assertEquals(0L, proof.path("mutablePatchesHandedToLanguage").asLong()); + assertEquals(0L, proof.path("mutablePatchValuesFrozen").asLong()); + assertEquals(0L, proof.path("frozenPatchValuesMaterialized").asLong()); + } + assertEquals(new HashSet(Arrays.asList( + "static-update-document", + "multi-patch-compute", + "paynote-resale-fixture", + "mandate-authority-confirmation")), ids); + } + + private static void assertCsvScenarios(Path csv) throws IOException { + String content = new String(Files.readAllBytes(csv), StandardCharsets.UTF_8); + assertTrue(content.startsWith("scenario_id,scenario_kind,fixture,status")); + assertTrue(content.contains("\"static-update-document\"")); + assertTrue(content.contains("\"multi-patch-compute\"")); + assertTrue(content.contains("\"paynote-resale-fixture\"")); + assertTrue(content.contains("\"mandate-authority-confirmation\"")); + assertTrue(content.contains("\"coordination-cumulative\",\"strong\"")); + assertTrue(content.contains("\"language-cumulative\",\"counter\"")); + assertTrue(content.contains("\"language-cumulative\",\"gauge\"")); + assertTrue(content.contains("\"language-cumulative\",\"high_water\"")); + assertTrue(content.contains("\"workflow-since-initialization\",\"counter_delta\"," + + "\"frozenPatchValuesMaterialized\",\"0\"")); + assertFalse(content.contains("Nanos")); + } + + private static void assertNoTimingMetrics(JsonNode metrics) { + Iterator names = metrics.fieldNames(); + while (names.hasNext()) { + assertFalse(names.next().endsWith("Nanos")); + } + } + + private static long expectedPatches(String scenarioId) { + if ("static-update-document".equals(scenarioId)) { + return 2L; + } + if ("multi-patch-compute".equals(scenarioId)) { + return 3L; + } + if ("paynote-resale-fixture".equals(scenarioId)) { + return 6L; + } + if ("mandate-authority-confirmation".equals(scenarioId)) { + return 2L; + } + throw new AssertionError("Unexpected scenario id: " + scenarioId); + } + + private static final class OwnedScenario implements AutoCloseable { + private final BexProcessingMetrics metrics = new BexProcessingMetrics(); + private final BexEngine engine = BexEngine.builder().build(); + private final SequentialWorkflowRunner runner = SequentialWorkflowRunner.withBexEngine( + engine, 100_000L, metrics); + private final ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create( + CoordinationProcessorOptions.builder() + .bexEngine(engine) + .sequentialWorkflowRunner(runner) + .defaultComputeGasLimit(100_000L) + .processingMetrics(metrics) + .build()); + + @Override + public void close() { + try { + support.blue.close(); + } finally { + runner.close(); + } + } + } +} diff --git a/src/test/java/blue/coordination/processor/compute/LanguageAdoptionMetricsArtifactWriter.java b/src/test/java/blue/coordination/processor/compute/LanguageAdoptionMetricsArtifactWriter.java new file mode 100644 index 0000000..5c82567 --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/LanguageAdoptionMetricsArtifactWriter.java @@ -0,0 +1,342 @@ +package blue.coordination.processor.compute; + +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.processor.DocumentProcessingResult; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** Deterministic JSON/CSV serialization for representative Language-adoption metrics. */ +final class LanguageAdoptionMetricsArtifactWriter { + static final String JSON_FILE_NAME = "scenario-metrics.json"; + static final String CSV_FILE_NAME = "scenario-metrics.csv"; + private static final String CAPTURE_PHASE = + "after-scenario-before-runtime-and-runner-close"; + private static final String CUMULATIVE_METRIC_SCOPE = + "runtime-construction-through-scenario"; + private static final String PROOF_COUNTER_DELTA_BASELINE = + "after-document-initialization-before-scenario"; + private static final String[] REQUIRED_PROOF_COUNTERS = { + "frozenPatchesHandedToLanguage", + "frozenPatchValuesHandedToLanguage", + "mutablePatchesHandedToLanguage", + "frozenPatchValuesAccepted", + "mutablePatchValuesFrozen", + "frozenPatchValuesMaterialized" + }; + + private LanguageAdoptionMetricsArtifactWriter() { + } + + static Scenario capture(String scenarioId, + String scenarioKind, + String fixture, + DocumentProcessingResult result, + BexProcessingMetrics metrics, + BexProcessingMetrics.Snapshot postInitializationBaseline) { + if (result == null) { + throw new IllegalArgumentException("result must not be null"); + } + if (metrics == null) { + throw new IllegalArgumentException("metrics must not be null"); + } + if (postInitializationBaseline == null) { + throw new IllegalArgumentException("postInitializationBaseline must not be null"); + } + + BexProcessingMetrics.Snapshot snapshot = metrics.snapshot(); + return new Scenario(scenarioId, + scenarioKind, + fixture, + result.status().name(), + result.errorCategory() != null ? result.errorCategory().name() : null, + result.failureReason(), + result.totalGas(), + result.triggeredEvents().size(), + result.blueId(), + strongMetrics(snapshot), + deterministicGenericMetrics(snapshot.languageCounters), + deterministicGenericMetrics(snapshot.languageGauges), + deterministicGenericMetrics(snapshot.languageHighWaterMarks), + proofCounterDeltas(postInitializationBaseline, snapshot)); + } + + static void write(Path reportDirectory, List scenarios) throws IOException { + if (reportDirectory == null) { + throw new IllegalArgumentException("reportDirectory must not be null"); + } + if (scenarios == null || scenarios.isEmpty()) { + throw new IllegalArgumentException("scenarios must not be empty"); + } + + List ordered = new ArrayList(scenarios); + Collections.sort(ordered, new Comparator() { + @Override + public int compare(Scenario left, Scenario right) { + return left.scenarioId.compareTo(right.scenarioId); + } + }); + rejectDuplicateScenarioIds(ordered); + + Files.createDirectories(reportDirectory); + writeAtomically(reportDirectory.resolve(JSON_FILE_NAME), json(ordered)); + writeAtomically(reportDirectory.resolve(CSV_FILE_NAME), csv(ordered)); + } + + private static Map strongMetrics(BexProcessingMetrics.Snapshot snapshot) { + Map values = new TreeMap(); + for (Field field : BexProcessingMetrics.Snapshot.class.getFields()) { + if (field.getType() != Long.TYPE + || !Modifier.isPublic(field.getModifiers()) + || Modifier.isStatic(field.getModifiers()) + || field.getName().endsWith("Nanos")) { + continue; + } + try { + values.put(field.getName(), Long.valueOf(field.getLong(snapshot))); + } catch (IllegalAccessException ex) { + throw new IllegalStateException("Cannot read metrics field " + field.getName(), ex); + } + } + return Collections.unmodifiableMap(values); + } + + private static Map deterministicGenericMetrics(Map source) { + Map values = new TreeMap(); + for (Map.Entry metric : source.entrySet()) { + if (!metric.getKey().endsWith("Nanos")) { + values.put(metric.getKey(), metric.getValue()); + } + } + return Collections.unmodifiableMap(values); + } + + private static Map proofCounterDeltas( + BexProcessingMetrics.Snapshot baseline, + BexProcessingMetrics.Snapshot current) { + Map values = new TreeMap(); + for (String name : REQUIRED_PROOF_COUNTERS) { + long delta = metric(current.languageCounters, name) + - metric(baseline.languageCounters, name); + if (delta < 0L) { + throw new IllegalStateException("Counter decreased after initialization: " + name); + } + values.put(name, Long.valueOf(delta)); + } + return Collections.unmodifiableMap(values); + } + + private static long metric(Map metrics, String name) { + Long value = metrics.get(name); + return value != null ? value.longValue() : 0L; + } + + private static byte[] json(List scenarios) throws IOException { + Map root = new LinkedHashMap(); + root.put("schemaVersion", Integer.valueOf(2)); + root.put("capturePhase", CAPTURE_PHASE); + root.put("cumulativeMetricScope", CUMULATIVE_METRIC_SCOPE); + root.put("proofCounterDeltaBaseline", PROOF_COUNTER_DELTA_BASELINE); + root.put("coordinationElapsedTimingMetricsExcluded", Boolean.TRUE); + root.put("genericElapsedTimingMetricsExcluded", Boolean.TRUE); + + List> serializedScenarios = + new ArrayList>(scenarios.size()); + for (Scenario scenario : scenarios) { + serializedScenarios.add(scenario.toJson()); + } + root.put("scenarios", serializedScenarios); + + ObjectMapper mapper = new ObjectMapper(); + mapper.enable(SerializationFeature.INDENT_OUTPUT); + mapper.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + return (mapper.writeValueAsString(root) + "\n").getBytes(StandardCharsets.UTF_8); + } + + private static byte[] csv(List scenarios) { + StringBuilder csv = new StringBuilder(); + csv.append("scenario_id,scenario_kind,fixture,status,error_category,failure_reason,") + .append("total_gas,triggered_event_count,final_blue_id,") + .append("metric_scope,metric_kind,metric_name,metric_value\n"); + for (Scenario scenario : scenarios) { + appendMetrics(csv, scenario, "coordination-cumulative", "strong", + scenario.coordination); + appendMetrics(csv, scenario, "language-cumulative", "counter", + scenario.languageCounters); + appendMetrics(csv, scenario, "language-cumulative", "gauge", + scenario.languageGauges); + appendMetrics(csv, scenario, "language-cumulative", "high_water", + scenario.languageHighWaterMarks); + appendMetrics(csv, scenario, "workflow-since-initialization", "counter_delta", + scenario.proofCounterDeltasSinceInitialization); + } + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + private static void appendMetrics(StringBuilder csv, + Scenario scenario, + String scope, + String kind, + Map metrics) { + for (Map.Entry metric : metrics.entrySet()) { + appendCsvCell(csv, scenario.scenarioId); + appendCsvCell(csv, scenario.scenarioKind); + appendCsvCell(csv, scenario.fixture); + appendCsvCell(csv, scenario.status); + appendCsvCell(csv, scenario.errorCategory); + appendCsvCell(csv, scenario.failureReason); + appendCsvCell(csv, Long.toString(scenario.totalGas)); + appendCsvCell(csv, Integer.toString(scenario.triggeredEventCount)); + appendCsvCell(csv, scenario.finalBlueId); + appendCsvCell(csv, scope); + appendCsvCell(csv, kind); + appendCsvCell(csv, metric.getKey()); + appendCsvCell(csv, Long.toString(metric.getValue().longValue()), true); + } + } + + private static void appendCsvCell(StringBuilder target, String value) { + appendCsvCell(target, value, false); + } + + private static void appendCsvCell(StringBuilder target, String value, boolean last) { + target.append('"'); + if (value != null) { + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + if (character == '"') { + target.append("\"\""); + } else { + target.append(character); + } + } + } + target.append('"').append(last ? '\n' : ','); + } + + private static void writeAtomically(Path target, byte[] content) throws IOException { + Path temporary = target.resolveSibling(target.getFileName().toString() + ".tmp"); + Files.write(temporary, content); + try { + Files.move(temporary, target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ex) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void rejectDuplicateScenarioIds(List scenarios) { + String previous = null; + for (Scenario scenario : scenarios) { + if (scenario.scenarioId.equals(previous)) { + throw new IllegalArgumentException("Duplicate scenario id: " + previous); + } + previous = scenario.scenarioId; + } + } + + static final class Scenario { + private final String scenarioId; + private final String scenarioKind; + private final String fixture; + private final String status; + private final String errorCategory; + private final String failureReason; + private final long totalGas; + private final int triggeredEventCount; + private final String finalBlueId; + private final Map coordination; + private final Map languageCounters; + private final Map languageGauges; + private final Map languageHighWaterMarks; + private final Map proofCounterDeltasSinceInitialization; + + private Scenario(String scenarioId, + String scenarioKind, + String fixture, + String status, + String errorCategory, + String failureReason, + long totalGas, + int triggeredEventCount, + String finalBlueId, + Map coordination, + Map languageCounters, + Map languageGauges, + Map languageHighWaterMarks, + Map proofCounterDeltasSinceInitialization) { + this.scenarioId = required(scenarioId, "scenarioId"); + this.scenarioKind = required(scenarioKind, "scenarioKind"); + this.fixture = required(fixture, "fixture"); + this.status = required(status, "status"); + this.errorCategory = errorCategory; + this.failureReason = failureReason; + this.totalGas = totalGas; + this.triggeredEventCount = triggeredEventCount; + this.finalBlueId = finalBlueId; + this.coordination = immutableSortedCopy(coordination); + this.languageCounters = immutableSortedCopy(languageCounters); + this.languageGauges = immutableSortedCopy(languageGauges); + this.languageHighWaterMarks = immutableSortedCopy(languageHighWaterMarks); + this.proofCounterDeltasSinceInitialization = + immutableSortedCopy(proofCounterDeltasSinceInitialization); + } + + private Map toJson() { + Map result = new LinkedHashMap(); + result.put("status", status); + result.put("errorCategory", errorCategory); + result.put("failureReason", failureReason); + result.put("totalGas", Long.valueOf(totalGas)); + result.put("triggeredEventCount", Integer.valueOf(triggeredEventCount)); + result.put("finalBlueId", finalBlueId); + + Map language = new LinkedHashMap(); + language.put("counters", languageCounters); + language.put("gauges", languageGauges); + language.put("highWaterMarks", languageHighWaterMarks); + + Map metrics = new LinkedHashMap(); + metrics.put("coordinationStrong", coordination); + metrics.put("language", language); + metrics.put("proofCounterDeltasSinceInitialization", + proofCounterDeltasSinceInitialization); + + Map json = new LinkedHashMap(); + json.put("scenarioId", scenarioId); + json.put("scenarioKind", scenarioKind); + json.put("fixture", fixture); + json.put("result", result); + json.put("metrics", metrics); + return json; + } + + private static String required(String value, String label) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(label + " must not be empty"); + } + return value; + } + + private static Map immutableSortedCopy(Map source) { + return Collections.unmodifiableMap(new TreeMap(source)); + } + } +} diff --git a/src/test/java/blue/coordination/processor/compute/ProcessingEventBindingTest.java b/src/test/java/blue/coordination/processor/compute/ProcessingEventBindingTest.java index af359dd..85676a9 100644 --- a/src/test/java/blue/coordination/processor/compute/ProcessingEventBindingTest.java +++ b/src/test/java/blue/coordination/processor/compute/ProcessingEventBindingTest.java @@ -310,7 +310,7 @@ void unusedProcessingEventBindingAddsZeroGas() { } @Test - void coordinationRegistrationDoesNotReplaceIndependentProcessorMetricsSink() { + void coordinationRegistrationPreservesIndependentSinkAndFansOutLanguageMetrics() { BexProcessingMetrics processorMetrics = new BexProcessingMetrics(); BexProcessingMetrics workflowMetrics = new BexProcessingMetrics(); BlueRepository repository = BlueRepository.latest(); @@ -327,7 +327,7 @@ void coordinationRegistrationDoesNotReplaceIndependentProcessorMetricsSink() { assertSuccess(result); assertEquals(1L, processorMetrics.processEventSnapshotAttempts()); - assertEquals(0L, workflowMetrics.processEventSnapshotAttempts()); + assertEquals(1L, workflowMetrics.processEventSnapshotAttempts()); assertEquals(0L, processorMetrics.computeStepsExecuted()); assertEquals(1L, workflowMetrics.computeStepsExecuted()); } diff --git a/src/test/java/blue/coordination/processor/compute/RepresentativeWorkflowLifecycleSmokeTest.java b/src/test/java/blue/coordination/processor/compute/RepresentativeWorkflowLifecycleSmokeTest.java new file mode 100644 index 0000000..416947e --- /dev/null +++ b/src/test/java/blue/coordination/processor/compute/RepresentativeWorkflowLifecycleSmokeTest.java @@ -0,0 +1,404 @@ +package blue.coordination.processor.compute; + +import blue.bex.api.BexEngine; +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.CoordinationTestResources; +import blue.coordination.processor.RepositoryTypeAliasPreprocessor; +import blue.coordination.processor.TestTimelineProvider; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.coordination.processor.workflow.SequentialWorkflowRunner; +import blue.language.BlueCacheStats; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.ProcessorStatus; +import blue.language.snapshot.ResolvedSnapshot; +import blue.repo.coordination.StatusPending; +import blue.repo.mandate.Mandate; +import java.math.BigInteger; +import java.util.Map; +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; + +/** + * A deterministic lifecycle/memory smoke over representative real workflow shapes. + * + *

This deliberately measures owned cache state instead of heap deltas, weak references, or + * forced GC. Replaying identical work must settle below a warmed retention ceiling, every + * workflow-scoped transient reference cache must return to its baseline, and explicit shutdown + * must release both the Language runtime and the externally owned Coordination runner.

+ */ +class RepresentativeWorkflowLifecycleSmokeTest { + private static final String PAYNOTE_RESOURCE = + "/processor-delay/paynote-resale-reduced-bex.yaml"; + private static final String TRANSIENT_REFERENCE_CACHE = "transientTrustedReferences"; + private static final long TWO_GIB = 2L * 1024L * 1024L * 1024L; + + @Test + void repeatedPayNoteMandateAndEmbeddedRunsPlateauAndReleaseOwnedState() { + assertEquals("1.8", System.getProperty("java.specification.version"), + "memoryIntegrationTest must keep the Java 8 compatibility runtime"); + assertTrue(Runtime.getRuntime().maxMemory() <= TWO_GIB, + "memoryIntegrationTest must retain its -Xmx2g ceiling"); + + OwnedFixture fixture = new OwnedFixture(); + try { + fixture.prepare(); + fixture.assertTransientStateAtBaseline("after fixture preparation"); + + fixture.runRepresentativeSuite(); + RetainedState firstWarmSample = fixture.retainedState(); + fixture.runRepresentativeSuite(); + RetainedState warmedCeiling = RetainedState.maximum( + firstWarmSample, fixture.retainedState()); + + for (int repetition = 0; repetition < 3; repetition++) { + fixture.runRepresentativeSuite(); + fixture.retainedState().assertAtOrBelow(warmedCeiling, + "repetition " + repetition); + } + + assertTrue(fixture.metrics.workflowStepsExecuted() > 0L); + assertTrue(fixture.metrics.computeStepsExecuted() > 0L); + assertTrue(fixture.metrics.workflowPlanWeightBytes() > 0L); + assertTrue(fixture.metrics.computePlanWeightBytes() > 0L); + assertTrue(fixture.runner.workflowPlanCacheSize() > 0); + + long runnerWeightBeforeRuntimeClose = + fixture.runner.workflowPlanCacheWeightBytes(); + long computeWeightBeforeRuntimeClose = fixture.metrics.computePlanWeightBytes(); + fixture.closeRuntime(); + + BlueCacheStats closedRuntime = fixture.support.blue.cacheStats(); + assertTrue(closedRuntime.isClosed()); + assertEquals(0, closedRuntime.entries()); + assertEquals(0L, closedRuntime.currentWeightBytes()); + assertEquals(runnerWeightBeforeRuntimeClose, + fixture.runner.workflowPlanCacheWeightBytes(), + "Blue must not close an injected runner it does not own"); + assertEquals(computeWeightBeforeRuntimeClose, + fixture.metrics.computePlanWeightBytes(), + "runner Compute plans remain externally owned until runner.close()"); + assertEquals(1L, metric(fixture.metrics.languageCounters(), "runtimeCloseCalls")); + assertTrue(metric(fixture.metrics.languageCounters(), + "runtimeCloseReleasedWeightBytes") > 0L); + assertClosedLanguageCacheGauges(fixture.metrics.languageGauges()); + + fixture.closeRunner(); + assertEquals(0, fixture.runner.workflowPlanCacheSize()); + assertEquals(0L, fixture.runner.workflowPlanCacheWeightBytes()); + assertEquals(0L, fixture.metrics.workflowPlanWeightBytes()); + assertEquals(0L, fixture.metrics.computePlanWeightBytes()); + } finally { + fixture.close(); + } + } + + private static void assertClosedLanguageCacheGauges(Map gauges) { + boolean observedRetentionGauge = false; + for (Map.Entry gauge : gauges.entrySet()) { + String name = gauge.getKey(); + if (name.startsWith("cache.") + && (name.endsWith(".entries") + || name.endsWith(".currentWeightBytes") + || name.endsWith(".pinnedEntries") + || name.endsWith(".derivedEntries"))) { + observedRetentionGauge = true; + assertEquals(0L, gauge.getValue().longValue(), + "runtime close retained " + name); + } + } + assertTrue(observedRetentionGauge, + "the Language runtime must publish close-time cache gauges"); + } + + private static long metric(Map metrics, String name) { + Long value = metrics.get(name); + return value == null ? 0L : value.longValue(); + } + + private static void assertSuccess(DocumentProcessingResult result) { + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + } + + private static Node subscriptionUpdate(String subscriptionId, + String targetSessionId, + String requestId, + String orderSessionId) { + return new Node() + .type("Sample/Subscription Update") + .properties("subscriptionId", new Node().value(subscriptionId)) + .properties("targetSessionId", new Node().value(targetSessionId)) + .properties("update", new Node() + .properties("kind", new Node().value("Resale Order Placed")) + .properties("inResponseTo", new Node() + .properties("requestId", new Node().value(requestId))) + .properties("orderSessionId", new Node().value(orderSessionId))); + } + + private static Node mandateDocument() { + return new Node() + .name("Lifecycle memory smoke mandate") + .type(Mandate.qualifiedName()) + .properties("activateOnAuthorityConfirmation", new Node().value(false)) + .properties("contracts", new Node() + .properties("mandateGuarantorChannel", + TestTimelineProvider.channel("guarantor")) + .properties("authorityHolderChannel", + TestTimelineProvider.channel("holder")) + .properties("authorizedActorChannel", + TestTimelineProvider.channel("authorized"))); + } + + private static Node embeddedDocument() { + return new Node() + .name("Lifecycle memory smoke embedded parent") + .properties("contracts", new Node() + .properties("embedded", new Node() + .type("Process Embedded") + .properties("paths", new Node().items( + new Node().value("/child"))))) + .properties("child", new Node() + .name("Lifecycle memory smoke embedded child") + .properties("status", new Node().value("idle")) + .properties("contracts", new Node() + .properties("childChannel", + TestTimelineProvider.channel("child")) + .properties("runChild", new Node() + .type("Coordination/Sequential Workflow Operation") + .properties("channel", new Node().value("childChannel")) + .properties("request", new Node().type("Text")) + .properties("steps", new Node().items( + embeddedComputeStep()))))); + } + + private static Node embeddedComputeStep() { + return new Node() + .name("Update embedded child") + .type("Coordination/Compute") + .properties("do", new Node().items( + new Node().properties("$appendChange", new Node() + .properties("op", new Node().value("replace")) + .properties("path", new Node().value("/status")) + .properties("val", new Node().value("processed"))), + new Node().properties("$return", new Node() + .properties("changeset", new Node() + .properties("$changeset", new Node().value(true)))))); + } + + private static final class OwnedFixture implements AutoCloseable { + private final BexProcessingMetrics metrics = new BexProcessingMetrics(); + private final BexEngine engine = BexEngine.builder().build(); + private final SequentialWorkflowRunner runner = SequentialWorkflowRunner.withBexEngine( + engine, 100_000L, metrics); + private final ComputeWorkflowTestSupport support = ComputeWorkflowTestSupport.create( + CoordinationProcessorOptions.builder() + .bexEngine(engine) + .sequentialWorkflowRunner(runner) + .defaultComputeGasLimit(100_000L) + .processingMetrics(metrics) + .build()); + + private ResolvedSnapshot paynoteSnapshot; + private Node paynoteEvent; + private ResolvedSnapshot mandateSnapshot; + private Node mandateEvent; + private ResolvedSnapshot embeddedSnapshot; + private Node embeddedEvent; + private int transientBaselineEntries; + private long transientBaselineWeightBytes; + private boolean runtimeClosed; + private boolean runnerClosed; + + private void prepare() { + DocumentProcessingResult paynoteInitialized = support.blue.initializeDocument( + support.yamlResource(PAYNOTE_RESOURCE)); + assertSuccess(paynoteInitialized); + paynoteSnapshot = paynoteInitialized.snapshot(); + paynoteEvent = CoordinationTestResources.operationRequestEvent( + support.blue, + support.repository, + "hotel-participant", + 1_700_000_100, + "hotelResaleOrderPlaced", + "hotelParticipantChannel", + subscriptionUpdate("hotel-resale-agreement", + "hotel-agreement-session", + "hotel-request-a", + "hotel-order-session-a")); + + Node mandate = mandateDocument(); + mandate.blue(support.repository.typeAliasBlue()); + Node aliasesResolved = new RepositoryTypeAliasPreprocessor( + support.repository).preprocess(mandate); + ResolvedSnapshot resolvedMandate = support.blue.resolveToSnapshot( + support.blue.preprocess(aliasesResolved)); + DocumentProcessingResult mandateInitialized = + support.blue.initializeDocument(resolvedMandate); + assertSuccess(mandateInitialized); + assertEquals(StatusPending.blueId(), + mandateInitialized.canonicalDocument().getAsText("/status/type/blueId")); + mandateSnapshot = mandateInitialized.snapshot(); + mandateEvent = TestTimelineProvider.timelineEntry( + support.blue, + support.repository, + "guarantor", + "guarantor", + BigInteger.valueOf(7_000_001L), + CoordinationTestResources.operationRequest( + "confirmMandateAuthority", + "mandateGuarantorChannel", + new Node())); + + DocumentProcessingResult embeddedInitialized = support.initialize( + embeddedDocument()); + assertSuccess(embeddedInitialized); + embeddedSnapshot = embeddedInitialized.snapshot(); + embeddedEvent = CoordinationTestResources.operationRequestEvent( + support.blue, + support.repository, + "child", + 1, + "runChild", + "childChannel", + new Node().value("request")); + + BlueCacheStats.Region transientCache = transientCache(); + transientBaselineEntries = transientCache.entries(); + transientBaselineWeightBytes = transientCache.currentWeightBytes(); + } + + private void runRepresentativeSuite() { + DocumentProcessingResult paynote = support.blue.processDocument( + paynoteSnapshot, paynoteEvent.clone()); + assertSuccess(paynote); + assertEquals(Boolean.TRUE, + paynote.document().get("/orders/package-order-a/hotelOrder/resalePlaced")); + + DocumentProcessingResult mandate = support.blue.processDocument( + mandateSnapshot, mandateEvent.clone()); + assertSuccess(mandate); + assertEquals(BigInteger.valueOf(7_000_001L), + mandate.document().get("/authorityConfirmedAt")); + + DocumentProcessingResult embedded = support.blue.processDocument( + embeddedSnapshot, embeddedEvent.clone()); + assertSuccess(embedded); + assertEquals("processed", embedded.document().get("/child/status")); + + assertTransientStateAtBaseline("after representative suite"); + } + + private RetainedState retainedState() { + BlueCacheStats runtime = support.blue.cacheStats(); + BlueCacheStats.Region transientCache = transientCache(); + return new RetainedState(runtime.entries(), + runtime.currentWeightBytes(), + transientCache.entries(), + transientCache.currentWeightBytes(), + runner.workflowPlanCacheSize(), + runner.workflowPlanCacheWeightBytes(), + metrics.computePlanWeightBytes()); + } + + private void assertTransientStateAtBaseline(String phase) { + BlueCacheStats.Region transientCache = transientCache(); + assertEquals(transientBaselineEntries, transientCache.entries(), + phase + " retained transient reference entries"); + assertEquals(transientBaselineWeightBytes, transientCache.currentWeightBytes(), + phase + " retained transient reference weight"); + } + + private BlueCacheStats.Region transientCache() { + BlueCacheStats.Region region = support.blue.cacheStats().region( + TRANSIENT_REFERENCE_CACHE); + assertNotNull(region, "Language runtime did not expose the transient cache region"); + return region; + } + + private void closeRuntime() { + if (!runtimeClosed) { + runtimeClosed = true; + support.blue.close(); + } + } + + private void closeRunner() { + if (!runnerClosed) { + runnerClosed = true; + runner.close(); + } + } + + @Override + public void close() { + try { + closeRuntime(); + } finally { + closeRunner(); + } + } + } + + private static final class RetainedState { + private final int languageEntries; + private final long languageWeightBytes; + private final int transientEntries; + private final long transientWeightBytes; + private final int workflowPlanEntries; + private final long workflowPlanWeightBytes; + private final long computePlanWeightBytes; + + private RetainedState(int languageEntries, + long languageWeightBytes, + int transientEntries, + long transientWeightBytes, + int workflowPlanEntries, + long workflowPlanWeightBytes, + long computePlanWeightBytes) { + this.languageEntries = languageEntries; + this.languageWeightBytes = languageWeightBytes; + this.transientEntries = transientEntries; + this.transientWeightBytes = transientWeightBytes; + this.workflowPlanEntries = workflowPlanEntries; + this.workflowPlanWeightBytes = workflowPlanWeightBytes; + this.computePlanWeightBytes = computePlanWeightBytes; + } + + private static RetainedState maximum(RetainedState left, RetainedState right) { + return new RetainedState( + Math.max(left.languageEntries, right.languageEntries), + Math.max(left.languageWeightBytes, right.languageWeightBytes), + Math.max(left.transientEntries, right.transientEntries), + Math.max(left.transientWeightBytes, right.transientWeightBytes), + Math.max(left.workflowPlanEntries, right.workflowPlanEntries), + Math.max(left.workflowPlanWeightBytes, right.workflowPlanWeightBytes), + Math.max(left.computePlanWeightBytes, right.computePlanWeightBytes)); + } + + private void assertAtOrBelow(RetainedState ceiling, String phase) { + assertAtOrBelow(languageEntries, ceiling.languageEntries, + phase + " Language cache entries"); + assertAtOrBelow(languageWeightBytes, ceiling.languageWeightBytes, + phase + " Language cache weight"); + assertAtOrBelow(transientEntries, ceiling.transientEntries, + phase + " transient entries"); + assertAtOrBelow(transientWeightBytes, ceiling.transientWeightBytes, + phase + " transient weight"); + assertAtOrBelow(workflowPlanEntries, ceiling.workflowPlanEntries, + phase + " workflow-plan entries"); + assertAtOrBelow(workflowPlanWeightBytes, ceiling.workflowPlanWeightBytes, + phase + " workflow-plan weight"); + assertAtOrBelow(computePlanWeightBytes, ceiling.computePlanWeightBytes, + phase + " Compute-plan weight"); + } + + private static void assertAtOrBelow(long actual, long ceiling, String label) { + assertTrue(actual <= ceiling, + label + " exceeded the warmed ceiling: actual=" + actual + + ", ceiling=" + ceiling); + } + } +} diff --git a/src/test/java/blue/coordination/processor/compute/UpdateDocumentBatchApplyIntegrationTest.java b/src/test/java/blue/coordination/processor/compute/UpdateDocumentBatchApplyIntegrationTest.java index c5dc19c..8c08d47 100644 --- a/src/test/java/blue/coordination/processor/compute/UpdateDocumentBatchApplyIntegrationTest.java +++ b/src/test/java/blue/coordination/processor/compute/UpdateDocumentBatchApplyIntegrationTest.java @@ -68,6 +68,9 @@ void computeChangesetUsesLanguageBatchApplyAndPreservesPatchOrder() { assertEquals(1L, metrics.updateBatchPatchApplications()); assertEquals(0L, metrics.updateIndividualPatchApplications()); assertEquals(1L, metrics.directBexChangesetHits()); + assertEquals(3L, metrics.bexPatchNodeMaterializations()); + assertEquals(0L, metrics.bexPatchFrozenDirectConversions(), + "newly computed scalar values still require the single measured BEX boundary conversion"); } @Test @@ -142,6 +145,10 @@ void literalUpdateDocumentChangesetsUseBatchApply() { " - op: replace", " path: /status", " val: existing")); + long frozenHandedBefore = metric(metrics, "frozenPatchesHandedToLanguage"); + long frozenAcceptedBefore = metric(metrics, "frozenPatchValuesAccepted"); + long mutableFrozenBefore = metric(metrics, "mutablePatchValuesFrozen"); + long frozenMaterializedBefore = metric(metrics, "frozenPatchValuesMaterialized"); DocumentProcessingResult result = support.processRun(document, new Node() @@ -153,6 +160,13 @@ void literalUpdateDocumentChangesetsUseBatchApply() { assertEquals(2L, metrics.patchesApplied()); assertEquals(2L, metrics.updateBatchPatchApplications()); assertEquals(0L, metrics.updateIndividualPatchApplications()); + assertEquals(2L, metrics.updateStaticTemplatesBuilt()); + assertEquals(2L, metrics.updateStaticTemplateHits()); + assertEquals(0L, metrics.updateReflectionFallbacks()); + assertEquals(2L, metric(metrics, "frozenPatchesHandedToLanguage") - frozenHandedBefore); + assertTrue(metric(metrics, "frozenPatchValuesAccepted") - frozenAcceptedBefore >= 2L); + assertEquals(0L, metric(metrics, "mutablePatchValuesFrozen") - mutableFrozenBefore); + assertEquals(0L, metric(metrics, "frozenPatchValuesMaterialized") - frozenMaterializedBefore); } @Test @@ -176,4 +190,9 @@ void updateDocumentRejectsBexOperatorsInStaticChangeset() { assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); assertTrue(result.failureReason().contains("Update Document changeset must be static")); } + + private static long metric(BexProcessingMetrics metrics, String name) { + Long value = metrics.languageCounters().get(name); + return value != null ? value.longValue() : 0L; + } } diff --git a/src/test/java/blue/coordination/processor/workflow/ComputeEffectPlanTest.java b/src/test/java/blue/coordination/processor/workflow/ComputeEffectPlanTest.java index e4b6087..fd9778c 100644 --- a/src/test/java/blue/coordination/processor/workflow/ComputeEffectPlanTest.java +++ b/src/test/java/blue/coordination/processor/workflow/ComputeEffectPlanTest.java @@ -7,8 +7,10 @@ import blue.bex.result.BexPatchEntry; import blue.bex.value.BexValue; import blue.bex.value.BexValues; +import blue.coordination.processor.bex.BexProcessingMetrics; import blue.language.model.Node; -import blue.language.processor.model.JsonPatch; +import blue.language.processor.model.FrozenJsonPatch; +import blue.language.snapshot.FrozenNode; import blue.repo.coordination.TerminateProcessing; import org.junit.jupiter.api.Test; @@ -25,6 +27,7 @@ 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.assertTrue; @@ -51,36 +54,45 @@ void planCopiesAndFreezesEventContent() { } @Test - void planCopiesAndFreezesPatchContent() { + void planDefensivelyCopiesAndRetainsImmutableFrozenPatches() { Node value = new Node().properties("status", new Node().value("original")); - List patches = new ArrayList(); - patches.add(JsonPatch.replace("/target", value)); + FrozenNode frozenValue = FrozenNode.fromNode(value); + FrozenJsonPatch patch = FrozenJsonPatch.replace("/target", frozenValue); + List patches = new ArrayList(); + patches.add(patch); ComputeEffectPlan plan = new ComputeEffectPlan( patches, Collections.emptyList(), false, null, true); value.getProperties().get("status").value("mutated-input"); patches.clear(); - List firstRead = plan.patches(); - firstRead.get(0).getVal().getProperties().get("status").value("mutated-output"); + List firstRead = plan.patches(); assertEquals(1, plan.patches().size()); - assertEquals("original", plan.patches().get(0).getVal().get("/status")); + assertSame(patch, plan.patches().get(0), + "immutable patches should be retained without rematerialization"); + assertSame(frozenValue, plan.patches().get(0).getVal()); + assertEquals("original", + plan.patches().get(0).getVal().property("status").getValue()); assertThrows(UnsupportedOperationException.class, firstRead::clear); } @Test void planPreservesEverySupportedPatchOperationAndRejectsNullPatches() { - List patches = new ArrayList(); - patches.add(JsonPatch.add("/added", new Node().value("value"))); - patches.add(JsonPatch.replace("/replaced", new Node().value("value"))); - patches.add(JsonPatch.remove("/removed")); + FrozenNode value = FrozenNode.fromNode(new Node().value("value")); + List patches = new ArrayList(); + patches.add(FrozenJsonPatch.add("/added", value)); + patches.add(FrozenJsonPatch.replace("/replaced", value)); + patches.add(FrozenJsonPatch.remove("/removed")); ComputeEffectPlan plan = new ComputeEffectPlan( patches, Collections.emptyList(), false, null, true); - assertEquals(JsonPatch.Op.ADD, plan.patches().get(0).getOp()); - assertEquals(JsonPatch.Op.REPLACE, plan.patches().get(1).getOp()); - assertEquals(JsonPatch.Op.REMOVE, plan.patches().get(2).getOp()); + assertEquals(blue.language.processor.model.JsonPatch.Op.ADD, + plan.patches().get(0).getOp()); + assertEquals(blue.language.processor.model.JsonPatch.Op.REPLACE, + plan.patches().get(1).getOp()); + assertEquals(blue.language.processor.model.JsonPatch.Op.REMOVE, + plan.patches().get(2).getOp()); assertThrows(IllegalArgumentException.class, () -> new ComputeEffectPlan(Collections.singletonList(null), Collections.emptyList(), false, null, false)); @@ -114,6 +126,25 @@ void emitterRejectsMissingExecutionResultOrPlan() { assertEquals("plan must not be null", missingPlan.getMessage()); } + @Test + void emitterRetainsStrictFrozenBexValuesAndMaterializesComputedValuesOnce() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeResultEmitter emitter = new ComputeResultEmitter(metrics); + FrozenNode retained = FrozenNode.fromNode(new Node() + .properties("kind", new Node().value("retained"))); + + FrozenNode direct = emitter.freezePatchValue(BexValues.frozen(retained)); + FrozenNode computed = emitter.freezePatchValue(BexValues.map( + Collections.singletonMap("kind", BexValues.scalar("computed")))); + + assertSame(retained, direct, + "strict BEX frozen values must cross the boundary by identity"); + assertTrue(computed.isStrictCanonical()); + assertEquals("computed", computed.property("kind").getValue()); + assertEquals(1L, metrics.bexPatchFrozenDirectConversions()); + assertEquals(1L, metrics.bexPatchNodeMaterializations()); + } + @Test void emitterTreatsMissingReturnedValueAsNoActiveEffects() { ComputeResultEmitter emitter = new ComputeResultEmitter(); diff --git a/src/test/java/blue/coordination/processor/workflow/ComputeProgramPlanCacheTest.java b/src/test/java/blue/coordination/processor/workflow/ComputeProgramPlanCacheTest.java new file mode 100644 index 0000000..38aa40b --- /dev/null +++ b/src/test/java/blue/coordination/processor/workflow/ComputeProgramPlanCacheTest.java @@ -0,0 +1,266 @@ +package blue.coordination.processor.workflow; + +import blue.bex.api.BexProgramSource; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ComputeProgramPlanCacheTest { + private static final String VERSION = "test-normalization-v1"; + + @Test + void firstLookupMissesAndPublishedPlanIsReusedByExactFrozenIdentity() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeProgramPlanCache cache = new ComputeProgramPlanCache(8, 1_000_000L, metrics); + FrozenNode rawStep = step("same"); + ComputeProgramPlan expected = plan(rawStep, null); + AtomicInteger builds = new AtomicInteger(); + ComputeProgramPlanCache.Key firstKey = key(rawStep, null, null); + + ComputeProgramPlanCache.Lookup first = cache.lookup(firstKey, () -> { + builds.incrementAndGet(); + return expected; + }); + cache.publish(first); + + // A distinct FrozenNode with exactly equivalent resolved content must + // prove the same key without relying on Java object identity. + ComputeProgramPlanCache.Lookup second = cache.lookup( + key(step("same"), null, null), + () -> { + throw new AssertionError("warm lookup rebuilt the plan"); + }); + + assertFalse(first.cacheHit()); + assertTrue(second.cacheHit()); + assertSame(expected, second.plan()); + assertEquals(1, builds.get()); + assertEquals(1L, metrics.computePlanCacheMisses()); + assertEquals(1L, metrics.computePlanCacheHits()); + assertEquals(1L, metrics.computePlansBuilt()); + assertEquals(1, cache.size()); + assertTrue(cache.weightBytes() > 0L); + assertEquals(cache.weightBytes(), metrics.computePlanWeightBytes()); + assertEquals(expected.sourceIdentity(), + blue.bex.compile.BexCompiledProgramKey.from(expected.source())); + } + + @Test + void changedStepDefinitionEntryOrNormalizationVersionCannotCollide() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeProgramPlanCache cache = new ComputeProgramPlanCache(8, 1_000_000L, metrics); + FrozenNode baseStep = step("base"); + FrozenNode definitionA = definition("A"); + FrozenNode definitionB = definition("B"); + + publish(cache, key(baseStep, definitionA, "run"), plan(baseStep, definitionA)); + + assertFalse(cache.lookup(key(step("changed"), definitionA, "run"), + () -> plan(step("changed"), definitionA)).cacheHit()); + assertFalse(cache.lookup(key(baseStep, definitionB, "run"), + () -> plan(baseStep, definitionB)).cacheHit()); + assertFalse(cache.lookup(key(baseStep, definitionA, "other"), + () -> plan(baseStep, definitionA)).cacheHit()); + ComputeProgramPlanCache.Key otherVersion = ComputeProgramPlanCache.Key.from( + baseStep, definitionA, "run", VERSION + "-changed"); + assertFalse(cache.lookup(otherVersion, + () -> plan(baseStep, definitionA)).cacheHit()); + + assertEquals(5L, metrics.computePlanCacheMisses()); + assertEquals(0L, metrics.computePlanCacheHits()); + } + + @Test + void leastRecentlyUsedPlansAreEvictedAndWeightGaugeTracksLiveEntries() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeProgramPlanCache cache = new ComputeProgramPlanCache(2, Long.MAX_VALUE, metrics); + FrozenNode rawA = step("A"); + FrozenNode rawB = step("B"); + FrozenNode rawC = step("C"); + ComputeProgramPlanCache.Key keyA = key(rawA, null, null); + ComputeProgramPlanCache.Key keyB = key(rawB, null, null); + + publish(cache, keyA, plan(rawA, null)); + publish(cache, keyB, plan(rawB, null)); + assertTrue(cache.lookup(keyA, + () -> { + throw new AssertionError("A should be cached"); + }).cacheHit()); + publish(cache, key(rawC, null, null), plan(rawC, null)); + + assertEquals(2, cache.size()); + assertEquals(1L, metrics.computePlanCacheEvictions()); + assertFalse(cache.lookup(keyB, () -> plan(rawB, null)).cacheHit()); + assertTrue(cache.weightBytes() > 0L); + assertEquals(cache.weightBytes(), metrics.computePlanWeightBytes()); + } + + @Test + void clearAndCloseReleaseAllWeightAndClosePreventsRepublishing() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeProgramPlanCache cache = new ComputeProgramPlanCache(4, 1_000_000L, metrics); + FrozenNode raw = step("clear"); + ComputeProgramPlan plan = plan(raw, null); + ComputeProgramPlanCache.Key key = key(raw, null, null); + + publish(cache, key, plan); + FrozenNode staleRaw = step("in-flight-before-clear"); + ComputeProgramPlanCache.Lookup staleCandidate = cache.lookup( + key(staleRaw, null, null), + () -> plan(staleRaw, null)); + cache.clear(); + cache.publish(staleCandidate); + + assertEquals(0, cache.size()); + assertEquals(0L, cache.weightBytes()); + assertEquals(0L, metrics.computePlanWeightBytes()); + ComputeProgramPlanCache.Lookup afterClear = cache.lookup(key, () -> plan); + cache.publish(afterClear); + assertEquals(1, cache.size()); + + cache.close(); + assertTrue(cache.isClosed()); + assertEquals(0, cache.size()); + assertEquals(0L, metrics.computePlanWeightBytes()); + ComputeProgramPlanCache.Lookup afterClose = cache.lookup(key, () -> plan); + cache.publish(afterClose); + assertEquals(0, cache.size()); + assertEquals(0L, cache.weightBytes()); + } + + @Test + void retainedWeightEstimatorDeduplicatesSharedFrozenSubgraphs() { + FrozenNode sharedChild = FrozenNode.fromResolvedNode(new Node().value("shared")); + FrozenNode shared = FrozenNode.empty() + .withProperty("left", sharedChild) + .withProperty("right", sharedChild); + FrozenNode duplicate = FrozenNode.empty() + .withProperty("left", FrozenNode.fromResolvedNode(new Node().value("shared"))) + .withProperty("right", FrozenNode.fromResolvedNode(new Node().value("shared"))); + + assertSame(shared.getProperties().get("left"), shared.getProperties().get("right")); + assertTrue(plan(shared, null).approximateWeightBytes() + < plan(duplicate, null).approximateWeightBytes()); + } + + @Test + void failedPlanBuildIsNeverPublishedOrReturnedAsAHit() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeProgramPlanCache cache = new ComputeProgramPlanCache(4, 1_000_000L, metrics); + FrozenNode raw = step("malformed"); + ComputeProgramPlanCache.Key key = key(raw, null, null); + + assertThrows(IllegalStateException.class, + () -> cache.lookup(key, () -> { + throw new IllegalStateException("malformed"); + })); + assertEquals(0, cache.size()); + + ComputeProgramPlan valid = plan(raw, null); + ComputeProgramPlanCache.Lookup retry = cache.lookup(key, () -> valid); + assertFalse(retry.cacheHit()); + cache.publish(retry); + + assertEquals(2L, metrics.computePlanCacheMisses()); + assertEquals(1L, metrics.computePlansBuilt()); + assertEquals(1, cache.size()); + } + + @Test + void concurrentWarmLookupsAreSafeAndNeverRebuild() throws Exception { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + ComputeProgramPlanCache cache = new ComputeProgramPlanCache(4, 1_000_000L, metrics); + FrozenNode raw = step("concurrent"); + ComputeProgramPlanCache.Key key = key(raw, null, null); + ComputeProgramPlan expected = plan(raw, null); + publish(cache, key, expected); + int threads = 8; + int lookupsPerThread = 100; + ExecutorService executor = Executors.newFixedThreadPool(threads); + List> futures = new ArrayList>(); + try { + for (int i = 0; i < threads; i++) { + futures.add(executor.submit(new Callable() { + @Override + public Void call() { + for (int j = 0; j < lookupsPerThread; j++) { + ComputeProgramPlanCache.Lookup lookup = cache.lookup(key, () -> { + throw new AssertionError("concurrent warm lookup rebuilt"); + }); + assertTrue(lookup.cacheHit()); + assertSame(expected, lookup.plan()); + } + return null; + } + })); + } + for (Future future : futures) { + future.get(10L, TimeUnit.SECONDS); + } + } finally { + executor.shutdownNow(); + } + + assertEquals((long) threads * lookupsPerThread, metrics.computePlanCacheHits()); + assertEquals(1L, metrics.computePlanCacheMisses()); + assertEquals(1L, metrics.computePlansBuilt()); + assertEquals(1, cache.size()); + } + + private static void publish(ComputeProgramPlanCache cache, + ComputeProgramPlanCache.Key key, + ComputeProgramPlan plan) { + ComputeProgramPlanCache.Lookup lookup = cache.lookup(key, () -> plan); + cache.publish(lookup); + } + + private static ComputeProgramPlanCache.Key key(FrozenNode rawStep, + FrozenNode definition, + String entry) { + return ComputeProgramPlanCache.Key.from(rawStep, definition, entry, VERSION); + } + + private static ComputeProgramPlan plan(FrozenNode rawStep, FrozenNode definition) { + BexProgramSource source = definition != null + ? BexProgramSource.withDefinition(rawStep, definition, null) + : BexProgramSource.inline(rawStep); + return new ComputeProgramPlan(rawStep, + definition, + source, + null, + 100_000L, + true, + true, + rawStep, + definition); + } + + private static FrozenNode step(String value) { + return FrozenNode.fromResolvedNode(new Node() + .name("Compute") + .properties("expr", new Node().value(value))); + } + + private static FrozenNode definition(String value) { + return FrozenNode.fromResolvedNode(new Node() + .name("Definition") + .properties("constants", new Node() + .properties("value", new Node().value(value)))); + } +} diff --git a/src/test/java/blue/coordination/processor/workflow/FrozenComputeDifferentialTest.java b/src/test/java/blue/coordination/processor/workflow/FrozenComputeDifferentialTest.java new file mode 100644 index 0000000..5d48f39 --- /dev/null +++ b/src/test/java/blue/coordination/processor/workflow/FrozenComputeDifferentialTest.java @@ -0,0 +1,565 @@ +package blue.coordination.processor.workflow; + +import blue.bex.BexException; +import blue.bex.api.BexEngine; +import blue.bex.api.BexExecutionContext; +import blue.bex.api.BexProgramSource; +import blue.bex.result.BexExecutionResult; +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.CoordinationProcessors; +import blue.coordination.processor.CoordinationTestResources; +import blue.coordination.processor.RepositoryTypeAliasPreprocessor; +import blue.coordination.processor.TestTimelineProvider; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.coordination.processor.bex.BexWorkflowContextFactory; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.ProcessorErrorCategory; +import blue.language.processor.ProcessorFatalException; +import blue.language.processor.ProcessorStatus; +import blue.language.processor.WorkingDocument; +import blue.language.processor.model.FrozenJsonPatch; +import blue.language.processor.model.JsonPatch; +import blue.language.processor.registry.RuntimeBlueIds; +import blue.language.snapshot.FrozenNode; +import blue.repo.BlueRepository; +import blue.repo.coordination.Compute; +import blue.repo.coordination.SequentialWorkflowStep; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +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.assertTrue; + +/** End-to-end differential for the frozen Compute patch handoff. */ +class FrozenComputeDifferentialTest { + + @Test + void computeChangesetEventsAndTerminationMatchTheLegacyMutableHandoff() { + Outcome frozen = run(false); + Outcome legacy = run(true); + + assertEquals(legacy.canonicalKey, frozen.canonicalKey, "final canonical document"); + assertEquals(legacy.resolvedKey, frozen.resolvedKey, "final resolved document"); + assertEquals(legacy.blueId, frozen.blueId, "final BlueId"); + assertEquals(legacy.documentUpdateEvents, frozen.documentUpdateEvents, + "all Document Update events and order"); + assertEquals(legacy.triggeredEvents, frozen.triggeredEvents, + "all triggered events and order"); + assertEquals(legacy.totalGas, frozen.totalGas, "gas"); + assertEquals(legacy.status, frozen.status, "status"); + assertEquals(legacy.errorCategory, frozen.errorCategory, "failure category"); + assertEquals(legacy.failureReason, frozen.failureReason, "failure reason"); + assertEquals(legacy.terminationMarker, frozen.terminationMarker, + "termination marker"); + assertEquals(legacy.channelCheckpoint, frozen.channelCheckpoint, + "channel checkpoint"); + + assertEquals(ProcessorStatus.SUCCESS, frozen.status, frozen.failureReason); + assertNull(frozen.failureReason); + assertEquals("value", frozen.document.get("/added/nested")); + assertEquals("final", frozen.document.get("/status")); + assertFalse(hasPath(frozen.document, "/removeMe")); + assertFalse(hasPath(frozen.document, "/mustNotRun")); + assertEquals("graceful", frozen.document.get("/contracts/terminated/cause")); + assertEquals("compute complete", frozen.document.get("/contracts/terminated/reason")); + assertNull(frozen.channelCheckpoint, + "graceful termination must not persist the source-channel checkpoint"); + assertEquals(Arrays.asList("first", "second"), selectedKinds(frozen.documentEvents)); + assertTrue(indexOfKind(frozen.documentEvents, "second") + < indexOfType(frozen.documentEvents, + RuntimeBlueIds.DOCUMENT_PROCESSING_TERMINATED), + "Compute events must remain ahead of the termination event"); + assertEquals(Arrays.asList( + "add:/added", + "replace:/status", + "replace:/status", + "remove:/removeMe"), + primaryUpdateOrder(frozen.documentEvents)); + + assertTrue(metricDelta(frozen, "frozenPatchesHandedToLanguage") > 0L); + assertEquals(0L, metricDelta(frozen, "mutablePatchesHandedToLanguage")); + assertTrue(metricDelta(frozen, "frozenPatchValuesAccepted") > 0L); + assertEquals(0L, metricDelta(frozen, "mutablePatchValuesFrozen"), + "initialization metrics must not be attributed to the Compute handoff"); + assertTrue(metricDelta(legacy, "mutablePatchesHandedToLanguage") > 0L); + assertTrue(metricDelta(legacy, "mutablePatchValuesFrozen") > 0L); + } + + private static Outcome run(boolean legacyMutableHandoff) { + BlueRepository repository = BlueRepository.latest(); + BexProcessingMetrics metrics = new BexProcessingMetrics(); + BexEngine engine = BexEngine.builder().build(); + SequentialWorkflowRunner runner = legacyMutableHandoff + ? legacyRunner(engine, metrics) + : SequentialWorkflowRunner.withBexEngine(engine, 100_000L, metrics); + Blue blue = CoordinationTestResources.configuredBlue(repository); + try { + CoordinationProcessors.registerWith(blue, CoordinationProcessorOptions.builder() + .bexEngine(engine) + .sequentialWorkflowRunner(runner) + .defaultComputeGasLimit(100_000L) + .processingMetrics(metrics) + .build()); + Node authored = blue.parseSourceYaml(documentYaml()); + authored.blue(repository.typeAliasBlue()); + Node aliasesResolved = new RepositoryTypeAliasPreprocessor( + CoordinationTestResources.testTypeAliases(repository)).preprocess(authored); + Node initialized = blue.initializeDocument(blue.preprocess(aliasesResolved)).document(); + Map languageCountersBeforeRun = metrics.languageCounters(); + Node event = TestTimelineProvider.timelineEntry(blue, + repository, + "owner", + 1, + TestTimelineProvider.chatMessage("run")); + + DocumentProcessingResult result = blue.processDocument(initialized, event); + List documentEvents = immutableClones(result.triggeredEvents()); + return new Outcome(result.document().clone(), + result.snapshot() != null + ? result.snapshot().frozenCanonicalRoot().resolvedStructuralKey() + : null, + result.snapshot() != null + ? result.snapshot().frozenResolvedRoot().resolvedStructuralKey() + : null, + result.blueId(), + jsonEvents(blue, documentEvents, false), + jsonEvents(blue, documentEvents, true), + result.totalGas(), + result.status(), + result.errorCategory(), + result.failureReason(), + jsonAt(blue, result.document(), "/contracts/terminated"), + jsonAt(blue, + result.document(), + "/contracts/checkpoint/lastEvents/ownerChannel"), + documentEvents, + metrics, + languageCountersBeforeRun); + } finally { + try { + blue.close(); + } finally { + runner.close(); + } + } + } + + private static SequentialWorkflowRunner legacyRunner(BexEngine engine, + BexProcessingMetrics metrics) { + return new SequentialWorkflowRunner(Arrays + .>asList( + new TriggerEventStepExecutor(metrics), + new LegacyMutableComputeExecutor(engine, 100_000L, metrics), + new TerminateProcessingStepExecutor(metrics), + new UpdateDocumentStepExecutor(metrics))); + } + + private static String documentYaml() { + return String.join("\n", + "name: Frozen Compute Differential", + "status: idle", + "removeMe: old", + "contracts:", + CoordinationTestResources.simpleTimelineChannelYaml("ownerChannel", "owner", 2), + " addedUpdates:", + " type: Document Update Channel", + " path: /added", + " statusUpdates:", + " type: Document Update Channel", + " path: /status", + " removedUpdates:", + " type: Document Update Channel", + " path: /removeMe", + documentUpdateObserver("observeAdded", "addedUpdates", "add", "/added"), + documentUpdateObserver("observeStatus", "statusUpdates", "replace", "/status"), + documentUpdateObserver("observeRemoved", "removedUpdates", "remove", "/removeMe"), + " run:", + " type: Coordination/Sequential Workflow", + " channel: ownerChannel", + " steps:", + " - name: Return ordered effects", + " type: Coordination/Compute", + " do:", + " - $return:", + " changeset:", + " - op: add", + " path: /added", + " val:", + " nested: value", + " - op: replace", + " path: /status", + " val: intermediate", + " - op: replace", + " path: /status", + " val: final", + " - op: remove", + " path: /removeMe", + " events:", + " - type: Coordination/Event", + " kind: first", + " - type: Coordination/Event", + " kind: second", + " termination:", + " reason: compute complete", + " - name: Must not run after termination", + " type: Coordination/Update Document", + " changeset:", + " - op: add", + " path: /mustNotRun", + " val: true"); + } + + /** + * Re-emits a stable trace from each exact-path Document Update channel. This + * makes every internally delivered update and its order observable through + * DocumentProcessingResult without depending on Language implementation + * internals. + */ + private static String documentUpdateObserver(String key, + String channel, + String op, + String path) { + return String.join("\n", + " " + key + ":", + " type: Coordination/Sequential Workflow", + " channel: " + channel, + " event:", + " type: Document Update", + " steps:", + " - type: Coordination/Trigger Event", + " event:", + " type: Coordination/Event", + " kind: document-update", + " op: " + op, + " path: " + path); + } + + private static List immutableClones(List events) { + List clones = new ArrayList(events.size()); + for (Node event : events) { + clones.add(event.clone()); + } + return Collections.unmodifiableList(clones); + } + + private static List jsonEvents(Blue blue, + List events, + boolean documentUpdatesOnly) { + List json = new ArrayList(); + for (Node event : events) { + if (!documentUpdatesOnly || isDocumentUpdateTrace(event)) { + json.add(blue.nodeToJson(event)); + } + } + return Collections.unmodifiableList(json); + } + + private static String jsonAt(Blue blue, Node document, String pointer) { + Node node = nodeAt(document, pointer); + return node != null ? blue.nodeToJson(node) : null; + } + + private static List selectedKinds(List events) { + List kinds = new ArrayList(); + for (Node event : events) { + Object kind = valueAt(event, "/kind"); + if ("first".equals(kind) || "second".equals(kind)) { + kinds.add((String) kind); + } + } + return kinds; + } + + private static List primaryUpdateOrder(List events) { + List updates = new ArrayList(); + for (Node event : events) { + if (!isDocumentUpdateTrace(event)) { + continue; + } + Object path = valueAt(event, "/path"); + if ("/added".equals(path) + || "/status".equals(path) + || "/removeMe".equals(path)) { + updates.add(String.valueOf(valueAt(event, "/op")) + ":" + path); + } + } + return updates; + } + + private static boolean isDocumentUpdateTrace(Node event) { + return "document-update".equals(valueAt(event, "/kind")); + } + + private static int indexOfKind(List events, String kind) { + for (int index = 0; index < events.size(); index++) { + if (kind.equals(valueAt(events.get(index), "/kind"))) { + return index; + } + } + return -1; + } + + private static int indexOfType(List events, String blueId) { + for (int index = 0; index < events.size(); index++) { + if (isType(events.get(index), blueId)) { + return index; + } + } + return -1; + } + + private static boolean isType(Node node, String blueId) { + return node != null + && node.getType() != null + && blueId.equals(node.getType().getBlueId()); + } + + private static Object valueAt(Node node, String pointer) { + try { + return node.get(pointer); + } catch (RuntimeException ex) { + return null; + } + } + + private static boolean hasPath(Node node, String pointer) { + return nodeAt(node, pointer) != null; + } + + private static Node nodeAt(Node node, String pointer) { + try { + return node != null ? node.getAsNode(pointer) : null; + } catch (RuntimeException ex) { + return null; + } + } + + private static long metricDelta(Outcome outcome, String name) { + return metric(outcome.metrics.languageCounters(), name) + - metric(outcome.languageCountersBeforeRun, name); + } + + private static long metric(Map counters, String name) { + Long value = counters.get(name); + return value != null ? value.longValue() : 0L; + } + + private static final class Outcome { + private final Node document; + private final Object canonicalKey; + private final Object resolvedKey; + private final String blueId; + private final List triggeredEvents; + private final List documentUpdateEvents; + private final long totalGas; + private final ProcessorStatus status; + private final ProcessorErrorCategory errorCategory; + private final String failureReason; + private final String terminationMarker; + private final String channelCheckpoint; + private final List documentEvents; + private final BexProcessingMetrics metrics; + private final Map languageCountersBeforeRun; + + private Outcome(Node document, + Object canonicalKey, + Object resolvedKey, + String blueId, + List triggeredEvents, + List documentUpdateEvents, + long totalGas, + ProcessorStatus status, + ProcessorErrorCategory errorCategory, + String failureReason, + String terminationMarker, + String channelCheckpoint, + List documentEvents, + BexProcessingMetrics metrics, + Map languageCountersBeforeRun) { + this.document = document; + this.canonicalKey = canonicalKey; + this.resolvedKey = resolvedKey; + this.blueId = blueId; + this.triggeredEvents = triggeredEvents; + this.documentUpdateEvents = documentUpdateEvents; + this.totalGas = totalGas; + this.status = status; + this.errorCategory = errorCategory; + this.failureReason = failureReason; + this.terminationMarker = terminationMarker; + this.channelCheckpoint = channelCheckpoint; + this.documentEvents = documentEvents; + this.metrics = metrics; + this.languageCountersBeforeRun = languageCountersBeforeRun; + } + } + + /** + * Test-only reproduction of the legacy mutable Language patch handoff. + * Planning and BEX execution stay shared so the differential isolates the + * mutable-versus-frozen boundary under test. + */ + private static final class LegacyMutableComputeExecutor + implements WorkflowStepExecutor { + private final BexEngine bexEngine; + private final long defaultGasLimit; + private final ComputeDefinitionResolver definitionResolver; + private final BexWorkflowContextFactory contextFactory; + private final ComputeResultEmitter resultPlanner; + private final ComputeProgramNormalizer normalizer; + private final BexProcessingMetrics metrics; + + private LegacyMutableComputeExecutor(BexEngine bexEngine, + long defaultGasLimit, + BexProcessingMetrics metrics) { + this.bexEngine = bexEngine; + this.defaultGasLimit = defaultGasLimit; + this.definitionResolver = new ComputeDefinitionResolver(metrics); + this.contextFactory = new BexWorkflowContextFactory(metrics); + this.resultPlanner = new ComputeResultEmitter(metrics); + this.normalizer = new ComputeProgramNormalizer(metrics); + this.metrics = metrics; + } + + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof Compute; + } + + @Override + public WorkflowStepResult execute(Compute step, StepExecutionContext context) { + long stepStart = System.nanoTime(); + try { + metrics.incrementComputeStepsExecuted(); + FrozenNode rawStep = context.stepFrozenNode(); + if (rawStep == null) { + Node mutableStep = context.stepNodeRef(); + if (mutableStep == null) { + context.processorContext().throwFatal( + "Compute step must have a raw step node"); + return WorkflowStepResult.none(); + } + rawStep = FrozenNode.fromResolvedNode(mutableStep); + } + FrozenNode resolvedDefinition = definitionResolver.resolve(rawStep, + context, + metrics); + FrozenNode program = normalizer.program(rawStep); + FrozenNode definition = resolvedDefinition != null + ? normalizer.definition(resolvedDefinition) + : null; + String authoredEntry = FrozenNodeUtil.textProperty(rawStep, "entry"); + String normalizedEntry = FrozenNodeUtil.textProperty(program, "entry"); + if (!Objects.equals(authoredEntry, normalizedEntry)) { + throw new BexException("Compute entry changed during normalization"); + } + BexProgramSource source = definition != null + ? BexProgramSource.withDefinition(program, definition, normalizedEntry) + : BexProgramSource.inline(program); + long gasLimit = gasLimit(program); + BexExecutionContext bexContext = contextFactory.create(context, gasLimit); + BexExecutionResult execution = bexEngine.compileAndExecute(source, bexContext); + metrics.addBexMetrics(execution.metrics()); + if (execution.gasUsed() > 0L) { + context.processorContext().consumeGas(execution.gasUsed()); + } + ComputeEffectPlan effects = resultPlanner.plan(execution, + context, + FrozenNodeUtil.booleanProperty(program, "emitEvents", true)); + bufferThroughLegacyMutableApi(effects, context); + if (effects.terminationRequested()) { + return FrozenNodeUtil.booleanProperty(program, "returnResult", true) + ? WorkflowStepResult.terminalValue(execution, + effects.changesetHandled()) + : WorkflowStepResult.terminal(); + } + return FrozenNodeUtil.booleanProperty(program, "returnResult", true) + ? WorkflowStepResult.value(execution, effects.changesetHandled()) + : WorkflowStepResult.none(); + } catch (ComputeResultValidationException ex) { + metrics.incrementComputeResultValidationFailures(); + context.processorContext().throwFatal( + "Invalid Compute result: " + ex.getMessage()); + return WorkflowStepResult.none(); + } catch (ProcessorFatalException ex) { + throw ex; + } catch (BexException ex) { + context.processorContext().throwFatal("Compute failed: " + ex.getMessage()); + return WorkflowStepResult.none(); + } catch (RuntimeException ex) { + context.processorContext().throwFatal("Compute failed: " + ex.getMessage()); + return WorkflowStepResult.none(); + } finally { + metrics.addComputeStepNanos(System.nanoTime() - stepStart); + } + } + + private long gasLimit(FrozenNode program) { + Long configured = FrozenNodeUtil.integer( + FrozenNodeUtil.property(program, "gasLimit")); + if (configured == null) { + return defaultGasLimit; + } + if (configured.longValue() <= 0L) { + throw new BexException("Compute gasLimit must be positive"); + } + return configured.longValue(); + } + + private void bufferThroughLegacyMutableApi(ComputeEffectPlan effects, + StepExecutionContext context) { + effects.claimForBuffering(); + List patches = mutablePatches(effects.patches()); + if (!patches.isEmpty()) { + WorkingDocument.Preview preview = null; + boolean transferred = false; + try { + preview = context.advanceWorkingDocument(patches); + if (preview != null) { + metrics.addMetric("mutablePatchesHandedToLanguage", patches.size()); + context.processorContext().applyPreviewedPatches(patches, preview); + transferred = true; + metrics.addPatchesApplied(patches.size()); + metrics.incrementUpdateBatchPatchApplications(); + } + } finally { + if (!transferred && preview != null) { + preview.close(); + } + } + } + for (FrozenNode event : effects.events()) { + context.processorContext().emitEvent(event.toNode()); + metrics.incrementEventsEmitted(); + } + if (effects.terminationRequested()) { + context.processorContext().terminateGracefully(effects.terminationReason()); + metrics.incrementSuccessfulComputeTerminationRequests(); + } + } + + private List mutablePatches(List frozenPatches) { + List mutable = new ArrayList(frozenPatches.size()); + for (FrozenJsonPatch patch : frozenPatches) { + if (patch.getOp() == JsonPatch.Op.ADD) { + mutable.add(JsonPatch.add(patch.getPath(), patch.getValue().toNode())); + } else if (patch.getOp() == JsonPatch.Op.REPLACE) { + mutable.add(JsonPatch.replace(patch.getPath(), patch.getValue().toNode())); + } else { + mutable.add(JsonPatch.remove(patch.getPath())); + } + } + return mutable; + } + } +} diff --git a/src/test/java/blue/coordination/processor/workflow/FrozenUpdateDocumentDifferentialTest.java b/src/test/java/blue/coordination/processor/workflow/FrozenUpdateDocumentDifferentialTest.java new file mode 100644 index 0000000..741bef4 --- /dev/null +++ b/src/test/java/blue/coordination/processor/workflow/FrozenUpdateDocumentDifferentialTest.java @@ -0,0 +1,503 @@ +package blue.coordination.processor.workflow; + +import blue.bex.api.BexEngine; +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.CoordinationProcessors; +import blue.coordination.processor.CoordinationTestResources; +import blue.coordination.processor.TestTimelineProvider; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.Blue; +import blue.language.model.Node; +import blue.language.processor.DocumentProcessingRuntime; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.ProcessorErrorCategory; +import blue.language.processor.ProcessorStatus; +import blue.language.processor.WorkingDocument; +import blue.language.processor.model.FrozenJsonPatch; +import blue.language.processor.model.JsonPatch; +import blue.language.snapshot.FrozenNode; +import blue.repo.BlueRepository; +import blue.repo.coordination.ChatMessage; +import blue.repo.coordination.SequentialWorkflowStep; +import blue.repo.coordination.UpdateDocument; +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 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Differential coverage for the test-only legacy mutable Update Document lane. */ +class FrozenUpdateDocumentDifferentialTest { + private static final String TEXT_BLUE_ID = + "GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC"; + + @Test + void orderedStructuralTypedReferenceAndReentrantUpdatesMatchLegacyLane() { + Outcome frozen = run(false, new DocumentFactory() { + @Override + public Node build(BlueRepository repository) { + return broadPatchDocument(repository); + } + }); + Outcome legacy = run(true, new DocumentFactory() { + @Override + public Node build(BlueRepository repository) { + return broadPatchDocument(repository); + } + }); + + assertEquivalent(frozen, legacy); + assertEquals("second", frozen.document.getAsText("/status")); + assertEquals("child-after-parent", frozen.document.getAsText("/parent/child")); + assertEquals("ZERO", frozen.document.getAsText("/rows/0")); + assertEquals("inserted", frozen.document.getAsText("/rows/1")); + assertEquals("one", frozen.document.getAsText("/rows/2")); + assertNull(nodeAt(frozen.document, "/removeMe")); + assertEquals(TEXT_BLUE_ID, frozen.document.getAsNode("/pureReference").getBlueId()); + assertEquals("typed text", frozen.document.get("/typed")); + assertEquals(2, frozen.document.getAsNode("/generalized").getItems().size()); + assertEquals("embedded payload", frozen.document.getAsNode("/embeddedValue").getName()); + assertEquals("seen", frozen.document.getAsText("/observed")); + assertFalse(frozen.triggeredEventsJson.isEmpty()); + assertTrue(metric(frozen.metrics, "frozenPatchesHandedToLanguage") > 0L); + assertEquals(0L, metric(frozen.metrics, "mutablePatchesHandedToLanguage")); + assertTrue(metric(legacy.metrics, "mutablePatchesHandedToLanguage") > 0L); + } + + @Test + void failureOnPatchNHasTheSameFailureAndCommittedPrefix() { + Outcome frozen = run(false, new DocumentFactory() { + @Override + public Node build(BlueRepository repository) { + return failureDocument(repository); + } + }); + Outcome legacy = run(true, new DocumentFactory() { + @Override + public Node build(BlueRepository repository) { + return failureDocument(repository); + } + }); + + assertEquivalent(frozen, legacy); + assertEquals(ProcessorStatus.RUNTIME_FATAL, frozen.status); + assertNotNull(frozen.failureReason); + assertTrue(frozen.failureReason.contains( + "Path does not exist for remove: /patchNTarget"), frozen.failureReason); + assertEquals("prefix-one", frozen.document.getAsText("/status")); + assertEquals("prefix-one", legacy.document.getAsText("/status")); + assertEquals("prefix-two", frozen.document.getAsText("/secondPrefix")); + assertEquals("prefix-two", legacy.document.getAsText("/secondPrefix")); + assertNull(nodeAt(frozen.document, "/patchNTarget")); + assertNull(nodeAt(legacy.document, "/patchNTarget")); + assertNull(nodeAt(frozen.document, "/mustNotAppear")); + assertNull(nodeAt(legacy.document, "/mustNotAppear")); + assertTrue(metric(frozen.metrics, "frozenPatchesHandedToLanguage") >= 4L, + "the full frozen sequence must cross the Language boundary before patch N fails"); + assertTrue(metric(legacy.metrics, "mutablePatchesHandedToLanguage") >= 4L, + "the full legacy sequence must cross the Language boundary before patch N fails"); + } + + @Test + void gracefulTerminationKeepsPriorChangesAndSkipsLaterPatchProduction() { + Outcome frozen = run(false, new DocumentFactory() { + @Override + public Node build(BlueRepository repository) { + return terminationDocument(repository); + } + }); + Outcome legacy = run(true, new DocumentFactory() { + @Override + public Node build(BlueRepository repository) { + return terminationDocument(repository); + } + }); + + assertEquivalent(frozen, legacy); + assertEquals("before termination", frozen.document.getAsText("/status")); + assertNull(nodeAt(frozen.document, "/mustNotAppear")); + assertNotNull(frozen.document.get("/contracts/terminated")); + assertEquals(1L, metric(frozen.metrics, "frozenPatchesHandedToLanguage")); + } + + @Test + void embeddedScopePointerResolutionMatchesLegacyLane() { + Outcome frozen = run(false, new DocumentFactory() { + @Override + public Node build(BlueRepository repository) { + return embeddedDocument(repository); + } + }); + Outcome legacy = run(true, new DocumentFactory() { + @Override + public Node build(BlueRepository repository) { + return embeddedDocument(repository); + } + }); + + assertEquivalent(frozen, legacy); + assertEquals(100, ((Number) frozen.document.get("/counter")).intValue()); + assertEquals(7, ((Number) frozen.document.get("/child/counter")).intValue()); + } + + @Test + void expandedReferenceLikeValueMatchesLegacyAtTheLanguageBoundary() { + BlueRepository repository = BlueRepository.latest(); + // Repository lookup returns a resolved view whose root combines blueId with expanded + // content. Remove the reference marker to model the equivalent authored expansion; + // FrozenJsonPatch must continue rejecting the ambiguous resolved representation. + Node expanded = repository.nodeByBlueId(ChatMessage.blueId()) + .orElseThrow(() -> new AssertionError("Chat Message type missing")) + .clone() + .blueId(null); + Node mutableDocument = new Node(); + Node frozenDocument = new Node(); + + new DocumentProcessingRuntime(mutableDocument).applyPatches("/", Collections.singletonList( + JsonPatch.add("/expanded", expanded.clone()))); + new DocumentProcessingRuntime(frozenDocument).applyFrozenPatches("/", Collections.singletonList( + FrozenJsonPatch.add("/expanded", FrozenNode.fromNode(expanded)))); + + Blue blue = CoordinationTestResources.configuredBlue(repository); + try { + assertEquals(blue.calculateBlueId(mutableDocument), blue.calculateBlueId(frozenDocument)); + assertEquals(mutableDocument.getAsNode("/expanded").getName(), + frozenDocument.getAsNode("/expanded").getName()); + } finally { + blue.close(); + } + } + + private static Node broadPatchDocument(BlueRepository repository) { + Map contracts = ownerContracts(); + contracts.put("allUpdates", documentUpdateChannel("/")); + contracts.put("statusUpdates", documentUpdateChannel("/status")); + contracts.put("observedUpdates", documentUpdateChannel("/observed")); + contracts.put("writer", directWorkflow("owner", + updateDocumentStep( + patch("add", "/added", new Node().value("added")), + patch("replace", "/status", new Node().value("first")), + patch("replace", "/status", new Node().value("second")), + patch("replace", "/parent", new Node() + .properties("child", new Node().value("from-parent")) + .properties("keep", new Node().value(false))), + patch("replace", "/parent/child", new Node().value("child-after-parent")), + patch("add", "/rows/1", new Node().value("inserted")), + patch("replace", "/rows/0", new Node().value("ZERO")), + patch("remove", "/rows/3", null), + patch("remove", "/removeMe", null), + patch("add", "/typed", new Node() + .type(new Node().blueId(TEXT_BLUE_ID)) + .value("typed text")), + patch("add", "/generalized", new Node() + .type("List") + .itemType(new Node().blueId(TEXT_BLUE_ID)) + .items(new Node().value("one"), new Node().value("two"))), + patch("add", "/pureReference", new Node().blueId(TEXT_BLUE_ID)), + patch("add", "/embeddedValue", new Node() + .name("embedded payload") + .properties("counter", new Node().value(1)))))); + contracts.put("reentrantWriter", directWorkflowMatching("statusUpdates", + new Node().type("Document Update"), + updateDocumentStep(patch("replace", "/observed", new Node().value("seen"))))); + contracts.put("reentrantObserver", directWorkflowMatching("observedUpdates", + new Node().type("Document Update"), + triggerEventStep("reentrant update observed"))); + return root(repository, contracts) + .properties("status", new Node().value("initial")) + .properties("observed", new Node().value("not yet")) + .properties("removeMe", new Node().value("gone")) + .properties("parent", new Node() + .properties("child", new Node().value("old")) + .properties("keep", new Node().value(true))) + .properties("rows", new Node().items( + new Node().value("zero"), + new Node().value("one"), + new Node().value("two"))); + } + + private static Node failureDocument(BlueRepository repository) { + Map contracts = ownerContracts(); + contracts.put("statusUpdates", documentUpdateChannel("/status")); + contracts.put("writer", directWorkflow("owner", + updateDocumentStep( + patch("replace", "/status", new Node().value("prefix-one")), + patch("add", "/secondPrefix", new Node().value("prefix-two")), + patch("remove", "/patchNTarget", null), + patch("add", "/mustNotAppear", new Node().value(true))))); + // Patch 1 routes a Document Update that removes patch N's target. The + // complete outer sequence therefore previews successfully, patch 1 and + // patch 2 commit, then the runtime rebase makes patch N fail. This is + // deliberately different from a preview-time batch rollback. + contracts.put("invalidatePatchN", directWorkflowMatching("statusUpdates", + new Node().type("Document Update"), + updateDocumentStep(patch("remove", "/patchNTarget", null)))); + return root(repository, contracts) + .properties("status", new Node().value("initial")) + .properties("patchNTarget", new Node().value("present during preview")); + } + + private static Node terminationDocument(BlueRepository repository) { + Map contracts = ownerContracts(); + contracts.put("writer", directWorkflow("owner", + updateDocumentStep(patch("replace", "/status", new Node().value("before termination"))), + new Node().type("Coordination/Terminate Processing") + .properties("reason", new Node().value("finished intentionally")), + updateDocumentStep(patch("add", "/mustNotAppear", new Node().value(true))))); + return root(repository, contracts).properties("status", new Node().value("initial")); + } + + private static Node embeddedDocument(BlueRepository repository) { + Map childContracts = ownerContracts(); + childContracts.put("writer", directWorkflow("owner", + updateDocumentStep(patch("replace", "/counter", new Node().value(7))))); + Map rootContracts = new LinkedHashMap(); + rootContracts.put("embedded", new Node() + .type("Process Embedded") + .properties("paths", new Node().items(new Node().value("/child")))); + return root(repository, rootContracts) + .properties("counter", new Node().value(100)) + .properties("child", new Node() + .name("Child") + .properties("counter", new Node().value(0)) + .properties("contracts", new Node().properties(childContracts))); + } + + private static Node root(BlueRepository repository, Map contracts) { + return new Node() + .blue(repository.typeAliasBlue()) + .name("Frozen Update Differential") + .properties("contracts", new Node().properties(contracts)); + } + + private static Map ownerContracts() { + Map contracts = new LinkedHashMap(); + contracts.put("owner", TestTimelineProvider.channel("owner")); + return contracts; + } + + private static Node directWorkflow(String channel, Node... steps) { + return new Node() + .type("Coordination/Sequential Workflow") + .properties("channel", new Node().value(channel)) + .properties("steps", new Node().items(steps)); + } + + private static Node directWorkflowMatching(String channel, Node event, Node... steps) { + return directWorkflow(channel, steps).properties("event", event); + } + + private static Node updateDocumentStep(Node... patches) { + return new Node() + .type("Coordination/Update Document") + .properties("changeset", new Node().items(patches)); + } + + private static Node patch(String op, String path, Node value) { + Node patch = new Node() + .properties("op", new Node().value(op)) + .properties("path", new Node().value(path)); + if (value != null) { + patch.properties("val", value); + } + return patch; + } + + private static Node documentUpdateChannel(String path) { + return new Node() + .type("Document Update Channel") + .properties("path", new Node().value(path)); + } + + private static Node triggerEventStep(String message) { + return new Node() + .type("Coordination/Trigger Event") + .properties("event", new Node() + .type("Coordination/Chat Message") + .properties("message", new Node().value(message))); + } + + private static Outcome run(boolean legacy, DocumentFactory factory) { + BlueRepository repository = BlueRepository.latest(); + BexProcessingMetrics metrics = new BexProcessingMetrics(); + SequentialWorkflowRunner runner = legacy + ? legacyRunner(metrics) + : SequentialWorkflowRunner.withBexEngine( + BexEngine.builder().build(), 100_000L, metrics); + Blue blue = CoordinationTestResources.configuredBlue(repository); + try { + CoordinationProcessors.registerWith(blue, CoordinationProcessorOptions.builder() + .sequentialWorkflowRunner(runner) + .processingMetrics(metrics) + .build()); + Node initialized = blue.initializeDocument(blue.preprocess(factory.build(repository))).document(); + Node event = TestTimelineProvider.timelineEntry(blue, + repository, + "owner", + 1, + TestTimelineProvider.chatMessage("run")); + DocumentProcessingResult result = blue.processDocument(initialized, event); + List triggeredEventsJson = new ArrayList(result.triggeredEvents().size()); + for (Node triggered : result.triggeredEvents()) { + triggeredEventsJson.add(blue.nodeToJson(triggered)); + } + return new Outcome(result.document().clone(), + result.snapshot() != null + ? result.snapshot().frozenCanonicalRoot().resolvedStructuralKey() + : null, + result.snapshot() != null + ? result.snapshot().frozenResolvedRoot().resolvedStructuralKey() + : null, + result.blueId(), + triggeredEventsJson, + result.totalGas(), + result.status(), + result.errorCategory(), + result.failureReason(), + metrics); + } finally { + try { + blue.close(); + } finally { + runner.close(); + } + } + } + + private static SequentialWorkflowRunner legacyRunner(BexProcessingMetrics metrics) { + return new SequentialWorkflowRunner(Arrays + .>asList( + new TriggerEventStepExecutor(metrics), + new TerminateProcessingStepExecutor(metrics), + new LegacyMutableUpdateExecutor(metrics))); + } + + private static void assertEquivalent(Outcome frozen, Outcome legacy) { + assertEquals(legacy.canonicalKey, frozen.canonicalKey, "canonical document"); + assertEquals(legacy.resolvedKey, frozen.resolvedKey, "resolved document"); + assertEquals(legacy.blueId, frozen.blueId, "final BlueId"); + assertEquals(legacy.triggeredEventsJson, frozen.triggeredEventsJson, + "triggered events and order"); + assertEquals(legacy.totalGas, frozen.totalGas, "gas"); + assertEquals(legacy.status, frozen.status, "status"); + assertEquals(legacy.errorCategory, frozen.errorCategory, "failure category"); + assertEquals(legacy.failureReason, frozen.failureReason, "failure reason"); + } + + private static long metric(BexProcessingMetrics metrics, String name) { + Long value = metrics.languageCounters().get(name); + return value != null ? value.longValue() : 0L; + } + + private static Node nodeAt(Node root, String path) { + try { + return root != null ? root.getAsNode(path) : null; + } catch (IllegalArgumentException ex) { + return null; + } + } + + private interface DocumentFactory { + Node build(BlueRepository repository); + } + + private static final class Outcome { + private final Node document; + private final Object canonicalKey; + private final Object resolvedKey; + private final String blueId; + private final List triggeredEventsJson; + private final long totalGas; + private final ProcessorStatus status; + private final ProcessorErrorCategory errorCategory; + private final String failureReason; + private final BexProcessingMetrics metrics; + + private Outcome(Node document, + Object canonicalKey, + Object resolvedKey, + String blueId, + List triggeredEventsJson, + long totalGas, + ProcessorStatus status, + ProcessorErrorCategory errorCategory, + String failureReason, + BexProcessingMetrics metrics) { + this.document = document; + this.canonicalKey = canonicalKey; + this.resolvedKey = resolvedKey; + this.blueId = blueId; + this.triggeredEventsJson = Collections.unmodifiableList( + new ArrayList(triggeredEventsJson)); + this.totalGas = totalGas; + this.status = status; + this.errorCategory = errorCategory; + this.failureReason = failureReason; + this.metrics = metrics; + } + } + + /** Test-only reproduction of the legacy mutable Language handoff. */ + private static final class LegacyMutableUpdateExecutor + implements WorkflowStepExecutor { + private final BexProcessingMetrics metrics; + + private LegacyMutableUpdateExecutor(BexProcessingMetrics metrics) { + this.metrics = metrics; + } + + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof UpdateDocument; + } + + @Override + public WorkflowStepResult execute(UpdateDocument step, StepExecutionContext context) { + StaticUpdatePlan plan = context.staticUpdatePlan(); + if (plan == null) { + context.processorContext().throwFatal("Legacy differential lane requires a static plan"); + return WorkflowStepResult.none(); + } + List patches = new ArrayList(plan.patches().size()); + for (StaticUpdatePlan.PatchTemplate template : plan.patches()) { + FrozenJsonPatch frozen = template.bind(context.processorContext() + .resolvePointer(template.authoredPath())); + if (frozen.getOp() == JsonPatch.Op.ADD) { + patches.add(JsonPatch.add(frozen.getPath(), frozen.getValue().toNode())); + } else if (frozen.getOp() == JsonPatch.Op.REPLACE) { + patches.add(JsonPatch.replace(frozen.getPath(), frozen.getValue().toNode())); + } else { + patches.add(JsonPatch.remove(frozen.getPath())); + } + } + if (patches.isEmpty()) { + return WorkflowStepResult.none(); + } + WorkingDocument.Preview preview = null; + boolean transferred = false; + try { + preview = context.advanceWorkingDocument(patches); + if (preview == null) { + return WorkflowStepResult.none(); + } + metrics.addMetric("mutablePatchesHandedToLanguage", patches.size()); + context.processorContext().applyPreviewedPatches(patches, preview); + transferred = true; + return WorkflowStepResult.none(); + } finally { + if (!transferred && preview != null) { + preview.close(); + } + } + } + } +} diff --git a/src/test/java/blue/coordination/processor/workflow/SequentialWorkflowPlanCacheTest.java b/src/test/java/blue/coordination/processor/workflow/SequentialWorkflowPlanCacheTest.java new file mode 100644 index 0000000..a76c64e --- /dev/null +++ b/src/test/java/blue/coordination/processor/workflow/SequentialWorkflowPlanCacheTest.java @@ -0,0 +1,275 @@ +package blue.coordination.processor.workflow; + +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.snapshot.FrozenNode; +import blue.repo.coordination.SequentialWorkflowStep; +import blue.repo.coordination.TriggerEvent; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SequentialWorkflowPlanCacheTest { + @Test + void exactEquivalentFrozenContractsReusePlanAndExecutorSelection() { + FrozenNode firstContract = contract("Same", "Run"); + FrozenNode equivalentContract = contract("Same", "Run"); + AtomicInteger supportsCalls = new AtomicInteger(); + List> executors = + Collections.>singletonList( + countingTriggerExecutor(supportsCalls)); + SequentialWorkflowPlanCache cache = new SequentialWorkflowPlanCache(8, 1024L * 1024L, null); + AtomicInteger builds = new AtomicInteger(); + + SequentialWorkflowPlan first = cache.getOrBuild(firstContract.resolvedStructuralKey(), + planFactory(firstContract, executors, builds)); + SequentialWorkflowPlan reused = cache.getOrBuild(equivalentContract.resolvedStructuralKey(), + planFactory(equivalentContract, executors, builds)); + + assertSame(first, reused); + assertEquals(1, builds.get()); + assertEquals(1, supportsCalls.get()); + assertEquals("Run", reused.step(0).key()); + assertTrue(reused.step(0).matches(new TriggerEvent())); + } + + @Test + void changedContractIdentityBuildsIndependentPlan() { + FrozenNode firstContract = contract("First", "Run"); + FrozenNode changedContract = contract("Changed", "Run"); + List> executors = + Collections.>singletonList( + countingTriggerExecutor(new AtomicInteger())); + SequentialWorkflowPlanCache cache = new SequentialWorkflowPlanCache(8, 1024L * 1024L, null); + AtomicInteger builds = new AtomicInteger(); + + SequentialWorkflowPlan first = cache.getOrBuild(firstContract.resolvedStructuralKey(), + planFactory(firstContract, executors, builds)); + SequentialWorkflowPlan changed = cache.getOrBuild(changedContract.resolvedStructuralKey(), + planFactory(changedContract, executors, builds)); + + assertNotSame(first, changed); + assertEquals(2, builds.get()); + assertEquals(2, cache.size()); + } + + @Test + void entryBoundUsesAccessOrderAndEvictedPlanRebuilds() { + FrozenNode firstContract = contract("First", "One"); + FrozenNode secondContract = contract("Second", "Two"); + FrozenNode thirdContract = contract("Third", "Three"); + List> executors = + Collections.>singletonList( + countingTriggerExecutor(new AtomicInteger())); + SequentialWorkflowPlanCache cache = new SequentialWorkflowPlanCache(2, Long.MAX_VALUE, null); + AtomicInteger builds = new AtomicInteger(); + + SequentialWorkflowPlan first = cache.getOrBuild(firstContract.resolvedStructuralKey(), + planFactory(firstContract, executors, builds)); + SequentialWorkflowPlan second = cache.getOrBuild(secondContract.resolvedStructuralKey(), + planFactory(secondContract, executors, builds)); + assertSame(first, cache.getOrBuild(firstContract.resolvedStructuralKey(), + planFactory(firstContract, executors, builds))); + cache.getOrBuild(thirdContract.resolvedStructuralKey(), + planFactory(thirdContract, executors, builds)); + SequentialWorkflowPlan rebuiltSecond = cache.getOrBuild(secondContract.resolvedStructuralKey(), + planFactory(secondContract, executors, builds)); + + assertNotSame(second, rebuiltSecond); + assertEquals(4, builds.get()); + assertEquals(2, cache.size()); + } + + @Test + void weightBoundEvictsAndOversizedPlansAreNotRetained() { + FrozenNode firstContract = contract("First", "One"); + FrozenNode secondContract = contract("Second", "Two"); + List> executors = + Collections.>singletonList( + countingTriggerExecutor(new AtomicInteger())); + SequentialWorkflowPlan firstPlan = buildPlan(firstContract, executors); + SequentialWorkflowPlan secondPlan = buildPlan(secondContract, executors); + long firstEntryWeight = firstPlan.approximateWeightBytes() + 64L; + long secondEntryWeight = secondPlan.approximateWeightBytes() + 64L; + long oneEntryLimit = Math.max(firstEntryWeight, secondEntryWeight); + SequentialWorkflowPlanCache bounded = new SequentialWorkflowPlanCache(8, oneEntryLimit, null); + + bounded.getOrBuild(firstContract.resolvedStructuralKey(), () -> firstPlan); + bounded.getOrBuild(secondContract.resolvedStructuralKey(), () -> secondPlan); + + assertEquals(1, bounded.size()); + assertTrue(bounded.weightBytes() <= oneEntryLimit); + + SequentialWorkflowPlanCache oversized = new SequentialWorkflowPlanCache(8, + firstPlan.approximateWeightBytes(), + null); + AtomicInteger oversizedBuilds = new AtomicInteger(); + oversized.getOrBuild(firstContract.resolvedStructuralKey(), + countingFactory(firstPlan, oversizedBuilds)); + oversized.getOrBuild(firstContract.resolvedStructuralKey(), + countingFactory(firstPlan, oversizedBuilds)); + assertEquals(2, oversizedBuilds.get()); + assertEquals(0, oversized.size()); + assertEquals(0L, oversized.weightBytes()); + } + + @Test + void clearAndCloseReleaseRetainedWeightAndPreventRepopulation() { + FrozenNode contract = contract("Clear", "Run"); + List> executors = + Collections.>singletonList( + countingTriggerExecutor(new AtomicInteger())); + SequentialWorkflowPlanCache cache = new SequentialWorkflowPlanCache(8, 1024L * 1024L, null); + cache.getOrBuild(contract.resolvedStructuralKey(), + planFactory(contract, executors, new AtomicInteger())); + + assertEquals(1, cache.size()); + assertTrue(cache.weightBytes() > 0L); + cache.close(); + assertTrue(cache.isClosed()); + assertEquals(0, cache.size()); + assertEquals(0L, cache.weightBytes()); + + AtomicInteger buildsAfterClose = new AtomicInteger(); + SequentialWorkflowPlan uncached = cache.getOrBuild(contract.resolvedStructuralKey(), + planFactory(contract, executors, buildsAfterClose)); + assertEquals(contract.resolvedStructuralKey(), uncached.contractIdentity()); + assertEquals(1, buildsAfterClose.get()); + assertEquals(0, cache.size()); + assertEquals(0L, cache.weightBytes()); + } + + @Test + void cachePublishesHitsMissesBuildsEvictionsLookupsAndCurrentWeight() { + FrozenNode firstContract = contract("First metrics", "One"); + FrozenNode secondContract = contract("Second metrics", "Two"); + BexProcessingMetrics metrics = new BexProcessingMetrics(); + List> executors = + Collections.>singletonList( + countingTriggerExecutor(new AtomicInteger())); + SequentialWorkflowPlanCache cache = new SequentialWorkflowPlanCache(1, 1024L * 1024L, metrics); + + cache.getOrBuild(firstContract.resolvedStructuralKey(), + metricsPlanFactory(firstContract, executors, metrics)); + cache.getOrBuild(contract("First metrics", "One").resolvedStructuralKey(), + metricsPlanFactory(firstContract, executors, metrics)); + cache.getOrBuild(secondContract.resolvedStructuralKey(), + metricsPlanFactory(secondContract, executors, metrics)); + + assertEquals(2L, metrics.workflowPlansBuilt()); + assertEquals(1L, metrics.workflowPlanCacheHits()); + assertEquals(2L, metrics.workflowPlanCacheMisses()); + assertEquals(1L, metrics.workflowPlanCacheEvictions()); + assertEquals(2L, metrics.workflowExecutorLookups()); + assertEquals(cache.weightBytes(), metrics.workflowPlanWeightBytes()); + cache.clear(); + assertEquals(0L, metrics.workflowPlanWeightBytes()); + } + + @Test + void concurrentMissBuildsOnlyOnce() throws Exception { + FrozenNode contract = contract("Concurrent", "Run"); + List> executors = + Collections.>singletonList( + countingTriggerExecutor(new AtomicInteger())); + SequentialWorkflowPlan expected = buildPlan(contract, executors); + SequentialWorkflowPlanCache cache = new SequentialWorkflowPlanCache(8, 1024L * 1024L, null); + AtomicInteger builds = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(8); + try { + @SuppressWarnings("unchecked") + Future[] futures = new Future[8]; + for (int i = 0; i < futures.length; i++) { + futures[i] = pool.submit(() -> { + start.await(); + return cache.getOrBuild(contract.resolvedStructuralKey(), + countingFactory(expected, builds)); + }); + } + start.countDown(); + for (Future future : futures) { + assertSame(expected, future.get(10L, TimeUnit.SECONDS)); + } + } finally { + pool.shutdownNow(); + } + assertEquals(1, builds.get()); + } + + private static SequentialWorkflowPlanCache.PlanFactory planFactory( + final FrozenNode contract, + final List> executors, + final AtomicInteger builds) { + return () -> { + builds.incrementAndGet(); + return buildPlan(contract, executors); + }; + } + + private static SequentialWorkflowPlanCache.PlanFactory countingFactory( + final SequentialWorkflowPlan plan, + final AtomicInteger builds) { + return () -> { + builds.incrementAndGet(); + return plan; + }; + } + + private static SequentialWorkflowPlanCache.PlanFactory metricsPlanFactory( + final FrozenNode contract, + final List> executors, + final BexProcessingMetrics metrics) { + return () -> SequentialWorkflowPlan.build(contract, + Collections.singletonList(new TriggerEvent()), + executors, + metrics); + } + + private static SequentialWorkflowPlan buildPlan( + FrozenNode contract, + List> executors) { + return SequentialWorkflowPlan.build(contract, + Collections.singletonList(new TriggerEvent()), + executors, + null); + } + + private static WorkflowStepExecutor countingTriggerExecutor(AtomicInteger supportsCalls) { + return new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + supportsCalls.incrementAndGet(); + return step instanceof TriggerEvent; + } + + @Override + public WorkflowStepResult execute(TriggerEvent step, StepExecutionContext context) { + return WorkflowStepResult.none(); + } + }; + } + + private static FrozenNode contract(String description, String stepName) { + Node step = new Node() + .name(stepName) + .type("Coordination/Trigger Event") + .properties("event", new Node().properties("value", new Node().value("event"))); + Node contract = new Node() + .description(description) + .properties("steps", new Node().items(step)); + return FrozenNode.fromResolvedNode(contract); + } +} diff --git a/src/test/java/blue/coordination/processor/workflow/SequentialWorkflowRunnerLifecycleTest.java b/src/test/java/blue/coordination/processor/workflow/SequentialWorkflowRunnerLifecycleTest.java new file mode 100644 index 0000000..bd97e62 --- /dev/null +++ b/src/test/java/blue/coordination/processor/workflow/SequentialWorkflowRunnerLifecycleTest.java @@ -0,0 +1,521 @@ +package blue.coordination.processor.workflow; + +import blue.bex.api.BexEngine; +import blue.coordination.processor.CoordinationProcessorOptions; +import blue.coordination.processor.CoordinationProcessors; +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.model.TypeBlueId; +import blue.language.processor.ChannelEvaluationContext; +import blue.language.processor.ChannelProcessor; +import blue.language.processor.DocumentProcessingResult; +import blue.language.processor.DocumentProcessor; +import blue.language.processor.ProcessingSnapshotManager; +import blue.language.processor.ProcessorStatus; +import blue.language.processor.WorkingDocument; +import blue.language.processor.model.ChannelContract; +import blue.language.processor.model.JsonPatch; +import blue.language.snapshot.CanonicalPatchResult; +import blue.language.snapshot.FrozenNode; +import blue.language.snapshot.ResolvedSnapshot; +import blue.repo.coordination.Compute; +import blue.repo.coordination.SequentialWorkflow; +import blue.repo.coordination.SequentialWorkflowStep; +import blue.repo.coordination.TerminateProcessing; +import blue.repo.coordination.TriggerEvent; +import blue.repo.coordination.UpdateDocument; +import java.math.BigInteger; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +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.assertTrue; + +/** Lifecycle regressions for the workflow-owned Language working document. */ +class SequentialWorkflowRunnerLifecycleTest { + + private static final String CHANNEL_BLUE_ID = + "BHRKnD9toWwiU34GJvqLJ3Rtiv6W7Mmubai7CdrA1i3L"; + + @Test + void normalWorkflowCreatesAndClosesOneFrozenWorkingDocument() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + SequentialWorkflowRunner runner = runner(metrics, frozenObservingExecutor()); + Fixture fixture = fixture(runner, triggerStep()); + + DocumentProcessingResult result = fixture.process(); + + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + fixture.assertOneWorkflowScopeReleased(); + assertEquals(1L, metrics.workflowDocumentViewsFromFrozen()); + assertEquals(0L, metrics.workflowDocumentViewsFromDocument()); + } + + @Test + void zeroStepWorkflowStillClosesItsWorkingDocument() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + SequentialWorkflowRunner runner = runner(metrics); + Fixture fixture = fixture(runner); + + DocumentProcessingResult result = fixture.process(); + + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + fixture.assertOneWorkflowScopeReleased(); + assertEquals(1L, metrics.workflowDocumentViewsFromFrozen()); + assertEquals(0L, metrics.workflowDocumentViewsFromDocument()); + } + + @Test + void executorExceptionClosesWorkingDocumentAndStillRecordsRunnerTiming() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + WorkflowStepExecutor throwing = new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof TriggerEvent; + } + + @Override + public WorkflowStepResult execute(TriggerEvent step, StepExecutionContext context) { + throw new IllegalStateException("executor exploded"); + } + }; + Fixture fixture = fixture(runner(metrics, throwing), triggerStep()); + + DocumentProcessingResult result = fixture.process(); + + assertRuntimeFatal(result, "executor exploded"); + fixture.assertOneWorkflowScopeReleased(); + assertTrue(metrics.workflowRunnerNanos() > 0L, + "the outer timing finally must run when an executor throws"); + } + + @Test + void throwFatalClosesWorkingDocument() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + WorkflowStepExecutor fatal = new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof TriggerEvent; + } + + @Override + public WorkflowStepResult execute(TriggerEvent step, StepExecutionContext context) { + context.processorContext().throwFatal("requested fatal"); + return WorkflowStepResult.none(); + } + }; + Fixture fixture = fixture(runner(metrics, fatal), triggerStep()); + + DocumentProcessingResult result = fixture.process(); + + assertRuntimeFatal(result, "requested fatal"); + fixture.assertOneWorkflowScopeReleased(); + } + + @Test + void declarativeGracefulTerminationClosesAndSkipsLaterPatchStep() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + AtomicInteger patchExecutions = new AtomicInteger(); + WorkflowStepExecutor forbiddenPatch = new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof UpdateDocument; + } + + @Override + public WorkflowStepResult execute(UpdateDocument step, StepExecutionContext context) { + patchExecutions.incrementAndGet(); + context.processorContext().applyPatch( + JsonPatch.replace("/counter", new Node().value(99))); + return WorkflowStepResult.none(); + } + }; + SequentialWorkflowRunner runner = runner(metrics, + new TerminateProcessingStepExecutor(metrics), forbiddenPatch); + Fixture fixture = fixture(runner, + terminateStep("finished"), + updateStep("replace", "/counter", new Node().value(99))); + + DocumentProcessingResult result = fixture.process(); + + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertEquals(0, patchExecutions.get(), + "no patch-producing step may execute after terminal scope work"); + assertEquals(BigInteger.ZERO, result.document().get("/counter")); + fixture.assertOneWorkflowScopeReleased(); + assertEquals(1L, metrics.declarativeTerminationSteps()); + } + + @Test + void computeResultValidationFailureClosesWorkingDocument() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + SequentialWorkflowRunner runner = SequentialWorkflowRunner.withBexEngine( + BexEngine.builder().build(), 100_000L, metrics); + Fixture fixture = fixture(runner, invalidComputeResultStep()); + + DocumentProcessingResult result = fixture.process(); + + assertRuntimeFatal(result, "changeset must be a list"); + fixture.assertNoTransientSequenceLeak(); + assertEquals(1L, metrics.computeResultValidationFailures()); + } + + @Test + void patchPreviewFailureClosesWorkingDocument() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + SequentialWorkflowRunner runner = SequentialWorkflowRunner.withBexEngine( + BexEngine.builder().build(), 100_000L, metrics); + Fixture fixture = fixture(runner, + updateStep("add", "/counter/child", new Node().value(1))); + + DocumentProcessingResult result = fixture.process(); + + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + fixture.assertNoTransientSequenceLeak(); + assertEquals(BigInteger.ZERO, result.document().get("/counter")); + } + + @Test + void patchApplicationFailureAfterPreviewReleasesEverySequenceScope() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + final TrackingSnapshotManager snapshotManager = new TrackingSnapshotManager(); + WorkflowStepExecutor previewThenFail = + new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof TriggerEvent; + } + + @Override + public WorkflowStepResult execute(TriggerEvent step, + StepExecutionContext context) { + List patches = Collections.singletonList( + JsonPatch.replace("/counter", new Node().value(7))); + WorkingDocument.Preview preview = context.advanceWorkingDocument(patches); + snapshotManager.failNextCacheSnapshot = true; + context.processorContext().applyPreviewedPatches(patches, preview); + return WorkflowStepResult.none(); + } + }; + Fixture fixture = fixture(runner(metrics, previewThenFail), + snapshotManager, + triggerStep()); + + DocumentProcessingResult result = fixture.process(); + + assertRuntimeFatal(result, "simulated final cache failure"); + fixture.assertNoTransientSequenceLeak(); + assertTrue(fixture.snapshotManager.openCalls() >= 2, + "preview preparation and transferred application both own scopes"); + } + + @Test + void transferredPreviewRemainsValidAfterWorkflowWorkingDocumentCloses() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + SequentialWorkflowRunner runner = SequentialWorkflowRunner.withBexEngine( + BexEngine.builder().build(), 100_000L, metrics); + Fixture fixture = fixture(runner, + updateStep("replace", "/counter", new Node().value(7))); + + DocumentProcessingResult result = fixture.process(); + + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertEquals(BigInteger.valueOf(7), result.document().get("/counter"), + "the processor must consume the transferred preview after runner closure"); + fixture.assertNoTransientSequenceLeak(); + assertEquals(fixture.snapshotManager.openCalls(), fixture.snapshotManager.releaseCalls()); + } + + @Test + void tenThousandShortWorkflowsDoNotAccumulateTransientSequenceState() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + SequentialWorkflowRunner runner = runner(metrics, noOpExecutor()); + Fixture fixture = fixture(runner, triggerStep()); + + for (int i = 0; i < 10_000; i++) { + DocumentProcessingResult result = fixture.process(); + assertEquals(ProcessorStatus.SUCCESS, result.status(), result.failureReason()); + assertEquals(0, fixture.snapshotManager.activeScopes(), + "transient scope leak after repetition " + i); + } + + assertEquals(10_000, fixture.snapshotManager.openCalls()); + assertEquals(10_000, fixture.snapshotManager.releaseCalls()); + assertEquals(10_000L, metrics.workflowDocumentViewsFromFrozen()); + assertEquals(0L, metrics.workflowDocumentViewsFromDocument()); + } + + private static WorkflowStepExecutor frozenObservingExecutor() { + return new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof TriggerEvent; + } + + @Override + public WorkflowStepResult execute(TriggerEvent step, StepExecutionContext context) { + assertFalse(context.workingDocument().usedMaterializedFallback()); + return WorkflowStepResult.none(); + } + }; + } + + private static WorkflowStepExecutor noOpExecutor() { + return new WorkflowStepExecutor() { + @Override + public boolean supports(SequentialWorkflowStep step) { + return step instanceof TriggerEvent; + } + + @Override + public WorkflowStepResult execute(TriggerEvent step, StepExecutionContext context) { + return WorkflowStepResult.none(); + } + }; + } + + @SafeVarargs + private static SequentialWorkflowRunner runner( + BexProcessingMetrics metrics, + WorkflowStepExecutor... executors) { + return new SequentialWorkflowRunner(Arrays.asList(executors), metrics, 32, 1_000_000L); + } + + private static Fixture fixture(SequentialWorkflowRunner runner, Node... steps) { + return fixture(runner, new TrackingSnapshotManager(), steps); + } + + private static Fixture fixture(SequentialWorkflowRunner runner, + TrackingSnapshotManager snapshotManager, + Node... steps) { + DocumentProcessor.Builder builder = DocumentProcessor.builder() + .withSnapshotManager(snapshotManager); + CoordinationProcessors.configure(builder, + CoordinationProcessorOptions.builder() + .sequentialWorkflowRunner(runner) + .build()); + DocumentProcessor processor = builder + .registerContractProcessor(new LifecycleChannelProcessor()) + .build(); + DocumentProcessingResult initialized = processor.initializeDocument(document(steps)); + assertEquals(ProcessorStatus.SUCCESS, initialized.status(), initialized.failureReason()); + snapshotManager.resetLifecycleCounters(); + return new Fixture(processor, initialized.document(), snapshotManager); + } + + private static Node document(Node... steps) { + Map contracts = new LinkedHashMap(); + contracts.put("channel", typed(CHANNEL_BLUE_ID)); + contracts.put("workflow", typed(SequentialWorkflow.blueId()) + .properties("channel", new Node().value("channel")) + .properties("steps", new Node().items(Arrays.asList(steps)))); + return new Node() + .properties("counter", new Node().value(0)) + .properties("contracts", new Node().properties(contracts)); + } + + private static Node triggerStep() { + return typed(TriggerEvent.blueId()); + } + + private static Node terminateStep(String reason) { + return typed(TerminateProcessing.blueId()) + .properties("reason", new Node().value(reason)); + } + + private static Node updateStep(String op, String path, Node value) { + return typed(UpdateDocument.blueId()) + .properties("changeset", new Node().items(new Node() + .properties("op", new Node().value(op)) + .properties("path", new Node().value(path)) + .properties("val", value))); + } + + private static Node invalidComputeResultStep() { + return typed(Compute.blueId()) + .properties("do", new Node().items(new Node() + .properties("$return", new Node() + .properties("changeset", new Node().value("not-a-list"))))); + } + + private static Node typed(String blueId) { + return new Node().type(new Node().blueId(blueId)); + } + + private static void assertRuntimeFatal(DocumentProcessingResult result, String message) { + assertEquals(ProcessorStatus.RUNTIME_FATAL, result.status(), result.failureReason()); + assertTrue(result.failureReason() != null && result.failureReason().contains(message), + result.failureReason()); + } + + private static final class Fixture { + private final DocumentProcessor processor; + private final Node initializedDocument; + private final TrackingSnapshotManager snapshotManager; + + private Fixture(DocumentProcessor processor, + Node initializedDocument, + TrackingSnapshotManager snapshotManager) { + this.processor = processor; + this.initializedDocument = initializedDocument; + this.snapshotManager = snapshotManager; + } + + private DocumentProcessingResult process() { + return processor.processDocument(initializedDocument, new Node() + .properties("id", new Node().value("run"))); + } + + private void assertOneWorkflowScopeReleased() { + assertEquals(1, snapshotManager.openCalls()); + assertEquals(1, snapshotManager.releaseCalls()); + assertNoTransientSequenceLeak(); + } + + private void assertNoTransientSequenceLeak() { + assertEquals(0, snapshotManager.activeScopes()); + assertEquals(snapshotManager.openCalls(), snapshotManager.releaseCalls()); + } + } + + @TypeBlueId(CHANNEL_BLUE_ID) + public static final class LifecycleChannel extends ChannelContract { + } + + private static final class LifecycleChannelProcessor + implements ChannelProcessor { + @Override + public Class contractType() { + return LifecycleChannel.class; + } + + @Override + public boolean matches(LifecycleChannel contract, ChannelEvaluationContext context) { + return context.event() != null; + } + + @Override + public String eventId(LifecycleChannel contract, ChannelEvaluationContext context) { + Object id = context.event().get("/id"); + return id != null ? String.valueOf(id) : "run"; + } + } + + /** Real Language snapshot transaction seam with deterministic scope accounting. */ + private static final class TrackingSnapshotManager implements ProcessingSnapshotManager { + private int openCalls; + private int releaseCalls; + private int activeScopes; + 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 fromDocumentTransient(Node document) { + return fromDocument(document); + } + + @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) { + if (failNextCacheSnapshot) { + failNextCacheSnapshot = false; + throw new IllegalStateException("simulated final cache failure"); + } + return snapshot; + } + + @Override + public ProcessingSnapshotManager transientSequence() { + openCalls++; + activeScopes++; + return new TrackingScope(this); + } + + private int openCalls() { + return openCalls; + } + + private int releaseCalls() { + return releaseCalls; + } + + private int activeScopes() { + return activeScopes; + } + + private void resetLifecycleCounters() { + assertEquals(openCalls, releaseCalls, "initialization must release transient scopes"); + assertEquals(0, activeScopes, "initialization must leave no transient scope"); + openCalls = 0; + releaseCalls = 0; + } + } + + 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() { + return owner.transientSequence(); + } + + @Override + public void releaseTransientState() { + if (!released) { + released = true; + owner.releaseCalls++; + owner.activeScopes--; + } + } + } +} diff --git a/src/test/java/blue/coordination/processor/workflow/StaticUpdatePlanTest.java b/src/test/java/blue/coordination/processor/workflow/StaticUpdatePlanTest.java new file mode 100644 index 0000000..dfbb95e --- /dev/null +++ b/src/test/java/blue/coordination/processor/workflow/StaticUpdatePlanTest.java @@ -0,0 +1,127 @@ +package blue.coordination.processor.workflow; + +import blue.coordination.processor.bex.BexProcessingMetrics; +import blue.language.model.Node; +import blue.language.processor.model.FrozenJsonPatch; +import blue.language.snapshot.FrozenNode; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +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 StaticUpdatePlanTest { + + @Test + void compilesOrderedFrozenTemplatesWithoutRetainingMutableValues() { + Node value = new Node().properties("status", new Node().value("authored")); + Node changeset = new Node().items(Arrays.asList( + patch("add", "/added", value), + patch("replace", "/replaced", new Node().value(2)), + patch("remove", "/removed", null))); + + StaticUpdatePlan plan = StaticUpdatePlan.compile(FrozenNode.fromNode(changeset)); + value.getProperties().get("status").value("mutated"); + + assertTrue(plan.valid()); + assertEquals(3, plan.patches().size()); + FrozenJsonPatch first = plan.patches().get(0).bind("/scope/added"); + assertEquals(blue.language.processor.model.JsonPatch.Op.ADD, first.getOp()); + assertEquals("/scope/added", first.getPath()); + assertEquals("authored", first.getVal().getProperties().get("status").getValue()); + assertTrue(first.getVal().isStrictCanonical()); + assertTrue(plan.approximateWeightBytes() > 0L); + } + + @Test + void resolvedConstructionFallbackCanonicalizesExactlyOnceAtPlanCompilation() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + Node changeset = new Node().items(patch("add", "/value", + new Node().properties("nested", new Node().value("authored")))); + + StaticUpdatePlan plan = StaticUpdatePlan.compile( + FrozenNode.fromResolvedNode(changeset), metrics); + FrozenJsonPatch first = plan.patches().get(0).bind("/scope/value"); + FrozenJsonPatch second = plan.patches().get(0).bind("/other/value"); + + assertTrue(first.getValue().isStrictCanonical()); + assertTrue(second.getValue().isStrictCanonical()); + assertEquals(first.getValue().blueId(), second.getValue().blueId()); + assertEquals(1L, metric(metrics, "staticUpdateResolvedValueCanonicalizations")); + } + + @Test + void removeIgnoresResolvedOnlyValueWithoutCanonicalizingIt() { + BexProcessingMetrics metrics = new BexProcessingMetrics(); + Node irrelevantResolvedValue = new Node() + .blueId("GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC") + .properties("expanded", new Node().value("ignored")); + Node changeset = new Node().items( + patch(" REMOVE ", "/removed", irrelevantResolvedValue)); + + StaticUpdatePlan plan = StaticUpdatePlan.compile( + FrozenNode.fromResolvedNode(changeset), metrics); + FrozenJsonPatch patch = plan.patches().get(0).bind("/scope/removed"); + + assertTrue(plan.valid()); + assertEquals(blue.language.processor.model.JsonPatch.Op.REMOVE, patch.getOp()); + assertEquals(0L, metric(metrics, "staticUpdateResolvedValueCanonicalizations")); + } + + @Test + void rejectsBexOperatorsAndMalformedStaticEntriesBeforeCaching() { + StaticUpdatePlan bex = compile(patch("replace", "/status", + new Node().properties("$binding", new Node().value("event")))); + StaticUpdatePlan missingValue = compile(patch("replace", "/status", null)); + StaticUpdatePlan badOperation = compile(patch("move", "/status", new Node().value("x"))); + StaticUpdatePlan scalarEntry = StaticUpdatePlan.compile(FrozenNode.fromResolvedNode( + new Node().items(new Node().value("not-a-patch")))); + + assertFalse(bex.valid()); + assertTrue(bex.validationFailure().contains("must be static")); + assertEquals("Update Document patch value is required for operation: replace", + missingValue.validationFailure()); + assertEquals("Unsupported Update Document patch operation: move", + badOperation.validationFailure()); + assertEquals("Update Document changeset entry 0 must be a static patch object", + scalarEntry.validationFailure()); + } + + @Test + void rejectsNonTextFieldsAndNonListPayloadsWithStableDiagnostics() { + Node nonText = new Node().properties("op", new Node().value(1)) + .properties("path", new Node().value("/status")) + .properties("val", new Node().value("x")); + + StaticUpdatePlan badField = StaticUpdatePlan.compile(FrozenNode.fromResolvedNode( + new Node().items(nonText))); + StaticUpdatePlan notList = StaticUpdatePlan.compile(FrozenNode.fromResolvedNode( + new Node().properties("op", new Node().value("replace")))); + + assertEquals("Update Document changeset entry 0 field 'op' must be text", + badField.validationFailure()); + assertEquals("Update Document changeset must be a static patch list", + notList.validationFailure()); + } + + private static StaticUpdatePlan compile(Node patch) { + return StaticUpdatePlan.compile(FrozenNode.fromResolvedNode(new Node().items(patch))); + } + + private static Node patch(String op, String path, Node value) { + Node patch = new Node() + .properties("op", new Node().value(op)) + .properties("path", new Node().value(path)); + if (value != null) { + patch.properties("val", value); + } + return patch; + } + + private static long metric(BexProcessingMetrics metrics, String name) { + Long value = metrics.languageCounters().get(name); + return value != null ? value.longValue() : 0L; + } +} diff --git a/src/test/java/blue/coordination/processor/workflow/WorkflowExecutionStateTest.java b/src/test/java/blue/coordination/processor/workflow/WorkflowExecutionStateTest.java new file mode 100644 index 0000000..6f2e192 --- /dev/null +++ b/src/test/java/blue/coordination/processor/workflow/WorkflowExecutionStateTest.java @@ -0,0 +1,82 @@ +package blue.coordination.processor.workflow; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkflowExecutionStateTest { + @Test + void snapshotViewsAreStableOrderedAndReadOnly() { + WorkflowExecutionState state = new WorkflowExecutionState(); + WorkflowExecutionState.Snapshot empty = state.snapshotView(); + + state.record("First", "a", false); + WorkflowExecutionState.Snapshot afterFirst = state.snapshotView(); + state.record("Second", "b", true); + WorkflowExecutionState.Snapshot afterSecond = state.snapshotView(); + + assertTrue(empty.results().isEmpty()); + assertEquals(Arrays.asList("First"), new ArrayList(afterFirst.results().keySet())); + assertEquals("a", afterFirst.results().get("First")); + assertFalse(afterFirst.results().containsKey("Second")); + assertFalse(afterFirst.wasChangesetHandled("Second")); + assertEquals(Arrays.asList("First", "Second"), + new ArrayList(afterSecond.results().keySet())); + assertTrue(afterSecond.wasChangesetHandled("Second")); + + assertThrows(UnsupportedOperationException.class, + () -> empty.results().clear()); + assertThrows(UnsupportedOperationException.class, + () -> afterFirst.results().put("Other", "value")); + assertThrows(UnsupportedOperationException.class, + () -> afterSecond.results().remove("missing")); + } + + @Test + void duplicateKeysPreserveEarlierViewsNullValuesAndFirstInsertionOrder() { + WorkflowExecutionState state = new WorkflowExecutionState(); + state.record("Repeated", "first", false); + WorkflowExecutionState.Snapshot beforeOverwrite = state.snapshotView(); + state.record("Other", "other", false); + state.record("Repeated", null, true); + WorkflowExecutionState.Snapshot afterOverwrite = state.snapshotView(); + state.record("Repeated", "third", false); + + assertEquals("first", beforeOverwrite.results().get("Repeated")); + assertFalse(beforeOverwrite.wasChangesetHandled("Repeated")); + assertEquals(Arrays.asList("Repeated", "Other"), + new ArrayList(afterOverwrite.results().keySet())); + assertTrue(afterOverwrite.results().containsKey("Repeated")); + assertNull(afterOverwrite.results().get("Repeated")); + assertTrue(afterOverwrite.wasChangesetHandled("Repeated")); + assertNull(afterOverwrite.results().get("Repeated")); + } + + @Test + void oneThousandStepViewsRetainTheirPrefixWithoutMapCopies() { + WorkflowExecutionState state = new WorkflowExecutionState(); + List retained = + new ArrayList(1001); + for (int i = 0; i <= 1000; i++) { + retained.add(state.snapshotView()); + if (i < 1000) { + state.record("Step" + (i + 1), Integer.valueOf(i), false); + } + } + + assertEquals(0, retained.get(0).size()); + assertEquals(500, retained.get(500).size()); + assertEquals(Integer.valueOf(499), retained.get(500).get("Step500")); + assertFalse(retained.get(500).containsKey("Step501")); + assertEquals(1000, retained.get(1000).size()); + assertEquals(Integer.valueOf(999), retained.get(1000).get("Step1000")); + } +} diff --git a/src/test/java/blue/coordination/processor/workflow/WorkflowPatchEntryTest.java b/src/test/java/blue/coordination/processor/workflow/WorkflowPatchEntryTest.java new file mode 100644 index 0000000..edcd796 --- /dev/null +++ b/src/test/java/blue/coordination/processor/workflow/WorkflowPatchEntryTest.java @@ -0,0 +1,56 @@ +package blue.coordination.processor.workflow; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class WorkflowPatchEntryTest { + + @Test + void legacyMutableValueIsDefensivelyFrozenOnceAtTheBoundary() { + Node callerOwned = new Node().properties("status", new Node().value("before")); + + WorkflowPatchEntry entry = new WorkflowPatchEntry("add", "/payload", callerOwned); + callerOwned.getProperties().get("status").value("after"); + + assertTrue(entry.val().isStrictCanonical()); + assertEquals("before", entry.val().getProperties().get("status").getValue()); + } + + @Test + void strictFrozenValueIsRetainedWithoutMaterialization() { + FrozenNode authored = FrozenNode.fromNode(new Node().value("authored")); + + WorkflowPatchEntry entry = new WorkflowPatchEntry("replace", "/payload", authored); + + assertSame(authored, entry.val()); + } + + @Test + void resolvedFrozenCompatibilityValueIsCanonicalizedAtConstruction() { + FrozenNode resolved = FrozenNode.fromResolvedNode(new Node() + .properties("status", new Node().value("resolved-shape"))); + + WorkflowPatchEntry entry = new WorkflowPatchEntry("add", "/payload", resolved); + + assertTrue(entry.val().isStrictCanonical()); + assertEquals("resolved-shape", entry.val().getProperties().get("status").getValue()); + } + + @Test + void removeIgnoresCallerOwnedValueWithoutFreezingIt() { + Node irrelevantValue = new Node() + .blueId("GX7CFUmSDrE2MzptunLCCdZwnuwwrenRQqEnHL4x3uoC") + .properties("expanded", new Node().value("ignored")); + + WorkflowPatchEntry entry = new WorkflowPatchEntry( + " REMOVE ", "/payload", irrelevantValue); + + assertNull(entry.val()); + } +}