mirror of
https://github.com/corda/corda.git
synced 2025-02-06 19:19:19 +00:00
CID-1154: reliable finality merge to OS (#5658)
CID-1154: reliable finality merge to OS (#5658)
This commit is contained in:
commit
c193aa46f0
@ -21,6 +21,10 @@ class NotaryException(
|
|||||||
/** Specifies the cause for notarisation request failure. */
|
/** Specifies the cause for notarisation request failure. */
|
||||||
@CordaSerializable
|
@CordaSerializable
|
||||||
sealed class NotaryError {
|
sealed class NotaryError {
|
||||||
|
companion object {
|
||||||
|
const val NUM_STATES = 5
|
||||||
|
}
|
||||||
|
|
||||||
/** Occurs when one or more input states have already been consumed by another transaction. */
|
/** Occurs when one or more input states have already been consumed by another transaction. */
|
||||||
data class Conflict(
|
data class Conflict(
|
||||||
/** Id of the transaction that was attempted to be notarised. */
|
/** Id of the transaction that was attempted to be notarised. */
|
||||||
@ -28,8 +32,9 @@ sealed class NotaryError {
|
|||||||
/** Specifies which states have already been consumed in another transaction. */
|
/** Specifies which states have already been consumed in another transaction. */
|
||||||
val consumedStates: Map<StateRef, StateConsumptionDetails>
|
val consumedStates: Map<StateRef, StateConsumptionDetails>
|
||||||
) : NotaryError() {
|
) : NotaryError() {
|
||||||
override fun toString() = "One or more input states or referenced states have already been used as input states in other transactions. Conflicting state count: ${consumedStates.size}, consumption details:\n" +
|
override fun toString() = "One or more input states or referenced states have already been used as input states in other transactions. " +
|
||||||
"${consumedStates.asSequence().joinToString(",\n", limit = 5) { it.key.toString() + " -> " + it.value }}.\n" +
|
"Conflicting state count: ${consumedStates.size}, consumption details:\n" +
|
||||||
|
"${consumedStates.asSequence().joinToString(",\n", limit = NUM_STATES) { it.key.toString() + " -> " + it.value }}.\n" +
|
||||||
"To find out if any of the conflicting transactions have been generated by this node you can use the hashLookup Corda shell command."
|
"To find out if any of the conflicting transactions have been generated by this node you can use the hashLookup Corda shell command."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,6 +92,7 @@ sealed class QueryCriteria : GenericQueryCriteria<QueryCriteria, IQueryCriteriaP
|
|||||||
/**
|
/**
|
||||||
* VaultQueryCriteria: provides query by attributes defined in [VaultSchema.VaultStates]
|
* VaultQueryCriteria: provides query by attributes defined in [VaultSchema.VaultStates]
|
||||||
*/
|
*/
|
||||||
|
@Suppress("MagicNumber") // need to list deprecation versions explicitly
|
||||||
data class VaultQueryCriteria(
|
data class VaultQueryCriteria(
|
||||||
override val status: Vault.StateStatus = Vault.StateStatus.UNCONSUMED,
|
override val status: Vault.StateStatus = Vault.StateStatus.UNCONSUMED,
|
||||||
override val contractStateTypes: Set<Class<out ContractState>>? = null,
|
override val contractStateTypes: Set<Class<out ContractState>>? = null,
|
||||||
@ -264,6 +265,7 @@ sealed class QueryCriteria : GenericQueryCriteria<QueryCriteria, IQueryCriteriaP
|
|||||||
/**
|
/**
|
||||||
* LinearStateQueryCriteria: provides query by attributes defined in [VaultSchema.VaultLinearState]
|
* LinearStateQueryCriteria: provides query by attributes defined in [VaultSchema.VaultLinearState]
|
||||||
*/
|
*/
|
||||||
|
@Suppress("MagicNumber") // need to list deprecation versions explicitly
|
||||||
data class LinearStateQueryCriteria(
|
data class LinearStateQueryCriteria(
|
||||||
override val participants: List<AbstractParty>? = null,
|
override val participants: List<AbstractParty>? = null,
|
||||||
val uuid: List<UUID>? = null,
|
val uuid: List<UUID>? = null,
|
||||||
@ -545,6 +547,7 @@ sealed class AttachmentQueryCriteria : GenericQueryCriteria<AttachmentQueryCrite
|
|||||||
/**
|
/**
|
||||||
* AttachmentsQueryCriteria:
|
* AttachmentsQueryCriteria:
|
||||||
*/
|
*/
|
||||||
|
@Suppress("MagicNumber") // need to list deprecation versions explicitly
|
||||||
data class AttachmentsQueryCriteria(val uploaderCondition: ColumnPredicate<String>? = null,
|
data class AttachmentsQueryCriteria(val uploaderCondition: ColumnPredicate<String>? = null,
|
||||||
val filenameCondition: ColumnPredicate<String>? = null,
|
val filenameCondition: ColumnPredicate<String>? = null,
|
||||||
val uploadDateCondition: ColumnPredicate<Instant>? = null,
|
val uploadDateCondition: ColumnPredicate<Instant>? = null,
|
||||||
|
@ -90,7 +90,9 @@ class PersistentState(@EmbeddedId override var stateRef: PersistentStateRef? = n
|
|||||||
@KeepForDJVM
|
@KeepForDJVM
|
||||||
@Embeddable
|
@Embeddable
|
||||||
@Immutable
|
@Immutable
|
||||||
|
|
||||||
data class PersistentStateRef(
|
data class PersistentStateRef(
|
||||||
|
@Suppress("MagicNumber") // column width
|
||||||
@Column(name = "transaction_id", length = 64, nullable = false)
|
@Column(name = "transaction_id", length = 64, nullable = false)
|
||||||
var txId: String,
|
var txId: String,
|
||||||
|
|
||||||
|
@ -143,8 +143,6 @@
|
|||||||
<ID>ComplexMethod:CustomSerializerRegistry.kt$CachingCustomSerializerRegistry$private fun doFindCustomSerializer(clazz: Class<*>, declaredType: Type): AMQPSerializer<Any>?</ID>
|
<ID>ComplexMethod:CustomSerializerRegistry.kt$CachingCustomSerializerRegistry$private fun doFindCustomSerializer(clazz: Class<*>, declaredType: Type): AMQPSerializer<Any>?</ID>
|
||||||
<ID>ComplexMethod:DeserializationInput.kt$DeserializationInput$fun readObject(obj: Any, schemas: SerializationSchemas, type: Type, context: SerializationContext): Any</ID>
|
<ID>ComplexMethod:DeserializationInput.kt$DeserializationInput$fun readObject(obj: Any, schemas: SerializationSchemas, type: Type, context: SerializationContext): Any</ID>
|
||||||
<ID>ComplexMethod:DriverDSLImpl.kt$DriverDSLImpl$override fun start()</ID>
|
<ID>ComplexMethod:DriverDSLImpl.kt$DriverDSLImpl$override fun start()</ID>
|
||||||
<ID>ComplexMethod:DriverDSLImpl.kt$DriverDSLImpl$private fun startNodeInternal(config: NodeConfig, webAddress: NetworkHostAndPort, localNetworkMap: LocalNetworkMap?, parameters: NodeParameters): CordaFuture<NodeHandle></ID>
|
|
||||||
<ID>ComplexMethod:DriverDSLImpl.kt$DriverDSLImpl$private fun startRegisteredNode(name: CordaX500Name, localNetworkMap: LocalNetworkMap?, parameters: NodeParameters, p2pAddress: NetworkHostAndPort = portAllocation.nextHostAndPort()): CordaFuture<NodeHandle></ID>
|
|
||||||
<ID>ComplexMethod:Expect.kt$ fun <S, E : Any> S.genericExpectEvents( isStrict: Boolean = true, stream: S.((E) -> Unit) -> Unit, expectCompose: () -> ExpectCompose<E> )</ID>
|
<ID>ComplexMethod:Expect.kt$ fun <S, E : Any> S.genericExpectEvents( isStrict: Boolean = true, stream: S.((E) -> Unit) -> Unit, expectCompose: () -> ExpectCompose<E> )</ID>
|
||||||
<ID>ComplexMethod:FinalityFlow.kt$FinalityFlow$@Suspendable @Throws(NotaryException::class) override fun call(): SignedTransaction</ID>
|
<ID>ComplexMethod:FinalityFlow.kt$FinalityFlow$@Suspendable @Throws(NotaryException::class) override fun call(): SignedTransaction</ID>
|
||||||
<ID>ComplexMethod:FlowMonitor.kt$FlowMonitor$private fun warningMessageForFlowWaitingOnIo(request: FlowIORequest<*>, flow: FlowStateMachineImpl<*>, now: Instant): String</ID>
|
<ID>ComplexMethod:FlowMonitor.kt$FlowMonitor$private fun warningMessageForFlowWaitingOnIo(request: FlowIORequest<*>, flow: FlowStateMachineImpl<*>, now: Instant): String</ID>
|
||||||
@ -690,7 +688,7 @@
|
|||||||
<ID>LongParameterList:DriverDSL.kt$DriverDSL$( defaultParameters: NodeParameters = NodeParameters(), providedName: CordaX500Name? = defaultParameters.providedName, rpcUsers: List<User> = defaultParameters.rpcUsers, verifierType: VerifierType = defaultParameters.verifierType, customOverrides: Map<String, Any?> = defaultParameters.customOverrides, startInSameProcess: Boolean? = defaultParameters.startInSameProcess, maximumHeapSize: String = defaultParameters.maximumHeapSize )</ID>
|
<ID>LongParameterList:DriverDSL.kt$DriverDSL$( defaultParameters: NodeParameters = NodeParameters(), providedName: CordaX500Name? = defaultParameters.providedName, rpcUsers: List<User> = defaultParameters.rpcUsers, verifierType: VerifierType = defaultParameters.verifierType, customOverrides: Map<String, Any?> = defaultParameters.customOverrides, startInSameProcess: Boolean? = defaultParameters.startInSameProcess, maximumHeapSize: String = defaultParameters.maximumHeapSize )</ID>
|
||||||
<ID>LongParameterList:DriverDSL.kt$DriverDSL$( defaultParameters: NodeParameters = NodeParameters(), providedName: CordaX500Name? = defaultParameters.providedName, rpcUsers: List<User> = defaultParameters.rpcUsers, verifierType: VerifierType = defaultParameters.verifierType, customOverrides: Map<String, Any?> = defaultParameters.customOverrides, startInSameProcess: Boolean? = defaultParameters.startInSameProcess, maximumHeapSize: String = defaultParameters.maximumHeapSize, logLevelOverride: String? = defaultParameters.logLevelOverride )</ID>
|
<ID>LongParameterList:DriverDSL.kt$DriverDSL$( defaultParameters: NodeParameters = NodeParameters(), providedName: CordaX500Name? = defaultParameters.providedName, rpcUsers: List<User> = defaultParameters.rpcUsers, verifierType: VerifierType = defaultParameters.verifierType, customOverrides: Map<String, Any?> = defaultParameters.customOverrides, startInSameProcess: Boolean? = defaultParameters.startInSameProcess, maximumHeapSize: String = defaultParameters.maximumHeapSize, logLevelOverride: String? = defaultParameters.logLevelOverride )</ID>
|
||||||
<ID>LongParameterList:DriverDSLImpl.kt$( isDebug: Boolean = DriverParameters().isDebug, driverDirectory: Path = DriverParameters().driverDirectory, portAllocation: PortAllocation = DriverParameters().portAllocation, debugPortAllocation: PortAllocation = DriverParameters().debugPortAllocation, systemProperties: Map<String, String> = DriverParameters().systemProperties, useTestClock: Boolean = DriverParameters().useTestClock, startNodesInProcess: Boolean = DriverParameters().startNodesInProcess, extraCordappPackagesToScan: List<String> = @Suppress("DEPRECATION") DriverParameters().extraCordappPackagesToScan, waitForAllNodesToFinish: Boolean = DriverParameters().waitForAllNodesToFinish, notarySpecs: List<NotarySpec> = DriverParameters().notarySpecs, jmxPolicy: JmxPolicy = DriverParameters().jmxPolicy, networkParameters: NetworkParameters = DriverParameters().networkParameters, compatibilityZone: CompatibilityZoneParams? = null, notaryCustomOverrides: Map<String, Any?> = DriverParameters().notaryCustomOverrides, inMemoryDB: Boolean = DriverParameters().inMemoryDB, cordappsForAllNodes: Collection<TestCordappInternal>? = null, dsl: DriverDSLImpl.() -> A )</ID>
|
<ID>LongParameterList:DriverDSLImpl.kt$( isDebug: Boolean = DriverParameters().isDebug, driverDirectory: Path = DriverParameters().driverDirectory, portAllocation: PortAllocation = DriverParameters().portAllocation, debugPortAllocation: PortAllocation = DriverParameters().debugPortAllocation, systemProperties: Map<String, String> = DriverParameters().systemProperties, useTestClock: Boolean = DriverParameters().useTestClock, startNodesInProcess: Boolean = DriverParameters().startNodesInProcess, extraCordappPackagesToScan: List<String> = @Suppress("DEPRECATION") DriverParameters().extraCordappPackagesToScan, waitForAllNodesToFinish: Boolean = DriverParameters().waitForAllNodesToFinish, notarySpecs: List<NotarySpec> = DriverParameters().notarySpecs, jmxPolicy: JmxPolicy = DriverParameters().jmxPolicy, networkParameters: NetworkParameters = DriverParameters().networkParameters, compatibilityZone: CompatibilityZoneParams? = null, notaryCustomOverrides: Map<String, Any?> = DriverParameters().notaryCustomOverrides, inMemoryDB: Boolean = DriverParameters().inMemoryDB, cordappsForAllNodes: Collection<TestCordappInternal>? = null, dsl: DriverDSLImpl.() -> A )</ID>
|
||||||
<ID>LongParameterList:DriverDSLImpl.kt$DriverDSLImpl.Companion$( config: NodeConfig, quasarJarPath: String, debugPort: Int?, overriddenSystemProperties: Map<String, String>, maximumHeapSize: String, logLevelOverride: String?, vararg extraCmdLineFlag: String )</ID>
|
<ID>LongParameterList:DriverDSLImpl.kt$DriverDSLImpl.Companion$( config: NodeConfig, quasarJarPath: String, debugPort: Int?, bytemanJarPath: String?, bytemanPort: Int?, overriddenSystemProperties: Map<String, String>, maximumHeapSize: String, logLevelOverride: String?, vararg extraCmdLineFlag: String )</ID>
|
||||||
<ID>LongParameterList:DummyFungibleContract.kt$DummyFungibleContract$(inputs: List<State>, outputs: List<State>, tx: LedgerTransaction, issueCommand: CommandWithParties<Commands.Issue>, currency: Currency, issuer: PartyAndReference)</ID>
|
<ID>LongParameterList:DummyFungibleContract.kt$DummyFungibleContract$(inputs: List<State>, outputs: List<State>, tx: LedgerTransaction, issueCommand: CommandWithParties<Commands.Issue>, currency: Currency, issuer: PartyAndReference)</ID>
|
||||||
<ID>LongParameterList:IRS.kt$FloatingRatePaymentEvent$(date: LocalDate = this.date, accrualStartDate: LocalDate = this.accrualStartDate, accrualEndDate: LocalDate = this.accrualEndDate, dayCountBasisDay: DayCountBasisDay = this.dayCountBasisDay, dayCountBasisYear: DayCountBasisYear = this.dayCountBasisYear, fixingDate: LocalDate = this.fixingDate, notional: Amount<Currency> = this.notional, rate: Rate = this.rate)</ID>
|
<ID>LongParameterList:IRS.kt$FloatingRatePaymentEvent$(date: LocalDate = this.date, accrualStartDate: LocalDate = this.accrualStartDate, accrualEndDate: LocalDate = this.accrualEndDate, dayCountBasisDay: DayCountBasisDay = this.dayCountBasisDay, dayCountBasisYear: DayCountBasisYear = this.dayCountBasisYear, fixingDate: LocalDate = this.fixingDate, notional: Amount<Currency> = this.notional, rate: Rate = this.rate)</ID>
|
||||||
<ID>LongParameterList:IRS.kt$InterestRateSwap$(floatingLeg: FloatingLeg, fixedLeg: FixedLeg, calculation: Calculation, common: Common, oracle: Party, notary: Party)</ID>
|
<ID>LongParameterList:IRS.kt$InterestRateSwap$(floatingLeg: FloatingLeg, fixedLeg: FixedLeg, calculation: Calculation, common: Common, oracle: Party, notary: Party)</ID>
|
||||||
@ -2080,9 +2078,9 @@
|
|||||||
<ID>MaxLineLength:DriverDSLImpl.kt$DriverDSLImpl$private</ID>
|
<ID>MaxLineLength:DriverDSLImpl.kt$DriverDSLImpl$private</ID>
|
||||||
<ID>MaxLineLength:DriverDSLImpl.kt$DriverDSLImpl$val flowOverrideConfig = FlowOverrideConfig(parameters.flowOverrides.map { FlowOverride(it.key.canonicalName, it.value.canonicalName) })</ID>
|
<ID>MaxLineLength:DriverDSLImpl.kt$DriverDSLImpl$val flowOverrideConfig = FlowOverrideConfig(parameters.flowOverrides.map { FlowOverride(it.key.canonicalName, it.value.canonicalName) })</ID>
|
||||||
<ID>MaxLineLength:DriverDSLImpl.kt$DriverDSLImpl$val jdbcUrl = "jdbc:h2:mem:persistence${inMemoryCounter.getAndIncrement()};DB_CLOSE_ON_EXIT=FALSE;LOCK_TIMEOUT=10000;WRITE_DELAY=100"</ID>
|
<ID>MaxLineLength:DriverDSLImpl.kt$DriverDSLImpl$val jdbcUrl = "jdbc:h2:mem:persistence${inMemoryCounter.getAndIncrement()};DB_CLOSE_ON_EXIT=FALSE;LOCK_TIMEOUT=10000;WRITE_DELAY=100"</ID>
|
||||||
<ID>MaxLineLength:DriverDSLImpl.kt$DriverDSLImpl$val process = startOutOfProcessNode(config, quasarJarPath, debugPort, systemProperties, parameters.maximumHeapSize, parameters.logLevelOverride)</ID>
|
<ID>MaxLineLength:DriverDSLImpl.kt$DriverDSLImpl.Companion$if (bytemanAgent != null && debugPort != null) listOf("-Dorg.jboss.byteman.verbose=true", "-Dorg.jboss.byteman.debug=true") else emptyList()</ID>
|
||||||
<ID>MaxLineLength:DriverDSLImpl.kt$DriverDSLImpl.Companion$private operator fun Config.plus(property: Pair<String, Any>)</ID>
|
<ID>MaxLineLength:DriverDSLImpl.kt$DriverDSLImpl.Companion$private operator fun Config.plus(property: Pair<String, Any>)</ID>
|
||||||
<ID>MaxLineLength:DriverDSLImpl.kt$DriverDSLImpl.Companion${ log.info("Starting out-of-process Node ${config.corda.myLegalName.organisation}, debug port is " + (debugPort ?: "not enabled")) // Write node.conf writeConfig(config.corda.baseDirectory, "node.conf", config.typesafe.toNodeOnly()) val systemProperties = mutableMapOf( "name" to config.corda.myLegalName, "visualvm.display.name" to "corda-${config.corda.myLegalName}" ) debugPort?.let { systemProperties += "log4j2.level" to "debug" systemProperties += "log4j2.debug" to "true" } systemProperties += inheritFromParentProcess() systemProperties += overriddenSystemProperties // See experimental/quasar-hook/README.md for how to generate. val excludePattern = "x(antlr**;bftsmart**;ch**;co.paralleluniverse**;com.codahale**;com.esotericsoftware**;" + "com.fasterxml**;com.google**;com.ibm**;com.intellij**;com.jcabi**;com.nhaarman**;com.opengamma**;" + "com.typesafe**;com.zaxxer**;de.javakaffee**;groovy**;groovyjarjarantlr**;groovyjarjarasm**;io.atomix**;" + "io.github**;io.netty**;jdk**;joptsimple**;junit**;kotlin**;net.bytebuddy**;net.i2p**;org.apache**;" + "org.assertj**;org.bouncycastle**;org.codehaus**;org.crsh**;org.dom4j**;org.fusesource**;org.h2**;" + "org.hamcrest**;org.hibernate**;org.jboss**;org.jcp**;org.joda**;org.junit**;org.mockito**;org.objectweb**;" + "org.objenesis**;org.slf4j**;org.w3c**;org.xml**;org.yaml**;reflectasm**;rx**;org.jolokia**;" + "com.lmax**;picocli**;liquibase**;com.github.benmanes**;org.json**;org.postgresql**;nonapi.io.github.classgraph**;)" val extraJvmArguments = systemProperties.removeResolvedClasspath().map { "-D${it.key}=${it.value}" } + "-javaagent:$quasarJarPath=$excludePattern" val loggingLevel = when { logLevelOverride != null -> logLevelOverride debugPort == null -> "INFO" else -> "DEBUG" } val arguments = mutableListOf( "--base-directory=${config.corda.baseDirectory}", "--logging-level=$loggingLevel", "--no-local-shell").also { it += extraCmdLineFlag }.toList() // The following dependencies are excluded from the classpath of the created JVM, so that the environment resembles a real one as close as possible. // These are either classes that will be added as attachments to the node (i.e. samples, finance, opengamma etc.) or irrelevant testing libraries (test, corda-mock etc.). // TODO: There is pending work to fix this issue without custom blacklisting. See: https://r3-cev.atlassian.net/browse/CORDA-2164. val exclude = listOf("samples", "finance", "integrationTest", "test", "corda-mock", "com.opengamma.strata") val cp = ProcessUtilities.defaultClassPath.filterNot { cpEntry -> exclude.any { token -> cpEntry.contains("${File.separatorChar}$token") } || cpEntry.endsWith("-tests.jar") } return ProcessUtilities.startJavaProcess( className = "net.corda.node.Corda", // cannot directly get class for this, so just use string arguments = arguments, jdwpPort = debugPort, extraJvmArguments = extraJvmArguments, workingDirectory = config.corda.baseDirectory, maximumHeapSize = maximumHeapSize, classPath = cp ) }</ID>
|
<ID>MaxLineLength:DriverDSLImpl.kt$DriverDSLImpl.Companion${ log.info("Starting out-of-process Node ${config.corda.myLegalName.organisation}, " + "debug port is " + (debugPort ?: "not enabled") + ", " + "byteMan: " + if (bytemanJarPath == null) "not in classpath" else "port is " + (bytemanPort ?: "not enabled")) // Write node.conf writeConfig(config.corda.baseDirectory, "node.conf", config.typesafe.toNodeOnly()) val systemProperties = mutableMapOf( "name" to config.corda.myLegalName, "visualvm.display.name" to "corda-${config.corda.myLegalName}" ) debugPort?.let { systemProperties += "log4j2.level" to "debug" systemProperties += "log4j2.debug" to "true" } systemProperties += inheritFromParentProcess() systemProperties += overriddenSystemProperties // See experimental/quasar-hook/README.md for how to generate. val excludePattern = "x(antlr**;bftsmart**;ch**;co.paralleluniverse**;com.codahale**;com.esotericsoftware**;" + "com.fasterxml**;com.google**;com.ibm**;com.intellij**;com.jcabi**;com.nhaarman**;com.opengamma**;" + "com.typesafe**;com.zaxxer**;de.javakaffee**;groovy**;groovyjarjarantlr**;groovyjarjarasm**;io.atomix**;" + "io.github**;io.netty**;jdk**;joptsimple**;junit**;kotlin**;net.bytebuddy**;net.i2p**;org.apache**;" + "org.assertj**;org.bouncycastle**;org.codehaus**;org.crsh**;org.dom4j**;org.fusesource**;org.h2**;" + "org.hamcrest**;org.hibernate**;org.jboss**;org.jcp**;org.joda**;org.junit**;org.mockito**;org.objectweb**;" + "org.objenesis**;org.slf4j**;org.w3c**;org.xml**;org.yaml**;reflectasm**;rx**;org.jolokia**;" + "com.lmax**;picocli**;liquibase**;com.github.benmanes**;org.json**;org.postgresql**;nonapi.io.github.classgraph**;)" val extraJvmArguments = systemProperties.removeResolvedClasspath().map { "-D${it.key}=${it.value}" } + "-javaagent:$quasarJarPath=$excludePattern" val loggingLevel = when { logLevelOverride != null -> logLevelOverride debugPort == null -> "INFO" else -> "DEBUG" } val arguments = mutableListOf( "--base-directory=${config.corda.baseDirectory}", "--logging-level=$loggingLevel", "--no-local-shell").also { it += extraCmdLineFlag }.toList() val bytemanJvmArgs = { val bytemanAgent = bytemanJarPath?.let { bytemanPort?.let { "-javaagent:$bytemanJarPath=port:$bytemanPort,listener:true" } } listOfNotNull(bytemanAgent) + if (bytemanAgent != null && debugPort != null) listOf("-Dorg.jboss.byteman.verbose=true", "-Dorg.jboss.byteman.debug=true") else emptyList() }.invoke() // The following dependencies are excluded from the classpath of the created JVM, so that the environment resembles a real one as close as possible. // These are either classes that will be added as attachments to the node (i.e. samples, finance, opengamma etc.) or irrelevant testing libraries (test, corda-mock etc.). // TODO: There is pending work to fix this issue without custom blacklisting. See: https://r3-cev.atlassian.net/browse/CORDA-2164. val exclude = listOf("samples", "finance", "integrationTest", "test", "corda-mock", "com.opengamma.strata") val cp = ProcessUtilities.defaultClassPath.filterNot { cpEntry -> exclude.any { token -> cpEntry.contains("${File.separatorChar}$token") } || cpEntry.endsWith("-tests.jar") } return ProcessUtilities.startJavaProcess( className = "net.corda.node.Corda", // cannot directly get class for this, so just use string arguments = arguments, jdwpPort = debugPort, extraJvmArguments = extraJvmArguments + bytemanJvmArgs, workingDirectory = config.corda.baseDirectory, maximumHeapSize = maximumHeapSize, classPath = cp ) }</ID>
|
||||||
<ID>MaxLineLength:DriverDSLImpl.kt$InternalDriverDSL$ fun <A> pollUntilNonNull(pollName: String, pollInterval: Duration = DEFAULT_POLL_INTERVAL, warnCount: Int = DEFAULT_WARN_COUNT, check: () -> A?): CordaFuture<A></ID>
|
<ID>MaxLineLength:DriverDSLImpl.kt$InternalDriverDSL$ fun <A> pollUntilNonNull(pollName: String, pollInterval: Duration = DEFAULT_POLL_INTERVAL, warnCount: Int = DEFAULT_WARN_COUNT, check: () -> A?): CordaFuture<A></ID>
|
||||||
<ID>MaxLineLength:DriverDSLImpl.kt$InternalDriverDSL$ fun pollUntilTrue(pollName: String, pollInterval: Duration = DEFAULT_POLL_INTERVAL, warnCount: Int = DEFAULT_WARN_COUNT, check: () -> Boolean): CordaFuture<Unit></ID>
|
<ID>MaxLineLength:DriverDSLImpl.kt$InternalDriverDSL$ fun pollUntilTrue(pollName: String, pollInterval: Duration = DEFAULT_POLL_INTERVAL, warnCount: Int = DEFAULT_WARN_COUNT, check: () -> Boolean): CordaFuture<Unit></ID>
|
||||||
<ID>MaxLineLength:DriverDSLImpl.kt$fun DriverDSL.startNode(providedName: CordaX500Name, devMode: Boolean, parameters: NodeParameters = NodeParameters()): CordaFuture<NodeHandle></ID>
|
<ID>MaxLineLength:DriverDSLImpl.kt$fun DriverDSL.startNode(providedName: CordaX500Name, devMode: Boolean, parameters: NodeParameters = NodeParameters()): CordaFuture<NodeHandle></ID>
|
||||||
@ -3859,7 +3857,7 @@
|
|||||||
<ID>SpreadOperator:DemoBench.kt$DemoBench.Companion$(DemoBench::class.java, *args)</ID>
|
<ID>SpreadOperator:DemoBench.kt$DemoBench.Companion$(DemoBench::class.java, *args)</ID>
|
||||||
<ID>SpreadOperator:DevCertificatesTest.kt$DevCertificatesTest$(*oldX509Certificates)</ID>
|
<ID>SpreadOperator:DevCertificatesTest.kt$DevCertificatesTest$(*oldX509Certificates)</ID>
|
||||||
<ID>SpreadOperator:DockerInstantiator.kt$DockerInstantiator$(*it.toTypedArray())</ID>
|
<ID>SpreadOperator:DockerInstantiator.kt$DockerInstantiator$(*it.toTypedArray())</ID>
|
||||||
<ID>SpreadOperator:DriverDSLImpl.kt$DriverDSLImpl$( config, quasarJarPath, debugPort, systemProperties, "512m", null, *extraCmdLineFlag )</ID>
|
<ID>SpreadOperator:DriverDSLImpl.kt$DriverDSLImpl$( config, quasarJarPath, debugPort, bytemanJarPath, null, systemProperties, "512m", null, *extraCmdLineFlag )</ID>
|
||||||
<ID>SpreadOperator:DummyContract.kt$DummyContract.Companion$( /* INPUTS */ *priors.toTypedArray(), /* COMMAND */ Command(cmd, priorState.owner.owningKey), /* OUTPUT */ StateAndContract(state, PROGRAM_ID) )</ID>
|
<ID>SpreadOperator:DummyContract.kt$DummyContract.Companion$( /* INPUTS */ *priors.toTypedArray(), /* COMMAND */ Command(cmd, priorState.owner.owningKey), /* OUTPUT */ StateAndContract(state, PROGRAM_ID) )</ID>
|
||||||
<ID>SpreadOperator:DummyContract.kt$DummyContract.Companion$(*items)</ID>
|
<ID>SpreadOperator:DummyContract.kt$DummyContract.Companion$(*items)</ID>
|
||||||
<ID>SpreadOperator:DummyContractV2.kt$DummyContractV2.Companion$( /* INPUTS */ *priors.toTypedArray(), /* COMMAND */ Command(cmd, priorState.owners.map { it.owningKey }), /* OUTPUT */ StateAndContract(state, DummyContractV2.PROGRAM_ID) )</ID>
|
<ID>SpreadOperator:DummyContractV2.kt$DummyContractV2.Companion$( /* INPUTS */ *priors.toTypedArray(), /* COMMAND */ Command(cmd, priorState.owners.map { it.owningKey }), /* OUTPUT */ StateAndContract(state, DummyContractV2.PROGRAM_ID) )</ID>
|
||||||
|
@ -266,6 +266,7 @@ class InitiatorFlow(val arg1: Boolean, val arg2: Int, private val counterparty:
|
|||||||
val ourOutputState: DummyState = DummyState()
|
val ourOutputState: DummyState = DummyState()
|
||||||
// DOCEND 22
|
// DOCEND 22
|
||||||
// Or as copies of other states with some properties changed.
|
// Or as copies of other states with some properties changed.
|
||||||
|
@Suppress("MagicNumber") // literally a magic number
|
||||||
// DOCSTART 23
|
// DOCSTART 23
|
||||||
val ourOtherOutputState: DummyState = ourOutputState.copy(magicNumber = 77)
|
val ourOtherOutputState: DummyState = ourOutputState.copy(magicNumber = 77)
|
||||||
// DOCEND 23
|
// DOCEND 23
|
||||||
|
@ -51,7 +51,13 @@ Specifically, there are two main ways a flow is hospitalized:
|
|||||||
|
|
||||||
* **Database constraint violation** (``ConstraintViolationException``):
|
* **Database constraint violation** (``ConstraintViolationException``):
|
||||||
This scenario may occur due to natural contention between racing flows as Corda delegates handling using the database's optimistic concurrency control.
|
This scenario may occur due to natural contention between racing flows as Corda delegates handling using the database's optimistic concurrency control.
|
||||||
As the likelihood of re-occurrence should be low, the flow will actually error and fail if it experiences this at the same point more than 3 times. No intervention required.
|
If this exception occurs, the flow will retry. After retrying a number of times, the errored flow is kept in for observation.
|
||||||
|
|
||||||
|
* ``SQLTransientConnectionException``:
|
||||||
|
Database connection pooling errors are dealt with. If this exception occurs, the flow will retry. After retrying a number of times, the errored flow is kept in for observation.
|
||||||
|
|
||||||
|
* All other instances of ``SQLException``:
|
||||||
|
Any ``SQLException`` that is thrown and not handled by any of the scenarios detailed above, will be kept in for observation after their first failure.
|
||||||
|
|
||||||
* **Finality Flow handling** - Corda 3.x (old style) ``FinalityFlow`` and Corda 4.x ``ReceiveFinalityFlow`` handling:
|
* **Finality Flow handling** - Corda 3.x (old style) ``FinalityFlow`` and Corda 4.x ``ReceiveFinalityFlow`` handling:
|
||||||
If on the receive side of the finality flow, any error will result in the flow being kept in for observation to allow the cause of the
|
If on the receive side of the finality flow, any error will result in the flow being kept in for observation to allow the cause of the
|
||||||
@ -64,7 +70,8 @@ Specifically, there are two main ways a flow is hospitalized:
|
|||||||
The time is hard to document as the notary members, if actually alive, will inform the requester of the ETA of a response.
|
The time is hard to document as the notary members, if actually alive, will inform the requester of the ETA of a response.
|
||||||
This can occur an infinite number of times. i.e. we never give up notarising. No intervention required.
|
This can occur an infinite number of times. i.e. we never give up notarising. No intervention required.
|
||||||
|
|
||||||
* ``SQLTransientConnectionException``:
|
* **Internal Corda errors**:
|
||||||
Database connection pooling errors are dealt with. If this exception occurs, the flow will retry. After retrying a number of times, the errored flow is kept in for observation.
|
Flows that experience errors from inside the Corda statemachine, that are not handled by any of the scenarios details above, will be retried a number of times
|
||||||
|
and then kept in for observation if the error continues.
|
||||||
|
|
||||||
.. note:: Flows that are kept in for observation are retried upon node restart.
|
.. note:: Flows that are kept in for observation are retried upon node restart.
|
||||||
|
@ -18,6 +18,7 @@ object CashSchema
|
|||||||
* First version of a cash contract ORM schema that maps all fields of the [Cash] contract state as it stood
|
* First version of a cash contract ORM schema that maps all fields of the [Cash] contract state as it stood
|
||||||
* at the time of writing.
|
* at the time of writing.
|
||||||
*/
|
*/
|
||||||
|
@Suppress("MagicNumber") // SQL column length
|
||||||
@CordaSerializable
|
@CordaSerializable
|
||||||
object CashSchemaV1 : MappedSchema(schemaFamily = CashSchema.javaClass, version = 1, mappedTypes = listOf(PersistentCashState::class.java)) {
|
object CashSchemaV1 : MappedSchema(schemaFamily = CashSchema.javaClass, version = 1, mappedTypes = listOf(PersistentCashState::class.java)) {
|
||||||
|
|
||||||
|
@ -22,6 +22,7 @@ object CommercialPaperSchema
|
|||||||
* as it stood at the time of writing.
|
* as it stood at the time of writing.
|
||||||
*/
|
*/
|
||||||
@CordaSerializable
|
@CordaSerializable
|
||||||
|
@Suppress("MagicNumber") // SQL column length
|
||||||
object CommercialPaperSchemaV1 : MappedSchema(schemaFamily = CommercialPaperSchema.javaClass, version = 1, mappedTypes = listOf(PersistentCommercialPaperState::class.java)) {
|
object CommercialPaperSchemaV1 : MappedSchema(schemaFamily = CommercialPaperSchema.javaClass, version = 1, mappedTypes = listOf(PersistentCommercialPaperState::class.java)) {
|
||||||
|
|
||||||
override val migrationResource = "commercial-paper.changelog-master"
|
override val migrationResource = "commercial-paper.changelog-master"
|
||||||
|
@ -20,6 +20,7 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import javax.persistence.AttributeConverter
|
import javax.persistence.AttributeConverter
|
||||||
|
import javax.persistence.PersistenceException
|
||||||
import javax.sql.DataSource
|
import javax.sql.DataSource
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -98,7 +99,8 @@ class CordaPersistence(
|
|||||||
cacheFactory: NamedCacheFactory,
|
cacheFactory: NamedCacheFactory,
|
||||||
attributeConverters: Collection<AttributeConverter<*, *>> = emptySet(),
|
attributeConverters: Collection<AttributeConverter<*, *>> = emptySet(),
|
||||||
customClassLoader: ClassLoader? = null,
|
customClassLoader: ClassLoader? = null,
|
||||||
val closeConnection: Boolean = true
|
val closeConnection: Boolean = true,
|
||||||
|
val errorHandler: (t: Throwable) -> Unit = {}
|
||||||
) : Closeable {
|
) : Closeable {
|
||||||
companion object {
|
companion object {
|
||||||
private val log = contextLogger()
|
private val log = contextLogger()
|
||||||
@ -189,10 +191,18 @@ class CordaPersistence(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun createSession(): Connection {
|
fun createSession(): Connection {
|
||||||
|
try {
|
||||||
// We need to set the database for the current [Thread] or [Fiber] here as some tests share threads across databases.
|
// We need to set the database for the current [Thread] or [Fiber] here as some tests share threads across databases.
|
||||||
_contextDatabase.set(this)
|
_contextDatabase.set(this)
|
||||||
currentDBSession().flush()
|
currentDBSession().flush()
|
||||||
return contextTransaction.connection
|
return contextTransaction.connection
|
||||||
|
} catch (sqlException: SQLException) {
|
||||||
|
errorHandler(sqlException)
|
||||||
|
throw sqlException
|
||||||
|
} catch (persistenceException: PersistenceException) {
|
||||||
|
errorHandler(persistenceException)
|
||||||
|
throw persistenceException
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -220,11 +230,19 @@ class CordaPersistence(
|
|||||||
recoverAnyNestedSQLException: Boolean, statement: DatabaseTransaction.() -> T): T {
|
recoverAnyNestedSQLException: Boolean, statement: DatabaseTransaction.() -> T): T {
|
||||||
_contextDatabase.set(this)
|
_contextDatabase.set(this)
|
||||||
val outer = contextTransactionOrNull
|
val outer = contextTransactionOrNull
|
||||||
|
try {
|
||||||
return if (outer != null) {
|
return if (outer != null) {
|
||||||
outer.statement()
|
outer.statement()
|
||||||
} else {
|
} else {
|
||||||
inTopLevelTransaction(isolationLevel, recoverableFailureTolerance, recoverAnyNestedSQLException, statement)
|
inTopLevelTransaction(isolationLevel, recoverableFailureTolerance, recoverAnyNestedSQLException, statement)
|
||||||
}
|
}
|
||||||
|
} catch (sqlException: SQLException) {
|
||||||
|
errorHandler(sqlException)
|
||||||
|
throw sqlException
|
||||||
|
} catch (persistenceException: PersistenceException) {
|
||||||
|
errorHandler(persistenceException)
|
||||||
|
throw persistenceException
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -228,6 +228,12 @@ dependencies {
|
|||||||
// Required by JVMAgentUtil (x-compatible java 8 & 11 agent lookup mechanism)
|
// Required by JVMAgentUtil (x-compatible java 8 & 11 agent lookup mechanism)
|
||||||
compile files("${System.properties['java.home']}/../lib/tools.jar")
|
compile files("${System.properties['java.home']}/../lib/tools.jar")
|
||||||
|
|
||||||
|
// Byteman for runtime (termination) rules injection on the running node
|
||||||
|
// Submission tool allowing to install rules on running nodes
|
||||||
|
integrationTestCompile "org.jboss.byteman:byteman-submit:4.0.3"
|
||||||
|
// The actual Byteman agent which should only be in the classpath of the out of process nodes
|
||||||
|
integrationTestCompile "org.jboss.byteman:byteman:4.0.3"
|
||||||
|
|
||||||
testCompile(project(':test-cli'))
|
testCompile(project(':test-cli'))
|
||||||
testCompile(project(':test-utils'))
|
testCompile(project(':test-utils'))
|
||||||
|
|
||||||
@ -237,6 +243,8 @@ dependencies {
|
|||||||
slowIntegrationTestCompile configurations.testCompile
|
slowIntegrationTestCompile configurations.testCompile
|
||||||
slowIntegrationTestRuntime configurations.runtime
|
slowIntegrationTestRuntime configurations.runtime
|
||||||
slowIntegrationTestRuntime configurations.testRuntime
|
slowIntegrationTestRuntime configurations.testRuntime
|
||||||
|
|
||||||
|
testCompile project(':testing:cordapps:dbfailure:dbfworkflows')
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType(JavaCompile) {
|
tasks.withType(JavaCompile) {
|
||||||
|
@ -17,6 +17,7 @@ import net.corda.core.utilities.getOrThrow
|
|||||||
import net.corda.core.utilities.unwrap
|
import net.corda.core.utilities.unwrap
|
||||||
import net.corda.node.services.Permissions
|
import net.corda.node.services.Permissions
|
||||||
import net.corda.node.services.statemachine.FlowTimeoutException
|
import net.corda.node.services.statemachine.FlowTimeoutException
|
||||||
|
import net.corda.node.services.statemachine.StaffedFlowHospital
|
||||||
import net.corda.testing.core.ALICE_NAME
|
import net.corda.testing.core.ALICE_NAME
|
||||||
import net.corda.testing.core.BOB_NAME
|
import net.corda.testing.core.BOB_NAME
|
||||||
import net.corda.testing.core.singleIdentity
|
import net.corda.testing.core.singleIdentity
|
||||||
@ -25,6 +26,7 @@ import net.corda.testing.driver.driver
|
|||||||
import net.corda.testing.node.User
|
import net.corda.testing.node.User
|
||||||
import org.assertj.core.api.Assertions.assertThatExceptionOfType
|
import org.assertj.core.api.Assertions.assertThatExceptionOfType
|
||||||
import org.hibernate.exception.ConstraintViolationException
|
import org.hibernate.exception.ConstraintViolationException
|
||||||
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.lang.management.ManagementFactory
|
import java.lang.management.ManagementFactory
|
||||||
@ -46,6 +48,12 @@ class FlowRetryTest {
|
|||||||
TransientConnectionFailureFlow.retryCount = -1
|
TransientConnectionFailureFlow.retryCount = -1
|
||||||
WrappedTransientConnectionFailureFlow.retryCount = -1
|
WrappedTransientConnectionFailureFlow.retryCount = -1
|
||||||
GeneralExternalFailureFlow.retryCount = -1
|
GeneralExternalFailureFlow.retryCount = -1
|
||||||
|
StaffedFlowHospital.DatabaseEndocrinologist.customConditions.add { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun cleanUp() {
|
||||||
|
StaffedFlowHospital.DatabaseEndocrinologist.customConditions.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -390,7 +398,9 @@ class WrappedTransientConnectionFailureFlow(private val party: Party) : FlowLogi
|
|||||||
initiateFlow(party).send("hello there")
|
initiateFlow(party).send("hello there")
|
||||||
// checkpoint will restart the flow after the send
|
// checkpoint will restart the flow after the send
|
||||||
retryCount += 1
|
retryCount += 1
|
||||||
throw IllegalStateException("wrapped error message", IllegalStateException("another layer deep", SQLTransientConnectionException("Connection is not available")/*.fillInStackTrace()*/))
|
throw IllegalStateException(
|
||||||
|
"wrapped error message",
|
||||||
|
IllegalStateException("another layer deep", SQLTransientConnectionException("Connection is not available")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,12 +135,13 @@ class InitFlow(private val party: Party) : FlowLogic<String>() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("TooGenericExceptionThrown")
|
||||||
@InitiatedBy(InitFlow::class)
|
@InitiatedBy(InitFlow::class)
|
||||||
class InitiatedFlow(private val initiatingSession: FlowSession) : FlowLogic<Unit>() {
|
class InitiatedFlow(private val initiatingSession: FlowSession) : FlowLogic<Unit>() {
|
||||||
@Suspendable
|
@Suspendable
|
||||||
override fun call() {
|
override fun call() {
|
||||||
initiatingSession.receive<String>().unwrap { it }
|
initiatingSession.receive<String>().unwrap { it }
|
||||||
throw GenericJDBCException("Something went wrong!", SQLException("Oops!"))
|
throw Exception("Something went wrong!", SQLException("Oops!"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,313 @@
|
|||||||
|
package net.corda.node.services.vault
|
||||||
|
|
||||||
|
import com.r3.dbfailure.workflows.CreateStateFlow
|
||||||
|
import com.r3.dbfailure.workflows.CreateStateFlow.Initiator
|
||||||
|
import com.r3.dbfailure.workflows.CreateStateFlow.errorTargetsToNum
|
||||||
|
import net.corda.core.CordaRuntimeException
|
||||||
|
import net.corda.core.internal.concurrent.openFuture
|
||||||
|
import net.corda.core.messaging.startFlow
|
||||||
|
import net.corda.core.utilities.contextLogger
|
||||||
|
import net.corda.core.utilities.getOrThrow
|
||||||
|
import net.corda.core.utilities.seconds
|
||||||
|
import net.corda.node.services.Permissions
|
||||||
|
import net.corda.node.services.statemachine.StaffedFlowHospital
|
||||||
|
import net.corda.testing.core.ALICE_NAME
|
||||||
|
import net.corda.testing.driver.DriverParameters
|
||||||
|
import net.corda.testing.driver.driver
|
||||||
|
import net.corda.testing.node.User
|
||||||
|
import net.corda.testing.node.internal.findCordapp
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Test
|
||||||
|
import rx.exceptions.OnErrorNotImplementedException
|
||||||
|
import java.sql.SQLException
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.util.concurrent.TimeoutException
|
||||||
|
import javax.persistence.PersistenceException
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
|
||||||
|
class VaultObserverExceptionTest {
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
val log = contextLogger()
|
||||||
|
|
||||||
|
private fun testCordapps() = listOf(
|
||||||
|
findCordapp("com.r3.dbfailure.contracts"),
|
||||||
|
findCordapp("com.r3.dbfailure.workflows"),
|
||||||
|
findCordapp("com.r3.dbfailure.schemas"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
StaffedFlowHospital.DatabaseEndocrinologist.customConditions.clear()
|
||||||
|
StaffedFlowHospital.onFlowKeptForOvernightObservation.clear()
|
||||||
|
StaffedFlowHospital.onFlowAdmitted.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Causing an SqlException via a syntax error in a vault observer causes the flow to hit the
|
||||||
|
* DatabsaseEndocrinologist in the FlowHospital and being kept for overnight observation
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun unhandledSqlExceptionFromVaultObserverGetsHospitatlised() {
|
||||||
|
val testControlFuture = openFuture<Boolean>().toCompletableFuture()
|
||||||
|
|
||||||
|
StaffedFlowHospital.DatabaseEndocrinologist.customConditions.add {
|
||||||
|
when (it) {
|
||||||
|
is OnErrorNotImplementedException -> Assert.fail("OnErrorNotImplementedException should be unwrapped")
|
||||||
|
is SQLException -> {
|
||||||
|
testControlFuture.complete(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
driver(DriverParameters(
|
||||||
|
startNodesInProcess = true,
|
||||||
|
cordappsForAllNodes = testCordapps())) {
|
||||||
|
val aliceUser = User("user", "foo", setOf(Permissions.all()))
|
||||||
|
val aliceNode = startNode(providedName = ALICE_NAME, rpcUsers = listOf(aliceUser)).getOrThrow()
|
||||||
|
aliceNode.rpc.startFlow(
|
||||||
|
::Initiator,
|
||||||
|
"Syntax Error in Custom SQL",
|
||||||
|
CreateStateFlow.errorTargetsToNum(CreateStateFlow.ErrorTarget.ServiceSqlSyntaxError)
|
||||||
|
).returnValue.then { testControlFuture.complete(false) }
|
||||||
|
val foundExpectedException = testControlFuture.getOrThrow(30.seconds)
|
||||||
|
|
||||||
|
Assert.assertTrue(foundExpectedException)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throwing a random (non-SQL releated) exception from a vault observer causes the flow to be
|
||||||
|
* aborted when unhandled in user code
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun otherExceptionsFromVaultObserverBringFlowDown() {
|
||||||
|
driver(DriverParameters(
|
||||||
|
startNodesInProcess = true,
|
||||||
|
cordappsForAllNodes = testCordapps())) {
|
||||||
|
val aliceUser = User("user", "foo", setOf(Permissions.all()))
|
||||||
|
val aliceNode = startNode(providedName = ALICE_NAME, rpcUsers = listOf(aliceUser)).getOrThrow()
|
||||||
|
assertFailsWith(CordaRuntimeException::class, "Toys out of pram") {
|
||||||
|
aliceNode.rpc.startFlow(
|
||||||
|
::Initiator,
|
||||||
|
"InvalidParameterException",
|
||||||
|
CreateStateFlow.errorTargetsToNum(CreateStateFlow.ErrorTarget.ServiceThrowInvalidParameter)
|
||||||
|
).returnValue.getOrThrow(30.seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A random exception from a VaultObserver will bring the Rx Observer down, but can be handled in the flow
|
||||||
|
* triggering the observer, and the flow will continue successfully (for some values of success)
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun otherExceptionsFromVaultObserverCanBeSuppressedInFlow() {
|
||||||
|
driver(DriverParameters(
|
||||||
|
startNodesInProcess = true,
|
||||||
|
cordappsForAllNodes = testCordapps())) {
|
||||||
|
val aliceUser = User("user", "foo", setOf(Permissions.all()))
|
||||||
|
val aliceNode = startNode(providedName = ALICE_NAME, rpcUsers = listOf(aliceUser)).getOrThrow()
|
||||||
|
aliceNode.rpc.startFlow(::Initiator, "InvalidParameterException", CreateStateFlow.errorTargetsToNum(
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceThrowInvalidParameter,
|
||||||
|
CreateStateFlow.ErrorTarget.FlowSwallowErrors))
|
||||||
|
.returnValue.getOrThrow(30.seconds)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the state we are trying to persist triggers a persistence exception, the flow hospital will retry the flow
|
||||||
|
* and keep it in for observation if errors persist.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun persistenceExceptionOnCommitGetsRetriedAndThenGetsKeptForObservation() {
|
||||||
|
var admitted = 0
|
||||||
|
var observation = 0
|
||||||
|
StaffedFlowHospital.onFlowAdmitted.add {
|
||||||
|
++admitted
|
||||||
|
}
|
||||||
|
StaffedFlowHospital.onFlowKeptForOvernightObservation.add { _, _ ->
|
||||||
|
++observation
|
||||||
|
}
|
||||||
|
|
||||||
|
driver(DriverParameters(
|
||||||
|
startNodesInProcess = true,
|
||||||
|
cordappsForAllNodes = testCordapps())) {
|
||||||
|
val aliceUser = User("user", "foo", setOf(Permissions.all()))
|
||||||
|
val aliceNode = startNode(providedName = ALICE_NAME, rpcUsers = listOf(aliceUser)).getOrThrow()
|
||||||
|
assertFailsWith<TimeoutException> {
|
||||||
|
aliceNode.rpc.startFlow(::Initiator, "EntityManager", errorTargetsToNum(CreateStateFlow.ErrorTarget.TxInvalidState))
|
||||||
|
.returnValue.getOrThrow(Duration.of(30, ChronoUnit.SECONDS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.assertTrue("Exception from service has not been to Hospital", admitted > 0)
|
||||||
|
Assert.assertEquals(1, observation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If we have a state causing a database error lined up for persistence, calling jdbConnection() in
|
||||||
|
* the vault observer will trigger a flush that throws. This will be kept in for observation.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun persistenceExceptionOnFlushGetsRetriedAndThenGetsKeptForObservation() {
|
||||||
|
var counter = 0
|
||||||
|
StaffedFlowHospital.DatabaseEndocrinologist.customConditions.add {
|
||||||
|
when (it) {
|
||||||
|
is OnErrorNotImplementedException -> Assert.fail("OnErrorNotImplementedException should be unwrapped")
|
||||||
|
is PersistenceException -> {
|
||||||
|
++counter
|
||||||
|
log.info("Got a PersistentException in the flow hospital count = $counter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
var observation = 0
|
||||||
|
StaffedFlowHospital.onFlowKeptForOvernightObservation.add { _, _ ->
|
||||||
|
++observation
|
||||||
|
}
|
||||||
|
|
||||||
|
driver(DriverParameters(
|
||||||
|
startNodesInProcess = true,
|
||||||
|
cordappsForAllNodes = testCordapps())) {
|
||||||
|
val aliceUser = User("user", "foo", setOf(Permissions.all()))
|
||||||
|
val aliceNode = startNode(providedName = ALICE_NAME, rpcUsers = listOf(aliceUser)).getOrThrow()
|
||||||
|
assertFailsWith<TimeoutException>("PersistenceException") {
|
||||||
|
aliceNode.rpc.startFlow(::Initiator, "EntityManager", errorTargetsToNum(
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceValidUpdate,
|
||||||
|
CreateStateFlow.ErrorTarget.TxInvalidState))
|
||||||
|
.returnValue.getOrThrow(30.seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Assert.assertTrue("Flow has not been to hospital", counter > 0)
|
||||||
|
Assert.assertEquals(1, observation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If we have a state causing a database error lined up for persistence, calling jdbConnection() in
|
||||||
|
* the vault observer will trigger a flush that throws.
|
||||||
|
* Trying to catch and suppress that exception in the flow around the code triggering the vault observer
|
||||||
|
* does not change the outcome - the first exception in the service will bring the service down and will
|
||||||
|
* be caught by the flow, but the state machine will error the flow anyway as Corda code threw.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun persistenceExceptionOnFlushInVaultObserverCannotBeSuppressedInFlow() {
|
||||||
|
var counter = 0
|
||||||
|
StaffedFlowHospital.DatabaseEndocrinologist.customConditions.add {
|
||||||
|
when (it) {
|
||||||
|
is OnErrorNotImplementedException -> Assert.fail("OnErrorNotImplementedException should be unwrapped")
|
||||||
|
is PersistenceException -> {
|
||||||
|
++counter
|
||||||
|
log.info("Got a PersistentException in the flow hospital count = $counter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
driver(DriverParameters(
|
||||||
|
startNodesInProcess = true,
|
||||||
|
cordappsForAllNodes = testCordapps())) {
|
||||||
|
val aliceUser = User("user", "foo", setOf(Permissions.all()))
|
||||||
|
val aliceNode = startNode(providedName = ALICE_NAME, rpcUsers = listOf(aliceUser)).getOrThrow()
|
||||||
|
val flowHandle = aliceNode.rpc.startFlow(
|
||||||
|
::Initiator,
|
||||||
|
"EntityManager",
|
||||||
|
CreateStateFlow.errorTargetsToNum(
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceValidUpdate,
|
||||||
|
CreateStateFlow.ErrorTarget.TxInvalidState,
|
||||||
|
CreateStateFlow.ErrorTarget.FlowSwallowErrors))
|
||||||
|
val flowResult = flowHandle.returnValue
|
||||||
|
assertFailsWith<TimeoutException>("PersistenceException") { flowResult.getOrThrow(30.seconds) }
|
||||||
|
Assert.assertTrue("Flow has not been to hospital", counter > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If we have a state causing a persistence exception lined up for persistence, calling jdbConnection() in
|
||||||
|
* the vault observer will trigger a flush that throws.
|
||||||
|
* Trying to catch and suppress that exception inside the service does protect the service, but the new
|
||||||
|
* interceptor will fail the flow anyway. The flow will be kept in for observation if errors persist.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun persistenceExceptionOnFlushInVaultObserverCannotBeSuppressedInService() {
|
||||||
|
var counter = 0
|
||||||
|
StaffedFlowHospital.DatabaseEndocrinologist.customConditions.add {
|
||||||
|
when (it) {
|
||||||
|
is OnErrorNotImplementedException -> Assert.fail("OnErrorNotImplementedException should be unwrapped")
|
||||||
|
is PersistenceException -> {
|
||||||
|
++counter
|
||||||
|
log.info("Got a PersistentException in the flow hospital count = $counter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
driver(DriverParameters(
|
||||||
|
startNodesInProcess = true,
|
||||||
|
cordappsForAllNodes = testCordapps())) {
|
||||||
|
val aliceUser = User("user", "foo", setOf(Permissions.all()))
|
||||||
|
val aliceNode = startNode(providedName = ALICE_NAME, rpcUsers = listOf(aliceUser)).getOrThrow()
|
||||||
|
val flowHandle = aliceNode.rpc.startFlow(
|
||||||
|
::Initiator, "EntityManager",
|
||||||
|
CreateStateFlow.errorTargetsToNum(
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceValidUpdate,
|
||||||
|
CreateStateFlow.ErrorTarget.TxInvalidState,
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceSwallowErrors))
|
||||||
|
val flowResult = flowHandle.returnValue
|
||||||
|
assertFailsWith<TimeoutException>("PersistenceException") { flowResult.getOrThrow(30.seconds) }
|
||||||
|
Assert.assertTrue("Flow has not been to hospital", counter > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User code throwing a syntax error in a raw vault observer will break the recordTransaction call,
|
||||||
|
* therefore handling it in flow code is no good, and the error will be passed to the flow hospital via the
|
||||||
|
* interceptor.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun syntaxErrorInUserCodeInServiceCannotBeSuppressedInFlow() {
|
||||||
|
val testControlFuture = openFuture<Boolean>()
|
||||||
|
StaffedFlowHospital.onFlowKeptForOvernightObservation.add { _, _ ->
|
||||||
|
log.info("Flow has been kept for overnight observation")
|
||||||
|
testControlFuture.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
driver(DriverParameters(
|
||||||
|
startNodesInProcess = true,
|
||||||
|
cordappsForAllNodes = testCordapps())) {
|
||||||
|
val aliceUser = User("user", "foo", setOf(Permissions.all()))
|
||||||
|
val aliceNode = startNode(providedName = ALICE_NAME, rpcUsers = listOf(aliceUser)).getOrThrow()
|
||||||
|
val flowHandle = aliceNode.rpc.startFlow(::Initiator, "EntityManager", CreateStateFlow.errorTargetsToNum(
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceSqlSyntaxError,
|
||||||
|
CreateStateFlow.ErrorTarget.FlowSwallowErrors))
|
||||||
|
val flowResult = flowHandle.returnValue
|
||||||
|
flowResult.then {
|
||||||
|
log.info("Flow has finished")
|
||||||
|
testControlFuture.set(false)
|
||||||
|
}
|
||||||
|
Assert.assertTrue("Flow has not been kept in hospital", testControlFuture.getOrThrow(30.seconds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User code throwing a syntax error and catching suppressing that within the observer code is fine
|
||||||
|
* and should not have any impact on the rest of the flow
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun syntaxErrorInUserCodeInServiceCanBeSuppressedInService() {
|
||||||
|
driver(DriverParameters(
|
||||||
|
startNodesInProcess = true,
|
||||||
|
cordappsForAllNodes = testCordapps())) {
|
||||||
|
val aliceUser = User("user", "foo", setOf(Permissions.all()))
|
||||||
|
val aliceNode = startNode(providedName = ALICE_NAME, rpcUsers = listOf(aliceUser)).getOrThrow()
|
||||||
|
val flowHandle = aliceNode.rpc.startFlow(::Initiator, "EntityManager", CreateStateFlow.errorTargetsToNum(
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceSqlSyntaxError,
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceSwallowErrors))
|
||||||
|
val flowResult = flowHandle.returnValue
|
||||||
|
flowResult.getOrThrow(30.seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1097,6 +1097,7 @@ class FlowStarterImpl(private val smm: StateMachineManager, private val flowLogi
|
|||||||
override val deduplicationHandler: DeduplicationHandler
|
override val deduplicationHandler: DeduplicationHandler
|
||||||
get() = this
|
get() = this
|
||||||
|
|
||||||
|
override val flowId: StateMachineRunId = StateMachineRunId.createRandom()
|
||||||
override val flowLogic: FlowLogic<T>
|
override val flowLogic: FlowLogic<T>
|
||||||
get() = logic
|
get() = logic
|
||||||
override val context: InvocationContext
|
override val context: InvocationContext
|
||||||
@ -1139,8 +1140,17 @@ fun createCordaPersistence(databaseConfig: DatabaseConfig,
|
|||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
org.hibernate.type.descriptor.java.JavaTypeDescriptorRegistry.INSTANCE.addDescriptor(AbstractPartyDescriptor(wellKnownPartyFromX500Name, wellKnownPartyFromAnonymous))
|
org.hibernate.type.descriptor.java.JavaTypeDescriptorRegistry.INSTANCE.addDescriptor(AbstractPartyDescriptor(wellKnownPartyFromX500Name, wellKnownPartyFromAnonymous))
|
||||||
val attributeConverters = listOf(PublicKeyToTextConverter(), AbstractPartyToX500NameAsStringConverter(wellKnownPartyFromX500Name, wellKnownPartyFromAnonymous))
|
val attributeConverters = listOf(PublicKeyToTextConverter(), AbstractPartyToX500NameAsStringConverter(wellKnownPartyFromX500Name, wellKnownPartyFromAnonymous))
|
||||||
|
|
||||||
val jdbcUrl = hikariProperties.getProperty("dataSource.url", "")
|
val jdbcUrl = hikariProperties.getProperty("dataSource.url", "")
|
||||||
return CordaPersistence(databaseConfig, schemaService.schemaOptions.keys, jdbcUrl, cacheFactory, attributeConverters, customClassLoader)
|
return CordaPersistence(
|
||||||
|
databaseConfig,
|
||||||
|
schemaService.schemaOptions.keys,
|
||||||
|
jdbcUrl,
|
||||||
|
cacheFactory,
|
||||||
|
attributeConverters, customClassLoader,
|
||||||
|
errorHandler = { t ->
|
||||||
|
FlowStateMachineImpl.currentStateMachine()?.scheduleEvent(Event.Error(t))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun CordaPersistence.startHikariPool(hikariProperties: Properties, databaseConfig: DatabaseConfig, schemas: Set<MappedSchema>, metricRegistry: MetricRegistry? = null, cordappLoader: CordappLoader? = null, currentDir: Path? = null, ourName: CordaX500Name) {
|
fun CordaPersistence.startHikariPool(hikariProperties: Properties, databaseConfig: DatabaseConfig, schemas: Set<MappedSchema>, metricRegistry: MetricRegistry? = null, cordappLoader: CordappLoader? = null, currentDir: Path? = null, ourName: CordaX500Name) {
|
||||||
|
@ -29,6 +29,7 @@ object NodeInfoSchemaV1 : MappedSchema(
|
|||||||
@Column(name = "node_info_id", nullable = false)
|
@Column(name = "node_info_id", nullable = false)
|
||||||
var id: Int,
|
var id: Int,
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // database column width
|
||||||
@Column(name = "node_info_hash", length = 64, nullable = false)
|
@Column(name = "node_info_hash", length = 64, nullable = false)
|
||||||
val hash: String,
|
val hash: String,
|
||||||
|
|
||||||
|
@ -11,6 +11,7 @@ import net.corda.core.contracts.ScheduledStateRef
|
|||||||
import net.corda.core.contracts.StateRef
|
import net.corda.core.contracts.StateRef
|
||||||
import net.corda.core.flows.FlowLogic
|
import net.corda.core.flows.FlowLogic
|
||||||
import net.corda.core.flows.FlowLogicRefFactory
|
import net.corda.core.flows.FlowLogicRefFactory
|
||||||
|
import net.corda.core.flows.StateMachineRunId
|
||||||
import net.corda.core.internal.*
|
import net.corda.core.internal.*
|
||||||
import net.corda.core.internal.concurrent.flatMap
|
import net.corda.core.internal.concurrent.flatMap
|
||||||
import net.corda.core.internal.concurrent.openFuture
|
import net.corda.core.internal.concurrent.openFuture
|
||||||
@ -239,6 +240,7 @@ class NodeSchedulerService(private val clock: CordaClock,
|
|||||||
}
|
}
|
||||||
|
|
||||||
private inner class FlowStartDeduplicationHandler(val scheduledState: ScheduledStateRef, override val flowLogic: FlowLogic<Any?>, override val context: InvocationContext) : DeduplicationHandler, ExternalEvent.ExternalStartFlowEvent<Any?> {
|
private inner class FlowStartDeduplicationHandler(val scheduledState: ScheduledStateRef, override val flowLogic: FlowLogic<Any?>, override val context: InvocationContext) : DeduplicationHandler, ExternalEvent.ExternalStartFlowEvent<Any?> {
|
||||||
|
override val flowId: StateMachineRunId = StateMachineRunId.createRandom()
|
||||||
override val externalCause: ExternalEvent
|
override val externalCause: ExternalEvent
|
||||||
get() = this
|
get() = this
|
||||||
override val deduplicationHandler: FlowStartDeduplicationHandler
|
override val deduplicationHandler: FlowStartDeduplicationHandler
|
||||||
|
@ -2,12 +2,16 @@ package net.corda.node.services.identity
|
|||||||
|
|
||||||
import net.corda.core.crypto.Crypto
|
import net.corda.core.crypto.Crypto
|
||||||
import net.corda.core.crypto.toStringShort
|
import net.corda.core.crypto.toStringShort
|
||||||
import net.corda.core.identity.*
|
import net.corda.core.identity.AbstractParty
|
||||||
|
import net.corda.core.identity.AnonymousParty
|
||||||
|
import net.corda.core.identity.CordaX500Name
|
||||||
|
import net.corda.core.identity.Party
|
||||||
|
import net.corda.core.identity.PartyAndCertificate
|
||||||
|
import net.corda.core.identity.x500Matches
|
||||||
import net.corda.core.internal.CertRole
|
import net.corda.core.internal.CertRole
|
||||||
import net.corda.core.internal.NamedCacheFactory
|
import net.corda.core.internal.NamedCacheFactory
|
||||||
import net.corda.core.internal.hash
|
import net.corda.core.internal.hash
|
||||||
import net.corda.core.internal.toSet
|
import net.corda.core.internal.toSet
|
||||||
import net.corda.core.node.services.IdentityService
|
|
||||||
import net.corda.core.node.services.UnknownAnonymousPartyException
|
import net.corda.core.node.services.UnknownAnonymousPartyException
|
||||||
import net.corda.core.serialization.SingletonSerializeAsToken
|
import net.corda.core.serialization.SingletonSerializeAsToken
|
||||||
import net.corda.core.utilities.MAX_HASH_HEX_SIZE
|
import net.corda.core.utilities.MAX_HASH_HEX_SIZE
|
||||||
@ -29,13 +33,18 @@ import org.hibernate.annotations.Type
|
|||||||
import org.hibernate.internal.util.collections.ArrayHelper.EMPTY_BYTE_ARRAY
|
import org.hibernate.internal.util.collections.ArrayHelper.EMPTY_BYTE_ARRAY
|
||||||
import java.security.InvalidAlgorithmParameterException
|
import java.security.InvalidAlgorithmParameterException
|
||||||
import java.security.PublicKey
|
import java.security.PublicKey
|
||||||
import java.security.cert.*
|
import java.security.cert.CertPathValidatorException
|
||||||
|
import java.security.cert.CertStore
|
||||||
|
import java.security.cert.CertificateExpiredException
|
||||||
|
import java.security.cert.CertificateNotYetValidException
|
||||||
|
import java.security.cert.CollectionCertStoreParameters
|
||||||
|
import java.security.cert.TrustAnchor
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import javax.annotation.concurrent.ThreadSafe
|
import javax.annotation.concurrent.ThreadSafe
|
||||||
import javax.persistence.Column
|
import javax.persistence.Column
|
||||||
import javax.persistence.Entity
|
import javax.persistence.Entity
|
||||||
import javax.persistence.Id
|
import javax.persistence.Id
|
||||||
import kotlin.IllegalStateException
|
|
||||||
import kotlin.collections.HashSet
|
import kotlin.collections.HashSet
|
||||||
import kotlin.streams.toList
|
import kotlin.streams.toList
|
||||||
|
|
||||||
@ -147,6 +156,7 @@ class PersistentIdentityService(cacheFactory: NamedCacheFactory) : SingletonSeri
|
|||||||
@javax.persistence.Table(name = NAME_TO_HASH_TABLE_NAME)
|
@javax.persistence.Table(name = NAME_TO_HASH_TABLE_NAME)
|
||||||
class PersistentPartyToPublicKeyHash(
|
class PersistentPartyToPublicKeyHash(
|
||||||
@Id
|
@Id
|
||||||
|
@Suppress("MagicNumber") // database column width
|
||||||
@Column(name = NAME_COLUMN_NAME, length = 128, nullable = false)
|
@Column(name = NAME_COLUMN_NAME, length = 128, nullable = false)
|
||||||
var name: String = "",
|
var name: String = "",
|
||||||
|
|
||||||
|
@ -85,6 +85,7 @@ class P2PMessageDeduplicator(cacheFactory: NamedCacheFactory, private val databa
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
|
@Suppress("MagicNumber") // database column width
|
||||||
@javax.persistence.Table(name = "${NODE_DATABASE_PREFIX}message_ids")
|
@javax.persistence.Table(name = "${NODE_DATABASE_PREFIX}message_ids")
|
||||||
class ProcessedMessage(
|
class ProcessedMessage(
|
||||||
@Id
|
@Id
|
||||||
|
@ -3,6 +3,7 @@ package net.corda.node.services.messaging
|
|||||||
import co.paralleluniverse.fibers.Suspendable
|
import co.paralleluniverse.fibers.Suspendable
|
||||||
import com.codahale.metrics.MetricRegistry
|
import com.codahale.metrics.MetricRegistry
|
||||||
import net.corda.core.crypto.toStringShort
|
import net.corda.core.crypto.toStringShort
|
||||||
|
import net.corda.core.flows.StateMachineRunId
|
||||||
import net.corda.core.identity.CordaX500Name
|
import net.corda.core.identity.CordaX500Name
|
||||||
import net.corda.core.internal.NamedCacheFactory
|
import net.corda.core.internal.NamedCacheFactory
|
||||||
import net.corda.core.internal.ThreadBox
|
import net.corda.core.internal.ThreadBox
|
||||||
@ -424,6 +425,7 @@ class P2PMessagingClient(val config: NodeConfiguration,
|
|||||||
private inner class MessageDeduplicationHandler(val artemisMessage: ClientMessage, override val receivedMessage: ReceivedMessage) : DeduplicationHandler, ExternalEvent.ExternalMessageEvent {
|
private inner class MessageDeduplicationHandler(val artemisMessage: ClientMessage, override val receivedMessage: ReceivedMessage) : DeduplicationHandler, ExternalEvent.ExternalMessageEvent {
|
||||||
override val externalCause: ExternalEvent
|
override val externalCause: ExternalEvent
|
||||||
get() = this
|
get() = this
|
||||||
|
override val flowId: StateMachineRunId = StateMachineRunId.createRandom()
|
||||||
override val deduplicationHandler: MessageDeduplicationHandler
|
override val deduplicationHandler: MessageDeduplicationHandler
|
||||||
get() = this
|
get() = this
|
||||||
|
|
||||||
|
@ -29,6 +29,7 @@ class DBCheckpointStorage : CheckpointStorage {
|
|||||||
@javax.persistence.Table(name = "${NODE_DATABASE_PREFIX}checkpoints")
|
@javax.persistence.Table(name = "${NODE_DATABASE_PREFIX}checkpoints")
|
||||||
class DBCheckpoint(
|
class DBCheckpoint(
|
||||||
@Id
|
@Id
|
||||||
|
@Suppress("MagicNumber") // database column width
|
||||||
@Column(name = "checkpoint_id", length = 64, nullable = false)
|
@Column(name = "checkpoint_id", length = 64, nullable = false)
|
||||||
var checkpointId: String = "",
|
var checkpointId: String = "",
|
||||||
|
|
||||||
|
@ -30,6 +30,7 @@ import kotlin.streams.toList
|
|||||||
|
|
||||||
class DBTransactionStorage(private val database: CordaPersistence, cacheFactory: NamedCacheFactory) : WritableTransactionStorage, SingletonSerializeAsToken() {
|
class DBTransactionStorage(private val database: CordaPersistence, cacheFactory: NamedCacheFactory) : WritableTransactionStorage, SingletonSerializeAsToken() {
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // database column width
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "${NODE_DATABASE_PREFIX}transactions")
|
@Table(name = "${NODE_DATABASE_PREFIX}transactions")
|
||||||
class DBTransaction(
|
class DBTransaction(
|
||||||
|
@ -120,10 +120,21 @@ class ActionExecutorImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("TooGenericExceptionCaught") // this is fully intentional here, see comment in the catch clause
|
||||||
@Suspendable
|
@Suspendable
|
||||||
private fun executeAcknowledgeMessages(action: Action.AcknowledgeMessages) {
|
private fun executeAcknowledgeMessages(action: Action.AcknowledgeMessages) {
|
||||||
action.deduplicationHandlers.forEach {
|
action.deduplicationHandlers.forEach {
|
||||||
|
try {
|
||||||
it.afterDatabaseTransaction()
|
it.afterDatabaseTransaction()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Catch all exceptions that occur in the [DeduplicationHandler]s (although errors should be unlikely)
|
||||||
|
// It is deemed safe for errors to occur here
|
||||||
|
// Therefore the current transition should not fail if something does go wrong
|
||||||
|
log.info(
|
||||||
|
"An error occurred executing a deduplication post-database commit handler. Continuing, as it is safe to do so.",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,8 +229,10 @@ class ActionExecutorImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("TooGenericExceptionCaught") // this is fully intentional here, see comment in the catch clause
|
||||||
@Suspendable
|
@Suspendable
|
||||||
private fun executeAsyncOperation(fiber: FlowFiber, action: Action.ExecuteAsyncOperation) {
|
private fun executeAsyncOperation(fiber: FlowFiber, action: Action.ExecuteAsyncOperation) {
|
||||||
|
try {
|
||||||
val operationFuture = action.operation.execute(action.deduplicationId)
|
val operationFuture = action.operation.execute(action.deduplicationId)
|
||||||
operationFuture.thenMatch(
|
operationFuture.thenMatch(
|
||||||
success = { result ->
|
success = { result ->
|
||||||
@ -229,6 +242,11 @@ class ActionExecutorImpl(
|
|||||||
fiber.scheduleEvent(Event.Error(exception))
|
fiber.scheduleEvent(Event.Error(exception))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Catch and wrap any unexpected exceptions from the async operation
|
||||||
|
// Wrapping the exception allows it to be better handled by the flow hospital
|
||||||
|
throw AsyncOperationTransitionException(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun executeRetryFlowFromSafePoint(action: Action.RetryFlowFromSafePoint) {
|
private fun executeRetryFlowFromSafePoint(action: Action.RetryFlowFromSafePoint) {
|
||||||
|
@ -16,6 +16,7 @@ import net.corda.core.identity.Party
|
|||||||
import net.corda.core.internal.*
|
import net.corda.core.internal.*
|
||||||
import net.corda.core.internal.concurrent.OpenFuture
|
import net.corda.core.internal.concurrent.OpenFuture
|
||||||
import net.corda.core.internal.concurrent.map
|
import net.corda.core.internal.concurrent.map
|
||||||
|
import net.corda.core.internal.concurrent.mapError
|
||||||
import net.corda.core.internal.concurrent.openFuture
|
import net.corda.core.internal.concurrent.openFuture
|
||||||
import net.corda.core.messaging.DataFeed
|
import net.corda.core.messaging.DataFeed
|
||||||
import net.corda.core.serialization.SerializedBytes
|
import net.corda.core.serialization.SerializedBytes
|
||||||
@ -113,7 +114,7 @@ class SingleThreadedStateMachineManager(
|
|||||||
private var checkpointSerializationContext: CheckpointSerializationContext? = null
|
private var checkpointSerializationContext: CheckpointSerializationContext? = null
|
||||||
private var actionExecutor: ActionExecutor? = null
|
private var actionExecutor: ActionExecutor? = null
|
||||||
|
|
||||||
override val flowHospital: StaffedFlowHospital = StaffedFlowHospital(flowMessaging, ourSenderUUID)
|
override val flowHospital: StaffedFlowHospital = makeFlowHospital()
|
||||||
private val transitionExecutor = makeTransitionExecutor()
|
private val transitionExecutor = makeTransitionExecutor()
|
||||||
|
|
||||||
override val allStateMachines: List<FlowLogic<*>>
|
override val allStateMachines: List<FlowLogic<*>>
|
||||||
@ -210,12 +211,14 @@ class SingleThreadedStateMachineManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun <A> startFlow(
|
private fun <A> startFlow(
|
||||||
|
flowId: StateMachineRunId,
|
||||||
flowLogic: FlowLogic<A>,
|
flowLogic: FlowLogic<A>,
|
||||||
context: InvocationContext,
|
context: InvocationContext,
|
||||||
ourIdentity: Party?,
|
ourIdentity: Party?,
|
||||||
deduplicationHandler: DeduplicationHandler?
|
deduplicationHandler: DeduplicationHandler?
|
||||||
): CordaFuture<FlowStateMachine<A>> {
|
): CordaFuture<FlowStateMachine<A>> {
|
||||||
return startFlowInternal(
|
return startFlowInternal(
|
||||||
|
flowId,
|
||||||
invocationContext = context,
|
invocationContext = context,
|
||||||
flowLogic = flowLogic,
|
flowLogic = flowLogic,
|
||||||
flowStart = FlowStart.Explicit,
|
flowStart = FlowStart.Explicit,
|
||||||
@ -230,7 +233,10 @@ class SingleThreadedStateMachineManager(
|
|||||||
cancelTimeoutIfScheduled(id)
|
cancelTimeoutIfScheduled(id)
|
||||||
val flow = flows.remove(id)
|
val flow = flows.remove(id)
|
||||||
if (flow != null) {
|
if (flow != null) {
|
||||||
logger.debug("Killing flow known to physical node.")
|
flow.fiber.transientState?.let {
|
||||||
|
flow.fiber.transientState = TransientReference(it.value.copy(isRemoved = true))
|
||||||
|
}
|
||||||
|
logger.info("Killing flow $id known to this node.")
|
||||||
decrementLiveFibers()
|
decrementLiveFibers()
|
||||||
totalFinishedFlows.inc()
|
totalFinishedFlows.inc()
|
||||||
try {
|
try {
|
||||||
@ -239,6 +245,7 @@ class SingleThreadedStateMachineManager(
|
|||||||
} finally {
|
} finally {
|
||||||
database.transaction {
|
database.transaction {
|
||||||
checkpointStorage.removeCheckpoint(id)
|
checkpointStorage.removeCheckpoint(id)
|
||||||
|
serviceHub.vaultService.softLockRelease(id.uuid)
|
||||||
}
|
}
|
||||||
transitionExecutor.forceRemoveFlow(id)
|
transitionExecutor.forceRemoveFlow(id)
|
||||||
unfinishedFibers.countDown()
|
unfinishedFibers.countDown()
|
||||||
@ -343,9 +350,11 @@ class SingleThreadedStateMachineManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("TooGenericExceptionCaught", "ComplexMethod", "MaxLineLength") // this is fully intentional here, see comment in the catch clause
|
||||||
override fun retryFlowFromSafePoint(currentState: StateMachineState) {
|
override fun retryFlowFromSafePoint(currentState: StateMachineState) {
|
||||||
// Get set of external events
|
// Get set of external events
|
||||||
val flowId = currentState.flowLogic.runId
|
val flowId = currentState.flowLogic.runId
|
||||||
|
try {
|
||||||
val oldFlowLeftOver = mutex.locked { flows[flowId] }?.fiber?.transientValues?.value?.eventQueue
|
val oldFlowLeftOver = mutex.locked { flows[flowId] }?.fiber?.transientValues?.value?.eventQueue
|
||||||
if (oldFlowLeftOver == null) {
|
if (oldFlowLeftOver == null) {
|
||||||
logger.error("Unable to find flow for flow $flowId. Something is very wrong. The flow will not retry.")
|
logger.error("Unable to find flow for flow $flowId. Something is very wrong. The flow will not retry.")
|
||||||
@ -396,6 +405,17 @@ class SingleThreadedStateMachineManager(
|
|||||||
deliverExternalEvent(externalEvent)
|
deliverExternalEvent(externalEvent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Failed to retry - manually put the flow in for observation rather than
|
||||||
|
// relying on the [HospitalisingInterceptor] to do so
|
||||||
|
val exceptions = (currentState.checkpoint.errorState as? ErrorState.Errored)
|
||||||
|
?.errors
|
||||||
|
?.map { it.exception }
|
||||||
|
?.plus(e) ?: emptyList()
|
||||||
|
logger.info("Failed to retry flow $flowId, keeping in for observation and aborting")
|
||||||
|
flowHospital.forceIntoOvernightObservation(flowId, exceptions)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun deliverExternalEvent(event: ExternalEvent) {
|
override fun deliverExternalEvent(event: ExternalEvent) {
|
||||||
@ -410,7 +430,13 @@ class SingleThreadedStateMachineManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> onExternalStartFlow(event: ExternalEvent.ExternalStartFlowEvent<T>) {
|
private fun <T> onExternalStartFlow(event: ExternalEvent.ExternalStartFlowEvent<T>) {
|
||||||
val future = startFlow(event.flowLogic, event.context, ourIdentity = null, deduplicationHandler = event.deduplicationHandler)
|
val future = startFlow(
|
||||||
|
event.flowId,
|
||||||
|
event.flowLogic,
|
||||||
|
event.context,
|
||||||
|
ourIdentity = null,
|
||||||
|
deduplicationHandler = event.deduplicationHandler
|
||||||
|
)
|
||||||
event.wireUpFuture(future)
|
event.wireUpFuture(future)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -476,7 +502,16 @@ class SingleThreadedStateMachineManager(
|
|||||||
is InitiatedFlowFactory.Core -> event.receivedMessage.platformVersion
|
is InitiatedFlowFactory.Core -> event.receivedMessage.platformVersion
|
||||||
is InitiatedFlowFactory.CorDapp -> null
|
is InitiatedFlowFactory.CorDapp -> null
|
||||||
}
|
}
|
||||||
startInitiatedFlow(flowLogic, event.deduplicationHandler, senderSession, initiatedSessionId, sessionMessage, senderCoreFlowVersion, initiatedFlowInfo)
|
startInitiatedFlow(
|
||||||
|
event.flowId,
|
||||||
|
flowLogic,
|
||||||
|
event.deduplicationHandler,
|
||||||
|
senderSession,
|
||||||
|
initiatedSessionId,
|
||||||
|
sessionMessage,
|
||||||
|
senderCoreFlowVersion,
|
||||||
|
initiatedFlowInfo
|
||||||
|
)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
logger.warn("Unable to initiate flow from $sender (appName=${sessionMessage.appName} " +
|
logger.warn("Unable to initiate flow from $sender (appName=${sessionMessage.appName} " +
|
||||||
"flowVersion=${sessionMessage.flowVersion}), sending to the flow hospital", t)
|
"flowVersion=${sessionMessage.flowVersion}), sending to the flow hospital", t)
|
||||||
@ -503,7 +538,9 @@ class SingleThreadedStateMachineManager(
|
|||||||
return serviceHub.getFlowFactory(initiatorFlowClass) ?: throw SessionRejectException.NotRegistered(initiatorFlowClass)
|
return serviceHub.getFlowFactory(initiatorFlowClass) ?: throw SessionRejectException.NotRegistered(initiatorFlowClass)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
private fun <A> startInitiatedFlow(
|
private fun <A> startInitiatedFlow(
|
||||||
|
flowId: StateMachineRunId,
|
||||||
flowLogic: FlowLogic<A>,
|
flowLogic: FlowLogic<A>,
|
||||||
initiatingMessageDeduplicationHandler: DeduplicationHandler,
|
initiatingMessageDeduplicationHandler: DeduplicationHandler,
|
||||||
peerSession: FlowSessionImpl,
|
peerSession: FlowSessionImpl,
|
||||||
@ -515,13 +552,19 @@ class SingleThreadedStateMachineManager(
|
|||||||
val flowStart = FlowStart.Initiated(peerSession, initiatedSessionId, initiatingMessage, senderCoreFlowVersion, initiatedFlowInfo)
|
val flowStart = FlowStart.Initiated(peerSession, initiatedSessionId, initiatingMessage, senderCoreFlowVersion, initiatedFlowInfo)
|
||||||
val ourIdentity = ourFirstIdentity
|
val ourIdentity = ourFirstIdentity
|
||||||
startFlowInternal(
|
startFlowInternal(
|
||||||
InvocationContext.peer(peerSession.counterparty.name), flowLogic, flowStart, ourIdentity,
|
flowId,
|
||||||
|
InvocationContext.peer(peerSession.counterparty.name),
|
||||||
|
flowLogic,
|
||||||
|
flowStart,
|
||||||
|
ourIdentity,
|
||||||
initiatingMessageDeduplicationHandler,
|
initiatingMessageDeduplicationHandler,
|
||||||
isStartIdempotent = false
|
isStartIdempotent = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList")
|
||||||
private fun <A> startFlowInternal(
|
private fun <A> startFlowInternal(
|
||||||
|
flowId: StateMachineRunId,
|
||||||
invocationContext: InvocationContext,
|
invocationContext: InvocationContext,
|
||||||
flowLogic: FlowLogic<A>,
|
flowLogic: FlowLogic<A>,
|
||||||
flowStart: FlowStart,
|
flowStart: FlowStart,
|
||||||
@ -529,7 +572,6 @@ class SingleThreadedStateMachineManager(
|
|||||||
deduplicationHandler: DeduplicationHandler?,
|
deduplicationHandler: DeduplicationHandler?,
|
||||||
isStartIdempotent: Boolean
|
isStartIdempotent: Boolean
|
||||||
): CordaFuture<FlowStateMachine<A>> {
|
): CordaFuture<FlowStateMachine<A>> {
|
||||||
val flowId = StateMachineRunId.createRandom()
|
|
||||||
|
|
||||||
// Before we construct the state machine state by freezing the FlowLogic we need to make sure that lazy properties
|
// Before we construct the state machine state by freezing the FlowLogic we need to make sure that lazy properties
|
||||||
// have access to the fiber (and thereby the service hub)
|
// have access to the fiber (and thereby the service hub)
|
||||||
@ -541,7 +583,28 @@ class SingleThreadedStateMachineManager(
|
|||||||
|
|
||||||
val flowCorDappVersion = createSubFlowVersion(serviceHub.cordappProvider.getCordappForFlow(flowLogic), serviceHub.myInfo.platformVersion)
|
val flowCorDappVersion = createSubFlowVersion(serviceHub.cordappProvider.getCordappForFlow(flowLogic), serviceHub.myInfo.platformVersion)
|
||||||
|
|
||||||
val initialCheckpoint = Checkpoint.create(
|
val flowAlreadyExists = mutex.locked { flows[flowId] != null }
|
||||||
|
|
||||||
|
val existingCheckpoint = if (flowAlreadyExists) {
|
||||||
|
// Load the flow's checkpoint
|
||||||
|
// The checkpoint will be missing if the flow failed before persisting the original checkpoint
|
||||||
|
// CORDA-3359 - Do not start/retry a flow that failed after deleting its checkpoint (the whole of the flow might replay)
|
||||||
|
checkpointStorage.getCheckpoint(flowId)?.let { serializedCheckpoint ->
|
||||||
|
val checkpoint = tryCheckpointDeserialize(serializedCheckpoint, flowId)
|
||||||
|
if (checkpoint == null) {
|
||||||
|
return openFuture<FlowStateMachine<A>>().mapError {
|
||||||
|
IllegalStateException("Unable to deserialize database checkpoint for flow $flowId. " +
|
||||||
|
"Something is very wrong. The flow will not retry.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checkpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// This is a brand new flow
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val checkpoint = existingCheckpoint ?: Checkpoint.create(
|
||||||
invocationContext,
|
invocationContext,
|
||||||
flowStart,
|
flowStart,
|
||||||
flowLogic.javaClass,
|
flowLogic.javaClass,
|
||||||
@ -550,13 +613,14 @@ class SingleThreadedStateMachineManager(
|
|||||||
flowCorDappVersion,
|
flowCorDappVersion,
|
||||||
flowLogic.isEnabledTimedFlow()
|
flowLogic.isEnabledTimedFlow()
|
||||||
).getOrThrow()
|
).getOrThrow()
|
||||||
|
|
||||||
val startedFuture = openFuture<Unit>()
|
val startedFuture = openFuture<Unit>()
|
||||||
val initialState = StateMachineState(
|
val initialState = StateMachineState(
|
||||||
checkpoint = initialCheckpoint,
|
checkpoint = checkpoint,
|
||||||
pendingDeduplicationHandlers = deduplicationHandler?.let { listOf(it) } ?: emptyList(),
|
pendingDeduplicationHandlers = deduplicationHandler?.let { listOf(it) } ?: emptyList(),
|
||||||
isFlowResumed = false,
|
isFlowResumed = false,
|
||||||
isTransactionTracked = false,
|
isTransactionTracked = false,
|
||||||
isAnyCheckpointPersisted = false,
|
isAnyCheckpointPersisted = existingCheckpoint != null,
|
||||||
isStartIdempotent = isStartIdempotent,
|
isStartIdempotent = isStartIdempotent,
|
||||||
isRemoved = false,
|
isRemoved = false,
|
||||||
flowLogic = flowLogic,
|
flowLogic = flowLogic,
|
||||||
@ -817,6 +881,12 @@ class SingleThreadedStateMachineManager(
|
|||||||
return interceptors.fold(transitionExecutor) { executor, interceptor -> interceptor(executor) }
|
return interceptors.fold(transitionExecutor) { executor, interceptor -> interceptor(executor) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun makeFlowHospital() : StaffedFlowHospital {
|
||||||
|
// If the node is running as a notary service, we don't retain errored session initiation requests in case of missing Cordapps
|
||||||
|
// to avoid memory leaks if the notary is under heavy load.
|
||||||
|
return StaffedFlowHospital(flowMessaging, serviceHub.clock, ourSenderUUID)
|
||||||
|
}
|
||||||
|
|
||||||
private fun InnerState.removeFlowOrderly(
|
private fun InnerState.removeFlowOrderly(
|
||||||
flow: Flow,
|
flow: Flow,
|
||||||
removalReason: FlowRemovalReason.OrderlyFinish,
|
removalReason: FlowRemovalReason.OrderlyFinish,
|
||||||
|
@ -10,48 +10,103 @@ import net.corda.core.identity.Party
|
|||||||
import net.corda.core.internal.DeclaredField
|
import net.corda.core.internal.DeclaredField
|
||||||
import net.corda.core.internal.ThreadBox
|
import net.corda.core.internal.ThreadBox
|
||||||
import net.corda.core.internal.TimedFlow
|
import net.corda.core.internal.TimedFlow
|
||||||
|
import net.corda.core.internal.VisibleForTesting
|
||||||
import net.corda.core.internal.bufferUntilSubscribed
|
import net.corda.core.internal.bufferUntilSubscribed
|
||||||
import net.corda.core.messaging.DataFeed
|
import net.corda.core.messaging.DataFeed
|
||||||
import net.corda.core.utilities.contextLogger
|
import net.corda.core.utilities.contextLogger
|
||||||
|
import net.corda.core.utilities.debug
|
||||||
|
import net.corda.core.utilities.minutes
|
||||||
import net.corda.core.utilities.seconds
|
import net.corda.core.utilities.seconds
|
||||||
import net.corda.node.services.FinalityHandler
|
import net.corda.node.services.FinalityHandler
|
||||||
import org.hibernate.exception.ConstraintViolationException
|
import org.hibernate.exception.ConstraintViolationException
|
||||||
import rx.subjects.PublishSubject
|
import rx.subjects.PublishSubject
|
||||||
import java.sql.SQLException
|
import java.sql.SQLException
|
||||||
import java.sql.SQLTransientConnectionException
|
import java.sql.SQLTransientConnectionException
|
||||||
|
import java.time.Clock
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.persistence.PersistenceException
|
||||||
|
import kotlin.concurrent.timerTask
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This hospital consults "staff" to see if they can automatically diagnose and treat flows.
|
* This hospital consults "staff" to see if they can automatically diagnose and treat flows.
|
||||||
*/
|
*/
|
||||||
class StaffedFlowHospital(private val flowMessaging: FlowMessaging, private val ourSenderUUID: String) {
|
class StaffedFlowHospital(private val flowMessaging: FlowMessaging,
|
||||||
private companion object {
|
private val clock: Clock,
|
||||||
|
private val ourSenderUUID: String) {
|
||||||
|
companion object {
|
||||||
private val log = contextLogger()
|
private val log = contextLogger()
|
||||||
private val staff = listOf(
|
private val staff = listOf(
|
||||||
DeadlockNurse,
|
DeadlockNurse,
|
||||||
DuplicateInsertSpecialist,
|
DuplicateInsertSpecialist,
|
||||||
DoctorTimeout,
|
DoctorTimeout,
|
||||||
FinalityDoctor,
|
FinalityDoctor,
|
||||||
TransientConnectionCardiologist
|
TransientConnectionCardiologist,
|
||||||
|
DatabaseEndocrinologist,
|
||||||
|
TransitionErrorGeneralPractitioner
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
val onFlowKeptForOvernightObservation = mutableListOf<(id: StateMachineRunId, by: List<String>) -> Unit>()
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
val onFlowDischarged = mutableListOf<(id: StateMachineRunId, by: List<String>) -> Unit>()
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
val onFlowAdmitted = mutableListOf<(id: StateMachineRunId) -> Unit>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val hospitalJobTimer = Timer("FlowHospitalJobTimer", true)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Register a task to log (at intervals) flows that are kept in hospital for overnight observation.
|
||||||
|
hospitalJobTimer.scheduleAtFixedRate(timerTask {
|
||||||
|
mutex.locked {
|
||||||
|
if (flowsInHospital.isNotEmpty()) {
|
||||||
|
// Get patients whose last record in their medical records is Outcome.OVERNIGHT_OBSERVATION.
|
||||||
|
val patientsUnderOvernightObservation =
|
||||||
|
flowsInHospital.filter { flowPatients[it.key]?.records?.last()?.outcome == Outcome.OVERNIGHT_OBSERVATION }
|
||||||
|
if (patientsUnderOvernightObservation.isNotEmpty())
|
||||||
|
log.warn("There are ${patientsUnderOvernightObservation.count()} flows kept for overnight observation. " +
|
||||||
|
"Affected flow ids: ${patientsUnderOvernightObservation.map { it.key.uuid.toString() }.joinToString()}")
|
||||||
|
}
|
||||||
|
if (treatableSessionInits.isNotEmpty()) {
|
||||||
|
log.warn("There are ${treatableSessionInits.count()} erroneous session initiations kept for overnight observation. " +
|
||||||
|
"Erroneous session initiation ids: ${treatableSessionInits.map { it.key.toString() }.joinToString()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1.minutes.toMillis(), 1.minutes.toMillis())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the flows that have been admitted to the hospital for treatment.
|
||||||
|
* Flows should be removed from [flowsInHospital] when they have completed a successful transition.
|
||||||
|
*/
|
||||||
|
private val flowsInHospital = ConcurrentHashMap<StateMachineRunId, FlowFiber>()
|
||||||
|
|
||||||
private val mutex = ThreadBox(object {
|
private val mutex = ThreadBox(object {
|
||||||
|
/**
|
||||||
|
* Contains medical history of every flow (a patient) that has entered the hospital. A flow can leave the hospital,
|
||||||
|
* but their medical history will be retained.
|
||||||
|
*
|
||||||
|
* Flows should be removed from [flowPatients] when they have completed successfully. Upon successful completion,
|
||||||
|
* the medical history of a flow is no longer relevant as that flow has been completely removed from the
|
||||||
|
* statemachine.
|
||||||
|
*/
|
||||||
val flowPatients = HashMap<StateMachineRunId, FlowMedicalHistory>()
|
val flowPatients = HashMap<StateMachineRunId, FlowMedicalHistory>()
|
||||||
val treatableSessionInits = HashMap<UUID, InternalSessionInitRecord>()
|
val treatableSessionInits = HashMap<UUID, InternalSessionInitRecord>()
|
||||||
val recordsPublisher = PublishSubject.create<MedicalRecord>()
|
val recordsPublisher = PublishSubject.create<MedicalRecord>()
|
||||||
})
|
})
|
||||||
private val secureRandom = newSecureRandom()
|
private val secureRandom = newSecureRandom()
|
||||||
|
|
||||||
private val delayedDischargeTimer = Timer("FlowHospitalDelayedDischargeTimer", true)
|
|
||||||
/**
|
/**
|
||||||
* The node was unable to initiate the [InitialSessionMessage] from [sender].
|
* The node was unable to initiate the [InitialSessionMessage] from [sender].
|
||||||
*/
|
*/
|
||||||
fun sessionInitErrored(sessionMessage: InitialSessionMessage, sender: Party, event: ExternalEvent.ExternalMessageEvent, error: Throwable) {
|
fun sessionInitErrored(sessionMessage: InitialSessionMessage, sender: Party, event: ExternalEvent.ExternalMessageEvent, error: Throwable) {
|
||||||
val time = Instant.now()
|
val time = clock.instant()
|
||||||
val id = UUID.randomUUID()
|
val id = UUID.randomUUID()
|
||||||
val outcome = if (error is SessionRejectException.UnknownClass) {
|
val outcome = if (error is SessionRejectException.UnknownClass) {
|
||||||
// We probably don't have the CorDapp installed so let's pause the message in the hopes that the CorDapp is
|
// We probably don't have the CorDapp installed so let's pause the message in the hopes that the CorDapp is
|
||||||
@ -104,11 +159,48 @@ class StaffedFlowHospital(private val flowMessaging: FlowMessaging, private val
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The flow running in [flowFiber] has errored.
|
* Forces the flow to be kept in for overnight observation by the hospital. A flow must already exist inside the hospital
|
||||||
|
* and have existing medical records for it to be moved to overnight observation. If it does not meet these criteria then
|
||||||
|
* an [IllegalArgumentException] will be thrown.
|
||||||
|
*
|
||||||
|
* @param id The [StateMachineRunId] of the flow that you are trying to force into observation
|
||||||
|
* @param errors The errors to include in the new medical record
|
||||||
*/
|
*/
|
||||||
fun flowErrored(flowFiber: FlowFiber, currentState: StateMachineState, errors: List<Throwable>) {
|
fun forceIntoOvernightObservation(id: StateMachineRunId, errors: List<Throwable>) {
|
||||||
val time = Instant.now()
|
mutex.locked {
|
||||||
|
// If a flow does not meet the criteria below, then it has moved into an invalid state or the function is being
|
||||||
|
// called from an incorrect location. The assertions below should error out the flow if they are not true.
|
||||||
|
requireNotNull(flowsInHospital[id]) { "Flow must already be in the hospital before forcing into overnight observation" }
|
||||||
|
val history = requireNotNull(flowPatients[id]) { "Flow must already have history before forcing into overnight observation" }
|
||||||
|
// Use the last staff member that last discharged the flow as the current staff member
|
||||||
|
val record = history.records.last().copy(
|
||||||
|
time = clock.instant(),
|
||||||
|
errors = errors,
|
||||||
|
outcome = Outcome.OVERNIGHT_OBSERVATION
|
||||||
|
)
|
||||||
|
onFlowKeptForOvernightObservation.forEach { hook -> hook.invoke(id, record.by.map { it.toString() }) }
|
||||||
|
history.records += record
|
||||||
|
recordsPublisher.onNext(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request treatment for the [flowFiber]. A flow can only be added to the hospital if they are not already being
|
||||||
|
* treated.
|
||||||
|
*/
|
||||||
|
fun requestTreatment(flowFiber: FlowFiber, currentState: StateMachineState, errors: List<Throwable>) {
|
||||||
|
// Only treat flows that are not already in the hospital
|
||||||
|
if (!currentState.isRemoved && flowsInHospital.putIfAbsent(flowFiber.id, flowFiber) == null) {
|
||||||
|
admit(flowFiber, currentState, errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ComplexMethod")
|
||||||
|
private fun admit(flowFiber: FlowFiber, currentState: StateMachineState, errors: List<Throwable>) {
|
||||||
|
val time = clock.instant()
|
||||||
log.info("Flow ${flowFiber.id} admitted to hospital in state $currentState")
|
log.info("Flow ${flowFiber.id} admitted to hospital in state $currentState")
|
||||||
|
onFlowAdmitted.forEach { it.invoke(flowFiber.id) }
|
||||||
|
|
||||||
val (event, backOffForChronicCondition) = mutex.locked {
|
val (event, backOffForChronicCondition) = mutex.locked {
|
||||||
val medicalHistory = flowPatients.computeIfAbsent(flowFiber.id) { FlowMedicalHistory() }
|
val medicalHistory = flowPatients.computeIfAbsent(flowFiber.id) { FlowMedicalHistory() }
|
||||||
@ -119,15 +211,17 @@ class StaffedFlowHospital(private val flowMessaging: FlowMessaging, private val
|
|||||||
Diagnosis.DISCHARGE -> {
|
Diagnosis.DISCHARGE -> {
|
||||||
val backOff = calculateBackOffForChronicCondition(report, medicalHistory, currentState)
|
val backOff = calculateBackOffForChronicCondition(report, medicalHistory, currentState)
|
||||||
log.info("Flow error discharged from hospital (delay ${backOff.seconds}s) by ${report.by} (error was ${report.error.message})")
|
log.info("Flow error discharged from hospital (delay ${backOff.seconds}s) by ${report.by} (error was ${report.error.message})")
|
||||||
|
onFlowDischarged.forEach { hook -> hook.invoke(flowFiber.id, report.by.map{it.toString()}) }
|
||||||
Triple(Outcome.DISCHARGE, Event.RetryFlowFromSafePoint, backOff)
|
Triple(Outcome.DISCHARGE, Event.RetryFlowFromSafePoint, backOff)
|
||||||
}
|
}
|
||||||
Diagnosis.OVERNIGHT_OBSERVATION -> {
|
Diagnosis.OVERNIGHT_OBSERVATION -> {
|
||||||
log.info("Flow error kept for overnight observation by ${report.by} (error was ${report.error.message})")
|
log.info("Flow error kept for overnight observation by ${report.by} (error was ${report.error.message})")
|
||||||
// We don't schedule a next event for the flow - it will automatically retry from its checkpoint on node restart
|
// We don't schedule a next event for the flow - it will automatically retry from its checkpoint on node restart
|
||||||
|
onFlowKeptForOvernightObservation.forEach { hook -> hook.invoke(flowFiber.id, report.by.map{it.toString()}) }
|
||||||
Triple(Outcome.OVERNIGHT_OBSERVATION, null, 0.seconds)
|
Triple(Outcome.OVERNIGHT_OBSERVATION, null, 0.seconds)
|
||||||
}
|
}
|
||||||
Diagnosis.NOT_MY_SPECIALTY -> {
|
Diagnosis.NOT_MY_SPECIALTY, Diagnosis.TERMINAL -> {
|
||||||
// None of the staff care for these errors so we let them propagate
|
// None of the staff care for these errors, or someone decided it is a terminal condition, so we let them propagate
|
||||||
log.info("Flow error allowed to propagate", report.error)
|
log.info("Flow error allowed to propagate", report.error)
|
||||||
Triple(Outcome.UNTREATABLE, Event.StartErrorPropagation, 0.seconds)
|
Triple(Outcome.UNTREATABLE, Event.StartErrorPropagation, 0.seconds)
|
||||||
}
|
}
|
||||||
@ -143,10 +237,8 @@ class StaffedFlowHospital(private val flowMessaging: FlowMessaging, private val
|
|||||||
if (backOffForChronicCondition.isZero) {
|
if (backOffForChronicCondition.isZero) {
|
||||||
flowFiber.scheduleEvent(event)
|
flowFiber.scheduleEvent(event)
|
||||||
} else {
|
} else {
|
||||||
delayedDischargeTimer.schedule(object : TimerTask() {
|
hospitalJobTimer.schedule(timerTask {
|
||||||
override fun run() {
|
|
||||||
flowFiber.scheduleEvent(event)
|
flowFiber.scheduleEvent(event)
|
||||||
}
|
|
||||||
}, backOffForChronicCondition.toMillis())
|
}, backOffForChronicCondition.toMillis())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -185,12 +277,19 @@ class StaffedFlowHospital(private val flowMessaging: FlowMessaging, private val
|
|||||||
private data class ConsultationReport(val error: Throwable, val diagnosis: Diagnosis, val by: List<Staff>)
|
private data class ConsultationReport(val error: Throwable, val diagnosis: Diagnosis, val by: List<Staff>)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The flow has been removed from the state machine.
|
* Remove the flow's medical history from the hospital.
|
||||||
*/
|
*/
|
||||||
fun flowRemoved(flowId: StateMachineRunId) {
|
fun removeMedicalHistory(flowId: StateMachineRunId) {
|
||||||
mutex.locked { flowPatients.remove(flowId) }
|
mutex.locked { flowPatients.remove(flowId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the flow from the hospital as it is not currently being treated.
|
||||||
|
*/
|
||||||
|
fun leave(id: StateMachineRunId) {
|
||||||
|
flowsInHospital.remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO MedicalRecord subtypes can expose the Staff class, something which we probably don't want when wiring this method to RPC
|
// TODO MedicalRecord subtypes can expose the Staff class, something which we probably don't want when wiring this method to RPC
|
||||||
/** Returns a stream of medical records as flows pass through the hospital. */
|
/** Returns a stream of medical records as flows pass through the hospital. */
|
||||||
fun track(): DataFeed<List<MedicalRecord>, MedicalRecord> {
|
fun track(): DataFeed<List<MedicalRecord>, MedicalRecord> {
|
||||||
@ -251,6 +350,8 @@ class StaffedFlowHospital(private val flowMessaging: FlowMessaging, private val
|
|||||||
|
|
||||||
/** The order of the enum values are in priority order. */
|
/** The order of the enum values are in priority order. */
|
||||||
enum class Diagnosis {
|
enum class Diagnosis {
|
||||||
|
/** The flow should not see other staff members */
|
||||||
|
TERMINAL,
|
||||||
/** Retry from last safe point. */
|
/** Retry from last safe point. */
|
||||||
DISCHARGE,
|
DISCHARGE,
|
||||||
/** Park and await intervention. */
|
/** Park and await intervention. */
|
||||||
@ -259,7 +360,6 @@ class StaffedFlowHospital(private val flowMessaging: FlowMessaging, private val
|
|||||||
NOT_MY_SPECIALTY
|
NOT_MY_SPECIALTY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface Staff {
|
interface Staff {
|
||||||
fun consult(flowFiber: FlowFiber, currentState: StateMachineState, newError: Throwable, history: FlowMedicalHistory): Diagnosis
|
fun consult(flowFiber: FlowFiber, currentState: StateMachineState, newError: Throwable, history: FlowMedicalHistory): Diagnosis
|
||||||
}
|
}
|
||||||
@ -288,7 +388,8 @@ class StaffedFlowHospital(private val flowMessaging: FlowMessaging, private val
|
|||||||
*/
|
*/
|
||||||
object DuplicateInsertSpecialist : Staff {
|
object DuplicateInsertSpecialist : Staff {
|
||||||
override fun consult(flowFiber: FlowFiber, currentState: StateMachineState, newError: Throwable, history: FlowMedicalHistory): Diagnosis {
|
override fun consult(flowFiber: FlowFiber, currentState: StateMachineState, newError: Throwable, history: FlowMedicalHistory): Diagnosis {
|
||||||
return if (newError.mentionsThrowable(ConstraintViolationException::class.java) && history.notDischargedForTheSameThingMoreThan(3, this, currentState)) {
|
return if (newError.mentionsThrowable(ConstraintViolationException::class.java)
|
||||||
|
&& history.notDischargedForTheSameThingMoreThan(2, this, currentState)) {
|
||||||
Diagnosis.DISCHARGE
|
Diagnosis.DISCHARGE
|
||||||
} else {
|
} else {
|
||||||
Diagnosis.NOT_MY_SPECIALTY
|
Diagnosis.NOT_MY_SPECIALTY
|
||||||
@ -334,7 +435,7 @@ class StaffedFlowHospital(private val flowMessaging: FlowMessaging, private val
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun isErrorPropagatedFromCounterparty(error: Throwable): Boolean {
|
private fun isErrorPropagatedFromCounterparty(error: Throwable): Boolean {
|
||||||
return when(error) {
|
return when (error) {
|
||||||
is UnexpectedFlowEndException -> {
|
is UnexpectedFlowEndException -> {
|
||||||
val peer = DeclaredField<Party?>(UnexpectedFlowEndException::class.java, "peer", error).value
|
val peer = DeclaredField<Party?>(UnexpectedFlowEndException::class.java, "peer", error).value
|
||||||
peer != null
|
peer != null
|
||||||
@ -358,17 +459,21 @@ class StaffedFlowHospital(private val flowMessaging: FlowMessaging, private val
|
|||||||
val strippedStacktrace = error.stackTrace
|
val strippedStacktrace = error.stackTrace
|
||||||
.filterNot { it?.className?.contains("counter-flow exception from peer") ?: false }
|
.filterNot { it?.className?.contains("counter-flow exception from peer") ?: false }
|
||||||
.filterNot { it?.className?.startsWith("net.corda.node.services.statemachine.") ?: false }
|
.filterNot { it?.className?.startsWith("net.corda.node.services.statemachine.") ?: false }
|
||||||
return strippedStacktrace.isNotEmpty() &&
|
return strippedStacktrace.isNotEmpty()
|
||||||
strippedStacktrace.first().className.startsWith(ReceiveTransactionFlow::class.qualifiedName!! )
|
&& strippedStacktrace.first().className.startsWith(ReceiveTransactionFlow::class.qualifiedName!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [SQLTransientConnectionException] detection that arise from failing to connect the underlying database/datasource
|
* [SQLTransientConnectionException] detection that arise from failing to connect the underlying database/datasource
|
||||||
*/
|
*/
|
||||||
object TransientConnectionCardiologist : Staff {
|
object TransientConnectionCardiologist : Staff {
|
||||||
override fun consult(flowFiber: FlowFiber, currentState: StateMachineState, newError: Throwable, history: FlowMedicalHistory): Diagnosis {
|
override fun consult(
|
||||||
|
flowFiber: FlowFiber,
|
||||||
|
currentState: StateMachineState,
|
||||||
|
newError: Throwable,
|
||||||
|
history: FlowMedicalHistory
|
||||||
|
): Diagnosis {
|
||||||
return if (mentionsTransientConnection(newError)) {
|
return if (mentionsTransientConnection(newError)) {
|
||||||
if (history.notDischargedForTheSameThingMoreThan(2, this, currentState)) {
|
if (history.notDischargedForTheSameThingMoreThan(2, this, currentState)) {
|
||||||
Diagnosis.DISCHARGE
|
Diagnosis.DISCHARGE
|
||||||
@ -384,6 +489,72 @@ class StaffedFlowHospital(private val flowMessaging: FlowMessaging, private val
|
|||||||
return exception.mentionsThrowable(SQLTransientConnectionException::class.java, "connection is not available")
|
return exception.mentionsThrowable(SQLTransientConnectionException::class.java, "connection is not available")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hospitalise any database (SQL and Persistence) exception that wasn't handled otherwise, unless on the configurable whitelist
|
||||||
|
* Note that retry decisions from other specialists will not be affected as retries take precedence over hospitalisation.
|
||||||
|
*/
|
||||||
|
object DatabaseEndocrinologist : Staff {
|
||||||
|
override fun consult(
|
||||||
|
flowFiber: FlowFiber,
|
||||||
|
currentState: StateMachineState,
|
||||||
|
newError: Throwable,
|
||||||
|
history: FlowMedicalHistory
|
||||||
|
): Diagnosis {
|
||||||
|
return if ((newError is SQLException || newError is PersistenceException) && !customConditions.any { it(newError) }) {
|
||||||
|
Diagnosis.OVERNIGHT_OBSERVATION
|
||||||
|
} else {
|
||||||
|
Diagnosis.NOT_MY_SPECIALTY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
val customConditions = mutableSetOf<(t: Throwable) -> Boolean>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles exceptions from internal state transitions that are not dealt with by the rest of the staff.
|
||||||
|
*
|
||||||
|
* [InterruptedException]s are diagnosed as [Diagnosis.TERMINAL] so they are never retried
|
||||||
|
* (can occur when a flow is killed - `killFlow`).
|
||||||
|
* [AsyncOperationTransitionException]s ares ignored as the error is likely to have originated in user async code rather than inside
|
||||||
|
* of a transition.
|
||||||
|
* All other exceptions are retried a maximum of 3 times before being kept in for observation.
|
||||||
|
*/
|
||||||
|
object TransitionErrorGeneralPractitioner : Staff {
|
||||||
|
override fun consult(
|
||||||
|
flowFiber: FlowFiber,
|
||||||
|
currentState: StateMachineState,
|
||||||
|
newError: Throwable,
|
||||||
|
history: FlowMedicalHistory
|
||||||
|
): Diagnosis {
|
||||||
|
return if (newError.mentionsThrowable(StateTransitionException::class.java)) {
|
||||||
|
when {
|
||||||
|
newError.mentionsThrowable(InterruptedException::class.java) -> Diagnosis.TERMINAL
|
||||||
|
newError.mentionsThrowable(AsyncOperationTransitionException::class.java) -> Diagnosis.NOT_MY_SPECIALTY
|
||||||
|
history.notDischargedForTheSameThingMoreThan(2, this, currentState) -> Diagnosis.DISCHARGE
|
||||||
|
else -> Diagnosis.OVERNIGHT_OBSERVATION
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Diagnosis.NOT_MY_SPECIALTY
|
||||||
|
}.also { logDiagnosis(it, newError, flowFiber, history) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logDiagnosis(diagnosis: Diagnosis, newError: Throwable, flowFiber: FlowFiber, history: FlowMedicalHistory) {
|
||||||
|
if (diagnosis != Diagnosis.NOT_MY_SPECIALTY) {
|
||||||
|
log.debug {
|
||||||
|
"""
|
||||||
|
Flow ${flowFiber.id} given $diagnosis diagnosis due to a transition error
|
||||||
|
- Exception: ${newError.message}
|
||||||
|
- History: $history
|
||||||
|
${(newError as? StateTransitionException)?.transitionAction?.let { "- Action: $it" }}
|
||||||
|
${(newError as? StateTransitionException)?.transitionEvent?.let { "- Event: $it" }}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T : Throwable> Throwable?.mentionsThrowable(exceptionType: Class<T>, errorMessage: String? = null): Boolean {
|
private fun <T : Throwable> Throwable?.mentionsThrowable(exceptionType: Class<T>, errorMessage: String? = null): Boolean {
|
||||||
@ -397,3 +568,4 @@ private fun <T : Throwable> Throwable?.mentionsThrowable(exceptionType: Class<T>
|
|||||||
}
|
}
|
||||||
return (exceptionType.isAssignableFrom(this::class.java) && containsMessage) || cause.mentionsThrowable(exceptionType, errorMessage)
|
return (exceptionType.isAssignableFrom(this::class.java) && containsMessage) || cause.mentionsThrowable(exceptionType, errorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,6 +114,7 @@ interface ExternalEvent {
|
|||||||
* An external P2P message event.
|
* An external P2P message event.
|
||||||
*/
|
*/
|
||||||
interface ExternalMessageEvent : ExternalEvent {
|
interface ExternalMessageEvent : ExternalEvent {
|
||||||
|
val flowId: StateMachineRunId
|
||||||
val receivedMessage: ReceivedMessage
|
val receivedMessage: ReceivedMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,6 +122,7 @@ interface ExternalEvent {
|
|||||||
* An external request to start a flow, from the scheduler for example.
|
* An external request to start a flow, from the scheduler for example.
|
||||||
*/
|
*/
|
||||||
interface ExternalStartFlowEvent<T> : ExternalEvent {
|
interface ExternalStartFlowEvent<T> : ExternalEvent {
|
||||||
|
val flowId: StateMachineRunId
|
||||||
val flowLogic: FlowLogic<T>
|
val flowLogic: FlowLogic<T>
|
||||||
val context: InvocationContext
|
val context: InvocationContext
|
||||||
|
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
package net.corda.node.services.statemachine
|
||||||
|
|
||||||
|
import net.corda.core.CordaException
|
||||||
|
import net.corda.core.serialization.ConstructorForDeserialization
|
||||||
|
|
||||||
|
// CORDA-3353 - These exceptions should not be propagated up to rpc as they suppress the real exceptions
|
||||||
|
|
||||||
|
class StateTransitionException(
|
||||||
|
val transitionAction: Action?,
|
||||||
|
val transitionEvent: Event?,
|
||||||
|
val exception: Exception
|
||||||
|
) : CordaException(exception.message, exception) {
|
||||||
|
|
||||||
|
@ConstructorForDeserialization
|
||||||
|
constructor(exception: Exception): this(null, null, exception)
|
||||||
|
}
|
||||||
|
|
||||||
|
class AsyncOperationTransitionException(exception: Exception) : CordaException(exception.message, exception)
|
@ -9,6 +9,7 @@ import net.corda.nodeapi.internal.persistence.CordaPersistence
|
|||||||
import net.corda.nodeapi.internal.persistence.contextDatabase
|
import net.corda.nodeapi.internal.persistence.contextDatabase
|
||||||
import net.corda.nodeapi.internal.persistence.contextTransactionOrNull
|
import net.corda.nodeapi.internal.persistence.contextTransactionOrNull
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
import javax.persistence.OptimisticLockException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This [TransitionExecutor] runs the transition actions using the passed in [ActionExecutor] and manually dirties the
|
* This [TransitionExecutor] runs the transition actions using the passed in [ActionExecutor] and manually dirties the
|
||||||
@ -27,6 +28,7 @@ class TransitionExecutorImpl(
|
|||||||
val log = contextLogger()
|
val log = contextLogger()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("NestedBlockDepth", "ReturnCount")
|
||||||
@Suspendable
|
@Suspendable
|
||||||
override fun executeTransition(
|
override fun executeTransition(
|
||||||
fiber: FlowFiber,
|
fiber: FlowFiber,
|
||||||
@ -47,15 +49,24 @@ class TransitionExecutorImpl(
|
|||||||
// Instead we just keep around the old error state and wait for a new schedule, perhaps
|
// Instead we just keep around the old error state and wait for a new schedule, perhaps
|
||||||
// triggered from a flow hospital
|
// triggered from a flow hospital
|
||||||
log.warn("Error while executing $action during transition to errored state, aborting transition", exception)
|
log.warn("Error while executing $action during transition to errored state, aborting transition", exception)
|
||||||
|
// CORDA-3354 - Go to the hospital with the new error that has occurred
|
||||||
|
// while already in a error state (as this error could be for a different reason)
|
||||||
return Pair(FlowContinuation.Abort, previousState.copy(isFlowResumed = false))
|
return Pair(FlowContinuation.Abort, previousState.copy(isFlowResumed = false))
|
||||||
} else {
|
} else {
|
||||||
// Otherwise error the state manually keeping the old flow state and schedule a DoRemainingWork
|
// Otherwise error the state manually keeping the old flow state and schedule a DoRemainingWork
|
||||||
// to trigger error propagation
|
// to trigger error propagation
|
||||||
log.info("Error while executing $action, erroring state", exception)
|
log.info("Error while executing $action, with event $event, erroring state", exception)
|
||||||
|
if(previousState.isRemoved && exception is OptimisticLockException) {
|
||||||
|
log.debug("Flow has been killed and the following error is likely due to the flow's checkpoint being deleted. " +
|
||||||
|
"Occurred while executing $action, with event $event", exception)
|
||||||
|
} else {
|
||||||
|
log.info("Error while executing $action, with event $event, erroring state", exception)
|
||||||
|
}
|
||||||
val newState = previousState.copy(
|
val newState = previousState.copy(
|
||||||
checkpoint = previousState.checkpoint.copy(
|
checkpoint = previousState.checkpoint.copy(
|
||||||
errorState = previousState.checkpoint.errorState.addErrors(
|
errorState = previousState.checkpoint.errorState.addErrors(
|
||||||
listOf(FlowError(secureRandom.nextLong(), exception))
|
// Wrap the exception with [StateTransitionException] for handling by the flow hospital
|
||||||
|
listOf(FlowError(secureRandom.nextLong(), StateTransitionException(action, event, exception)))
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
isFlowResumed = false
|
isFlowResumed = false
|
||||||
|
@ -19,6 +19,7 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
* This interceptor records a trace of all of the flows' states and transitions. If the flow dirties it dumps the trace
|
* This interceptor records a trace of all of the flows' states and transitions. If the flow dirties it dumps the trace
|
||||||
* transition to the logger.
|
* transition to the logger.
|
||||||
*/
|
*/
|
||||||
|
@Suppress("MaxLineLength") // detekt confusing the whole if statement for a line
|
||||||
class DumpHistoryOnErrorInterceptor(val delegate: TransitionExecutor) : TransitionExecutor {
|
class DumpHistoryOnErrorInterceptor(val delegate: TransitionExecutor) : TransitionExecutor {
|
||||||
companion object {
|
companion object {
|
||||||
private val log = contextLogger()
|
private val log = contextLogger()
|
||||||
@ -34,8 +35,12 @@ class DumpHistoryOnErrorInterceptor(val delegate: TransitionExecutor) : Transiti
|
|||||||
transition: TransitionResult,
|
transition: TransitionResult,
|
||||||
actionExecutor: ActionExecutor
|
actionExecutor: ActionExecutor
|
||||||
): Pair<FlowContinuation, StateMachineState> {
|
): Pair<FlowContinuation, StateMachineState> {
|
||||||
val (continuation, nextState) = delegate.executeTransition(fiber, previousState, event, transition, actionExecutor)
|
val (continuation, nextState)
|
||||||
val transitionRecord = TransitionDiagnosticRecord(Instant.now(), fiber.id, previousState, nextState, event, transition, continuation)
|
= delegate.executeTransition(fiber, previousState, event, transition, actionExecutor)
|
||||||
|
|
||||||
|
if (!previousState.isRemoved) {
|
||||||
|
val transitionRecord =
|
||||||
|
TransitionDiagnosticRecord(Instant.now(), fiber.id, previousState, nextState, event, transition, continuation)
|
||||||
val record = records.compute(fiber.id) { _, record ->
|
val record = records.compute(fiber.id) { _, record ->
|
||||||
(record ?: ArrayList()).apply { add(transitionRecord) }
|
(record ?: ArrayList()).apply { add(transitionRecord) }
|
||||||
}
|
}
|
||||||
@ -48,6 +53,7 @@ class DumpHistoryOnErrorInterceptor(val delegate: TransitionExecutor) : Transiti
|
|||||||
log.warn("Flow ${fiber.id} error", error.exception)
|
log.warn("Flow ${fiber.id} error", error.exception)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (nextState.isRemoved) {
|
if (nextState.isRemoved) {
|
||||||
records.remove(fiber.id)
|
records.remove(fiber.id)
|
||||||
|
@ -11,7 +11,6 @@ import net.corda.node.services.statemachine.StateMachineState
|
|||||||
import net.corda.node.services.statemachine.TransitionExecutor
|
import net.corda.node.services.statemachine.TransitionExecutor
|
||||||
import net.corda.node.services.statemachine.transitions.FlowContinuation
|
import net.corda.node.services.statemachine.transitions.FlowContinuation
|
||||||
import net.corda.node.services.statemachine.transitions.TransitionResult
|
import net.corda.node.services.statemachine.transitions.TransitionResult
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This interceptor notifies the passed in [flowHospital] in case a flow went through a clean->errored or a errored->clean
|
* This interceptor notifies the passed in [flowHospital] in case a flow went through a clean->errored or a errored->clean
|
||||||
@ -27,12 +26,10 @@ class HospitalisingInterceptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun removeFlow(id: StateMachineRunId) {
|
private fun removeFlow(id: StateMachineRunId) {
|
||||||
hospitalisedFlows.remove(id)
|
flowHospital.leave(id)
|
||||||
flowHospital.flowRemoved(id)
|
flowHospital.removeMedicalHistory(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val hospitalisedFlows = ConcurrentHashMap<StateMachineRunId, FlowFiber>()
|
|
||||||
|
|
||||||
@Suspendable
|
@Suspendable
|
||||||
override fun executeTransition(
|
override fun executeTransition(
|
||||||
fiber: FlowFiber,
|
fiber: FlowFiber,
|
||||||
@ -41,18 +38,18 @@ class HospitalisingInterceptor(
|
|||||||
transition: TransitionResult,
|
transition: TransitionResult,
|
||||||
actionExecutor: ActionExecutor
|
actionExecutor: ActionExecutor
|
||||||
): Pair<FlowContinuation, StateMachineState> {
|
): Pair<FlowContinuation, StateMachineState> {
|
||||||
|
|
||||||
|
// If the fiber's previous state was clean then remove it from the hospital
|
||||||
|
// This is important for retrying a flow that has errored during a state machine transition
|
||||||
|
if (previousState.checkpoint.errorState is ErrorState.Clean) {
|
||||||
|
flowHospital.leave(fiber.id)
|
||||||
|
}
|
||||||
|
|
||||||
val (continuation, nextState) = delegate.executeTransition(fiber, previousState, event, transition, actionExecutor)
|
val (continuation, nextState) = delegate.executeTransition(fiber, previousState, event, transition, actionExecutor)
|
||||||
|
|
||||||
when (nextState.checkpoint.errorState) {
|
if (nextState.checkpoint.errorState is ErrorState.Errored && previousState.checkpoint.errorState is ErrorState.Clean) {
|
||||||
is ErrorState.Clean -> {
|
|
||||||
hospitalisedFlows.remove(fiber.id)
|
|
||||||
}
|
|
||||||
is ErrorState.Errored -> {
|
|
||||||
val exceptionsToHandle = nextState.checkpoint.errorState.errors.map { it.exception }
|
val exceptionsToHandle = nextState.checkpoint.errorState.errors.map { it.exception }
|
||||||
if (hospitalisedFlows.putIfAbsent(fiber.id, fiber) == null) {
|
flowHospital.requestTreatment(fiber, previousState, exceptionsToHandle)
|
||||||
flowHospital.flowErrored(fiber, previousState, exceptionsToHandle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (nextState.isRemoved) {
|
if (nextState.isRemoved) {
|
||||||
removeFlow(fiber.id)
|
removeFlow(fiber.id)
|
||||||
|
@ -72,6 +72,7 @@ class PersistentUniquenessProvider(val clock: Clock, val database: CordaPersiste
|
|||||||
var requestDate: Instant
|
var requestDate: Instant
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // database column length
|
||||||
@Entity
|
@Entity
|
||||||
@javax.persistence.Table(name = "${NODE_DATABASE_PREFIX}notary_committed_txs")
|
@javax.persistence.Table(name = "${NODE_DATABASE_PREFIX}notary_committed_txs")
|
||||||
class CommittedTransaction(
|
class CommittedTransaction(
|
||||||
|
@ -12,6 +12,7 @@ import javax.persistence.Entity
|
|||||||
import javax.persistence.Id
|
import javax.persistence.Id
|
||||||
import javax.persistence.Table
|
import javax.persistence.Table
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // database column length
|
||||||
class ContractUpgradeServiceImpl(cacheFactory: NamedCacheFactory) : ContractUpgradeService, SingletonSerializeAsToken() {
|
class ContractUpgradeServiceImpl(cacheFactory: NamedCacheFactory) : ContractUpgradeService, SingletonSerializeAsToken() {
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
|
@ -23,6 +23,7 @@ import net.corda.node.services.statemachine.FlowStateMachineImpl
|
|||||||
import net.corda.nodeapi.internal.persistence.*
|
import net.corda.nodeapi.internal.persistence.*
|
||||||
import org.hibernate.Session
|
import org.hibernate.Session
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
import rx.exceptions.OnErrorNotImplementedException
|
||||||
import rx.subjects.PublishSubject
|
import rx.subjects.PublishSubject
|
||||||
import java.security.PublicKey
|
import java.security.PublicKey
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
@ -390,7 +391,15 @@ class NodeVaultService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
persistentStateService.persist(vaultUpdate.produced + vaultUpdate.references)
|
persistentStateService.persist(vaultUpdate.produced + vaultUpdate.references)
|
||||||
|
try {
|
||||||
updatesPublisher.onNext(vaultUpdate)
|
updatesPublisher.onNext(vaultUpdate)
|
||||||
|
} catch (e: OnErrorNotImplementedException) {
|
||||||
|
log.warn("Caught an Rx.OnErrorNotImplementedException " +
|
||||||
|
"- caused by an exception in an RX observer that was unhandled " +
|
||||||
|
"- the observer has been unsubscribed! The underlying exception will be rethrown.", e)
|
||||||
|
// if the observer code threw, unwrap their exception from the RX wrapper
|
||||||
|
throw e.cause ?: e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,6 +26,7 @@ object VaultSchema
|
|||||||
/**
|
/**
|
||||||
* First version of the Vault ORM schema
|
* First version of the Vault ORM schema
|
||||||
*/
|
*/
|
||||||
|
@Suppress("MagicNumber") // database column length
|
||||||
@CordaSerializable
|
@CordaSerializable
|
||||||
object VaultSchemaV1 : MappedSchema(
|
object VaultSchemaV1 : MappedSchema(
|
||||||
schemaFamily = VaultSchema.javaClass,
|
schemaFamily = VaultSchema.javaClass,
|
||||||
|
@ -127,6 +127,7 @@ class BFTSmartNotaryService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // database column length
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "${NODE_DATABASE_PREFIX}bft_committed_txs")
|
@Table(name = "${NODE_DATABASE_PREFIX}bft_committed_txs")
|
||||||
class CommittedTransaction(
|
class CommittedTransaction(
|
||||||
|
@ -104,6 +104,7 @@ class RaftUniquenessProvider(
|
|||||||
var index: Long = 0
|
var index: Long = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // database column length
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "${NODE_DATABASE_PREFIX}raft_committed_txs")
|
@Table(name = "${NODE_DATABASE_PREFIX}raft_committed_txs")
|
||||||
class CommittedTransaction(
|
class CommittedTransaction(
|
||||||
|
@ -37,7 +37,8 @@ import net.corda.testing.internal.LogHelper
|
|||||||
import net.corda.testing.node.InMemoryMessagingNetwork.MessageTransfer
|
import net.corda.testing.node.InMemoryMessagingNetwork.MessageTransfer
|
||||||
import net.corda.testing.node.InMemoryMessagingNetwork.ServicePeerAllocationStrategy.RoundRobin
|
import net.corda.testing.node.InMemoryMessagingNetwork.ServicePeerAllocationStrategy.RoundRobin
|
||||||
import net.corda.testing.node.internal.*
|
import net.corda.testing.node.internal.*
|
||||||
import org.assertj.core.api.Assertions.*
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.assertj.core.api.Assertions.assertThatIllegalArgumentException
|
||||||
import org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType
|
import org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType
|
||||||
import org.assertj.core.api.Condition
|
import org.assertj.core.api.Condition
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
@ -115,18 +116,16 @@ class FlowFrameworkTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `exception while fiber suspended`() {
|
fun `exception while fiber suspended is retried and completes successfully`() {
|
||||||
bobNode.registerCordappFlowFactory(ReceiveFlow::class) { InitiatedSendFlow("Hello", it) }
|
bobNode.registerCordappFlowFactory(ReceiveFlow::class) { InitiatedSendFlow("Hello", it) }
|
||||||
val flow = ReceiveFlow(bob)
|
val flow = ReceiveFlow(bob)
|
||||||
val fiber = aliceNode.services.startFlow(flow) as FlowStateMachineImpl
|
val fiber = aliceNode.services.startFlow(flow) as FlowStateMachineImpl
|
||||||
// Before the flow runs change the suspend action to throw an exception
|
// Before the flow runs change the suspend action to throw an exception
|
||||||
val exceptionDuringSuspend = Exception("Thrown during suspend")
|
val throwingActionExecutor = SuspendThrowingActionExecutor(Exception("Thrown during suspend"),
|
||||||
val throwingActionExecutor = SuspendThrowingActionExecutor(exceptionDuringSuspend, fiber.transientValues!!.value.actionExecutor)
|
fiber.transientValues!!.value.actionExecutor)
|
||||||
fiber.transientValues = TransientReference(fiber.transientValues!!.value.copy(actionExecutor = throwingActionExecutor))
|
fiber.transientValues = TransientReference(fiber.transientValues!!.value.copy(actionExecutor = throwingActionExecutor))
|
||||||
mockNet.runNetwork()
|
mockNet.runNetwork()
|
||||||
assertThatThrownBy {
|
|
||||||
fiber.resultFuture.getOrThrow()
|
fiber.resultFuture.getOrThrow()
|
||||||
}.isSameAs(exceptionDuringSuspend)
|
|
||||||
assertThat(aliceNode.smm.allStateMachines).isEmpty()
|
assertThat(aliceNode.smm.allStateMachines).isEmpty()
|
||||||
// Make sure the fiber does actually terminate
|
// Make sure the fiber does actually terminate
|
||||||
assertThat(fiber.state).isEqualTo(Strand.State.WAITING)
|
assertThat(fiber.state).isEqualTo(Strand.State.WAITING)
|
||||||
|
@ -2,14 +2,18 @@ package net.corda.node.services.statemachine
|
|||||||
|
|
||||||
import co.paralleluniverse.fibers.Suspendable
|
import co.paralleluniverse.fibers.Suspendable
|
||||||
import net.corda.core.concurrent.CordaFuture
|
import net.corda.core.concurrent.CordaFuture
|
||||||
import net.corda.core.flows.*
|
import net.corda.core.flows.Destination
|
||||||
|
import net.corda.core.flows.FlowInfo
|
||||||
|
import net.corda.core.flows.FlowLogic
|
||||||
|
import net.corda.core.flows.FlowSession
|
||||||
|
import net.corda.core.flows.InitiatedBy
|
||||||
|
import net.corda.core.flows.InitiatingFlow
|
||||||
import net.corda.core.identity.CordaX500Name
|
import net.corda.core.identity.CordaX500Name
|
||||||
import net.corda.core.identity.Party
|
import net.corda.core.identity.Party
|
||||||
import net.corda.core.internal.FlowStateMachine
|
import net.corda.core.internal.FlowStateMachine
|
||||||
import net.corda.core.internal.concurrent.flatMap
|
import net.corda.core.internal.concurrent.flatMap
|
||||||
import net.corda.core.messaging.MessageRecipients
|
import net.corda.core.messaging.MessageRecipients
|
||||||
import net.corda.core.utilities.UntrustworthyData
|
import net.corda.core.utilities.UntrustworthyData
|
||||||
import net.corda.core.utilities.getOrThrow
|
|
||||||
import net.corda.core.utilities.unwrap
|
import net.corda.core.utilities.unwrap
|
||||||
import net.corda.node.services.FinalityHandler
|
import net.corda.node.services.FinalityHandler
|
||||||
import net.corda.node.services.messaging.Message
|
import net.corda.node.services.messaging.Message
|
||||||
@ -17,11 +21,13 @@ import net.corda.node.services.persistence.DBTransactionStorage
|
|||||||
import net.corda.nodeapi.internal.persistence.contextTransaction
|
import net.corda.nodeapi.internal.persistence.contextTransaction
|
||||||
import net.corda.testing.common.internal.eventually
|
import net.corda.testing.common.internal.eventually
|
||||||
import net.corda.testing.core.TestIdentity
|
import net.corda.testing.core.TestIdentity
|
||||||
import net.corda.testing.node.internal.*
|
import net.corda.testing.node.internal.InternalMockNetwork
|
||||||
|
import net.corda.testing.node.internal.MessagingServiceSpy
|
||||||
|
import net.corda.testing.node.internal.TestStartedNode
|
||||||
|
import net.corda.testing.node.internal.enclosedCordapp
|
||||||
|
import net.corda.testing.node.internal.newContext
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
|
||||||
import org.h2.util.Utils
|
import org.h2.util.Utils
|
||||||
import org.hibernate.exception.ConstraintViolationException
|
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
@ -49,6 +55,8 @@ class RetryFlowMockTest {
|
|||||||
SendAndRetryFlow.count = 0
|
SendAndRetryFlow.count = 0
|
||||||
RetryInsertFlow.count = 0
|
RetryInsertFlow.count = 0
|
||||||
KeepSendingFlow.count.set(0)
|
KeepSendingFlow.count.set(0)
|
||||||
|
StaffedFlowHospital.DatabaseEndocrinologist.customConditions.add { t -> t is LimitedRetryCausingError }
|
||||||
|
StaffedFlowHospital.DatabaseEndocrinologist.customConditions.add { t -> t is RetryCausingError }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> TestStartedNode.startFlow(logic: FlowLogic<T>): CordaFuture<T> {
|
private fun <T> TestStartedNode.startFlow(logic: FlowLogic<T>): CordaFuture<T> {
|
||||||
@ -58,6 +66,7 @@ class RetryFlowMockTest {
|
|||||||
@After
|
@After
|
||||||
fun cleanUp() {
|
fun cleanUp() {
|
||||||
mockNet.stopNodes()
|
mockNet.stopNodes()
|
||||||
|
StaffedFlowHospital.DatabaseEndocrinologist.customConditions.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -66,14 +75,6 @@ class RetryFlowMockTest {
|
|||||||
assertEquals(2, RetryFlow.count)
|
assertEquals(2, RetryFlow.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Retry forever`() {
|
|
||||||
assertThatThrownBy {
|
|
||||||
nodeA.startFlow(RetryFlow(Int.MAX_VALUE)).getOrThrow()
|
|
||||||
}.isInstanceOf(LimitedRetryCausingError::class.java)
|
|
||||||
assertEquals(5, RetryFlow.count)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Retry does not set senderUUID`() {
|
fun `Retry does not set senderUUID`() {
|
||||||
val messagesSent = Collections.synchronizedList(mutableListOf<Message>())
|
val messagesSent = Collections.synchronizedList(mutableListOf<Message>())
|
||||||
@ -184,8 +185,7 @@ class RetryFlowMockTest {
|
|||||||
assertThat(nodeA.smm.flowHospital.track().snapshot).isEmpty()
|
assertThat(nodeA.smm.flowHospital.track().snapshot).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class LimitedRetryCausingError : IllegalStateException("I am going to live forever")
|
||||||
class LimitedRetryCausingError : ConstraintViolationException("Test message", SQLException(), "Test constraint")
|
|
||||||
|
|
||||||
class RetryCausingError : SQLException("deadlock")
|
class RetryCausingError : SQLException("deadlock")
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ import net.corda.core.identity.CordaX500Name
|
|||||||
import net.corda.core.identity.Party
|
import net.corda.core.identity.Party
|
||||||
import net.corda.core.node.services.queryBy
|
import net.corda.core.node.services.queryBy
|
||||||
import net.corda.core.transactions.TransactionBuilder
|
import net.corda.core.transactions.TransactionBuilder
|
||||||
|
import net.corda.node.services.statemachine.StaffedFlowHospital
|
||||||
import net.corda.testing.core.DummyCommandData
|
import net.corda.testing.core.DummyCommandData
|
||||||
import net.corda.testing.core.singleIdentity
|
import net.corda.testing.core.singleIdentity
|
||||||
import net.corda.testing.internal.vault.DUMMY_DEAL_PROGRAM_ID
|
import net.corda.testing.internal.vault.DUMMY_DEAL_PROGRAM_ID
|
||||||
@ -16,12 +17,13 @@ import net.corda.testing.node.MockNetwork
|
|||||||
import net.corda.testing.node.MockNetworkNotarySpec
|
import net.corda.testing.node.MockNetworkNotarySpec
|
||||||
import net.corda.testing.node.MockNodeParameters
|
import net.corda.testing.node.MockNodeParameters
|
||||||
import net.corda.testing.node.StartedMockNode
|
import net.corda.testing.node.StartedMockNode
|
||||||
import org.assertj.core.api.Assertions
|
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.util.concurrent.ExecutionException
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class VaultFlowTest {
|
class VaultFlowTest {
|
||||||
private lateinit var mockNetwork: MockNetwork
|
private lateinit var mockNetwork: MockNetwork
|
||||||
@ -48,14 +50,19 @@ class VaultFlowTest {
|
|||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
mockNetwork.stopNodes()
|
mockNetwork.stopNodes()
|
||||||
|
StaffedFlowHospital.DatabaseEndocrinologist.customConditions.clear()
|
||||||
|
StaffedFlowHospital.onFlowKeptForOvernightObservation.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Unique column constraint failing causes states to not persist to vaults`() {
|
fun `Unique column constraint failing causes states to not persist to vaults`() {
|
||||||
|
StaffedFlowHospital.DatabaseEndocrinologist.customConditions.add( { t: Throwable -> t is javax.persistence.PersistenceException })
|
||||||
partyA.startFlow(Initiator(listOf(partyA.info.singleIdentity(), partyB.info.singleIdentity()))).get()
|
partyA.startFlow(Initiator(listOf(partyA.info.singleIdentity(), partyB.info.singleIdentity()))).get()
|
||||||
Assertions.assertThatExceptionOfType(ExecutionException::class.java).isThrownBy {
|
val hospitalLatch = CountDownLatch(1)
|
||||||
partyA.startFlow(Initiator(listOf(partyA.info.singleIdentity(), partyB.info.singleIdentity()))).get()
|
StaffedFlowHospital.onFlowKeptForOvernightObservation.add { _, _ -> hospitalLatch.countDown() }
|
||||||
}
|
partyA.startFlow(Initiator(listOf(partyA.info.singleIdentity(), partyB.info.singleIdentity())))
|
||||||
|
assertTrue(hospitalLatch.await(10, TimeUnit.SECONDS), "Flow not hospitalised")
|
||||||
|
|
||||||
assertEquals(1, partyA.transaction {
|
assertEquals(1, partyA.transaction {
|
||||||
partyA.services.vaultService.queryBy<UniqueDummyLinearContract.State>().states.size
|
partyA.services.vaultService.queryBy<UniqueDummyLinearContract.State>().states.size
|
||||||
})
|
})
|
||||||
|
@ -78,6 +78,8 @@ include 'samples:network-verifier:contracts'
|
|||||||
include 'samples:network-verifier:workflows'
|
include 'samples:network-verifier:workflows'
|
||||||
include 'serialization'
|
include 'serialization'
|
||||||
include 'serialization-tests'
|
include 'serialization-tests'
|
||||||
|
include 'testing:cordapps:dbfailure:dbfcontracts'
|
||||||
|
include 'testing:cordapps:dbfailure:dbfworkflows'
|
||||||
|
|
||||||
// Common libraries - start
|
// Common libraries - start
|
||||||
include 'common-validation'
|
include 'common-validation'
|
||||||
|
18
testing/cordapps/dbfailure/dbfcontracts/build.gradle
Normal file
18
testing/cordapps/dbfailure/dbfcontracts/build.gradle
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
apply plugin: 'kotlin'
|
||||||
|
//apply plugin: 'net.corda.plugins.cordapp'
|
||||||
|
//apply plugin: 'net.corda.plugins.quasar-utils'
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenLocal()
|
||||||
|
mavenCentral()
|
||||||
|
maven { url "$artifactory_contextUrl/corda-dependencies" }
|
||||||
|
maven { url "$artifactory_contextUrl/corda" }
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compile project(":core")
|
||||||
|
}
|
||||||
|
|
||||||
|
jar{
|
||||||
|
baseName "testing-dbfailure-contracts"
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
package com.r3.dbfailure.contracts
|
||||||
|
|
||||||
|
import com.r3.dbfailure.schemas.DbFailureSchemaV1
|
||||||
|
import net.corda.core.contracts.CommandData
|
||||||
|
import net.corda.core.contracts.Contract
|
||||||
|
import net.corda.core.contracts.LinearState
|
||||||
|
import net.corda.core.contracts.UniqueIdentifier
|
||||||
|
import net.corda.core.identity.AbstractParty
|
||||||
|
import net.corda.core.identity.Party
|
||||||
|
import net.corda.core.schemas.MappedSchema
|
||||||
|
import net.corda.core.schemas.PersistentState
|
||||||
|
import net.corda.core.schemas.QueryableState
|
||||||
|
import net.corda.core.transactions.LedgerTransaction
|
||||||
|
import java.lang.IllegalArgumentException
|
||||||
|
|
||||||
|
class DbFailureContract : Contract {
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
val ID = "com.r3.dbfailure.contracts.DbFailureContract"
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestState(
|
||||||
|
override val linearId: UniqueIdentifier,
|
||||||
|
val particpant: Party,
|
||||||
|
val randomValue: String?,
|
||||||
|
val errorTarget: Int = 0
|
||||||
|
) : LinearState, QueryableState {
|
||||||
|
|
||||||
|
override val participants: List<AbstractParty> = listOf(particpant)
|
||||||
|
|
||||||
|
override fun supportedSchemas(): Iterable<MappedSchema> = listOf(DbFailureSchemaV1)
|
||||||
|
|
||||||
|
override fun generateMappedObject(schema: MappedSchema): PersistentState {
|
||||||
|
return if (schema is DbFailureSchemaV1){
|
||||||
|
DbFailureSchemaV1.PersistentTestState( particpant.name.toString(), randomValue, errorTarget, linearId.id)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw IllegalArgumentException("Unsupported schema $schema")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun verify(tx: LedgerTransaction) {
|
||||||
|
// no op - don't care for now
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Commands : CommandData{
|
||||||
|
class Create: Commands
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
package com.r3.dbfailure.schemas
|
||||||
|
|
||||||
|
import net.corda.core.schemas.MappedSchema
|
||||||
|
import net.corda.core.schemas.PersistentState
|
||||||
|
import java.util.*
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.Table
|
||||||
|
|
||||||
|
object DbFailureSchema
|
||||||
|
|
||||||
|
object DbFailureSchemaV1 : MappedSchema(
|
||||||
|
schemaFamily = DbFailureSchema.javaClass,
|
||||||
|
version = 1,
|
||||||
|
mappedTypes = listOf(DbFailureSchemaV1.PersistentTestState::class.java)){
|
||||||
|
override val migrationResource = "dbfailure.changelog-master"
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table( name = "fail_test_states")
|
||||||
|
class PersistentTestState(
|
||||||
|
@Column( name = "participant")
|
||||||
|
var participantName: String,
|
||||||
|
|
||||||
|
@Column( name = "random_value", nullable = false)
|
||||||
|
var randomValue: String?,
|
||||||
|
|
||||||
|
@Column( name = "error_target")
|
||||||
|
var errorTarget: Int,
|
||||||
|
|
||||||
|
@Column( name = "linear_id")
|
||||||
|
var linearId: UUID
|
||||||
|
) : PersistentState() {
|
||||||
|
constructor() : this( "", "", 0, UUID.randomUUID())
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd" >
|
||||||
|
<changeSet author="R3.Corda" id="test dbfailure error target">
|
||||||
|
<addColumn tableName="fail_test_states">
|
||||||
|
<column name="error_target" type="INT"></column>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd" >
|
||||||
|
<changeSet author="R3.Corda" id="test dbfailure init">
|
||||||
|
<createTable tableName="fail_test_states">
|
||||||
|
<column name="output_index" type="INT">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="transaction_id" type="NVARCHAR(64)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="participant" type="NVARCHAR(255)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="random_value" type="NVARCHAR(255)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="linear_id" type="BINARY(255)"/>
|
||||||
|
</createTable>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd">
|
||||||
|
|
||||||
|
<include file="migration/dbfailure.changelog-init.xml"/>
|
||||||
|
<include file="migration/dbfailure.changelog-errortarget.xml"/>
|
||||||
|
</databaseChangeLog>
|
12
testing/cordapps/dbfailure/dbfworkflows/build.gradle
Normal file
12
testing/cordapps/dbfailure/dbfworkflows/build.gradle
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
apply plugin: 'kotlin'
|
||||||
|
//apply plugin: 'net.corda.plugins.cordapp'
|
||||||
|
//apply plugin: 'net.corda.plugins.quasar-utils'
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compile project(":core")
|
||||||
|
compile project(":testing:cordapps:dbfailure:dbfcontracts")
|
||||||
|
}
|
||||||
|
|
||||||
|
jar{
|
||||||
|
baseName "testing-dbfailure-workflows"
|
||||||
|
}
|
@ -0,0 +1,99 @@
|
|||||||
|
package com.r3.dbfailure.workflows
|
||||||
|
|
||||||
|
import co.paralleluniverse.fibers.Suspendable
|
||||||
|
import com.r3.dbfailure.contracts.DbFailureContract
|
||||||
|
import net.corda.core.contracts.Command
|
||||||
|
import net.corda.core.contracts.UniqueIdentifier
|
||||||
|
import net.corda.core.flows.FlowLogic
|
||||||
|
import net.corda.core.flows.InitiatingFlow
|
||||||
|
import net.corda.core.flows.StartableByRPC
|
||||||
|
import net.corda.core.transactions.TransactionBuilder
|
||||||
|
|
||||||
|
// There is a bit of number fiddling in this class to encode/decode the error target instructions
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
object CreateStateFlow {
|
||||||
|
|
||||||
|
// Encoding of error targets
|
||||||
|
// 1s are errors actions to be taken in the vault listener in the service
|
||||||
|
// 10s are errors caused in the flow
|
||||||
|
// 100s control exception handling in the flow
|
||||||
|
// 1000s control exception handlling in the service/vault listener
|
||||||
|
enum class ErrorTarget(val targetNumber: Int) {
|
||||||
|
NoError(0),
|
||||||
|
ServiceSqlSyntaxError(1),
|
||||||
|
ServiceNullConstraintViolation(2),
|
||||||
|
ServiceValidUpdate(3),
|
||||||
|
ServiceReadState(4),
|
||||||
|
ServiceCheckForState(5),
|
||||||
|
ServiceThrowInvalidParameter(6),
|
||||||
|
TxInvalidState(10),
|
||||||
|
FlowSwallowErrors(100),
|
||||||
|
ServiceSwallowErrors(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun errorTargetsToNum(vararg targets: ErrorTarget): Int {
|
||||||
|
return targets.map { it.targetNumber }.sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val targetMap = ErrorTarget.values().associateBy(ErrorTarget::targetNumber)
|
||||||
|
|
||||||
|
fun getServiceTarget(target: Int?): ErrorTarget {
|
||||||
|
return target?.let { targetMap.getValue(it % 10) } ?: CreateStateFlow.ErrorTarget.NoError
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getServiceExceptionHandlingTarget(target: Int?): ErrorTarget {
|
||||||
|
return target?.let { targetMap.getValue(((it / 1000) % 10) * 1000) } ?: CreateStateFlow.ErrorTarget.NoError
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTxTarget(target: Int?): ErrorTarget {
|
||||||
|
return target?.let { targetMap.getValue(((it / 10) % 10) * 10) } ?: CreateStateFlow.ErrorTarget.NoError
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFlowTarget(target: Int?): ErrorTarget {
|
||||||
|
return target?.let { targetMap.getValue(((it / 100) % 10) * 100) } ?: CreateStateFlow.ErrorTarget.NoError
|
||||||
|
}
|
||||||
|
|
||||||
|
@InitiatingFlow
|
||||||
|
@StartableByRPC
|
||||||
|
class Initiator(private val randomValue: String, private val errorTarget: Int) : FlowLogic<UniqueIdentifier>() {
|
||||||
|
|
||||||
|
@Suspendable
|
||||||
|
override fun call(): UniqueIdentifier {
|
||||||
|
logger.info("Test flow: starting")
|
||||||
|
val notary = serviceHub.networkMapCache.notaryIdentities[0]
|
||||||
|
val txTarget = getTxTarget(errorTarget)
|
||||||
|
logger.info("Test flow: The tx error target is $txTarget")
|
||||||
|
val state = DbFailureContract.TestState(
|
||||||
|
UniqueIdentifier(),
|
||||||
|
ourIdentity,
|
||||||
|
if (txTarget == CreateStateFlow.ErrorTarget.TxInvalidState) null else randomValue,
|
||||||
|
errorTarget)
|
||||||
|
val txCommand = Command(DbFailureContract.Commands.Create(), ourIdentity.owningKey)
|
||||||
|
|
||||||
|
logger.info("Test flow: tx builder")
|
||||||
|
val txBuilder = TransactionBuilder(notary)
|
||||||
|
.addOutputState(state)
|
||||||
|
.addCommand(txCommand)
|
||||||
|
|
||||||
|
logger.info("Test flow: verify")
|
||||||
|
txBuilder.verify(serviceHub)
|
||||||
|
|
||||||
|
val signedTx = serviceHub.signInitialTransaction(txBuilder)
|
||||||
|
|
||||||
|
@Suppress("TooGenericExceptionCaught") // this is fully intentional here, to allow twiddling with exceptions according to config
|
||||||
|
try {
|
||||||
|
logger.info("Test flow: recording transaction")
|
||||||
|
serviceHub.recordTransactions(signedTx)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
if (getFlowTarget(errorTarget) == CreateStateFlow.ErrorTarget.FlowSwallowErrors) {
|
||||||
|
logger.info("Test flow: Swallowing all exception! Muahahaha!", t)
|
||||||
|
} else {
|
||||||
|
logger.info("Test flow: caught exception - rethrowing")
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info("Test flow: returning")
|
||||||
|
return state.linearId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,98 @@
|
|||||||
|
package com.r3.dbfailure.workflows
|
||||||
|
|
||||||
|
import com.r3.dbfailure.contracts.DbFailureContract
|
||||||
|
import net.corda.core.node.AppServiceHub
|
||||||
|
import net.corda.core.node.services.CordaService
|
||||||
|
import net.corda.core.serialization.SingletonSerializeAsToken
|
||||||
|
import net.corda.core.utilities.contextLogger
|
||||||
|
import java.security.InvalidParameterException
|
||||||
|
|
||||||
|
@CordaService
|
||||||
|
class DbListenerService(services: AppServiceHub) : SingletonSerializeAsToken() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val log = contextLogger()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
services.vaultService.rawUpdates.subscribe { (_, produced) ->
|
||||||
|
produced.forEach {
|
||||||
|
val contractState = it.state.data as? DbFailureContract.TestState
|
||||||
|
@Suppress("TooGenericExceptionCaught") // this is fully intentional here, to allow twiddling with exceptions
|
||||||
|
try {
|
||||||
|
when (CreateStateFlow.getServiceTarget(contractState?.errorTarget)) {
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceSqlSyntaxError -> {
|
||||||
|
log.info("Fail with syntax error on raw statement")
|
||||||
|
val session = services.jdbcSession()
|
||||||
|
val statement = session.createStatement()
|
||||||
|
statement.execute(
|
||||||
|
"UPDATE FAIL_TEST_STATES \n" +
|
||||||
|
"BLAAA RANDOM_VALUE = NULL\n" +
|
||||||
|
"WHERE transaction_id = '${it.ref.txhash}' AND output_index = ${it.ref.index};"
|
||||||
|
)
|
||||||
|
log.info("SQL result: ${statement.resultSet}")
|
||||||
|
}
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceNullConstraintViolation -> {
|
||||||
|
log.info("Fail with null constraint violation on raw statement")
|
||||||
|
val session = services.jdbcSession()
|
||||||
|
val statement = session.createStatement()
|
||||||
|
statement.execute(
|
||||||
|
"UPDATE FAIL_TEST_STATES \n" +
|
||||||
|
"SET RANDOM_VALUE = NULL\n" +
|
||||||
|
"WHERE transaction_id = '${it.ref.txhash}' AND output_index = ${it.ref.index};"
|
||||||
|
)
|
||||||
|
log.info("SQL result: ${statement.resultSet}")
|
||||||
|
}
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceValidUpdate -> {
|
||||||
|
log.info("Update current statement")
|
||||||
|
val session = services.jdbcSession()
|
||||||
|
val statement = session.createStatement()
|
||||||
|
statement.execute(
|
||||||
|
"UPDATE FAIL_TEST_STATES \n" +
|
||||||
|
"SET RANDOM_VALUE = '${contractState!!.randomValue} Updated by service'\n" +
|
||||||
|
"WHERE transaction_id = '${it.ref.txhash}' AND output_index = ${it.ref.index};"
|
||||||
|
)
|
||||||
|
log.info("SQL result: ${statement.resultSet}")
|
||||||
|
}
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceReadState -> {
|
||||||
|
log.info("Read current state from db")
|
||||||
|
val session = services.jdbcSession()
|
||||||
|
val statement = session.createStatement()
|
||||||
|
statement.execute(
|
||||||
|
"SELECT * FROM FAIL_TEST_STATES \n" +
|
||||||
|
"WHERE transaction_id = '${it.ref.txhash}' AND output_index = ${it.ref.index};"
|
||||||
|
)
|
||||||
|
log.info("SQL result: ${statement.resultSet}")
|
||||||
|
}
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceCheckForState -> {
|
||||||
|
log.info("Check for currently written state in the db")
|
||||||
|
val session = services.jdbcSession()
|
||||||
|
val statement = session.createStatement()
|
||||||
|
val rs = statement.executeQuery(
|
||||||
|
"SELECT COUNT(*) FROM FAIL_TEST_STATES \n" +
|
||||||
|
"WHERE transaction_id = '${it.ref.txhash}' AND output_index = ${it.ref.index};"
|
||||||
|
)
|
||||||
|
val numOfRows = if (rs.next()) rs.getInt("COUNT(*)") else 0
|
||||||
|
log.info("Found a state with tx:ind ${it.ref.txhash}:${it.ref.index} in " +
|
||||||
|
"TEST_FAIL_STATES: ${if (numOfRows > 0) "Yes" else "No"}")
|
||||||
|
}
|
||||||
|
CreateStateFlow.ErrorTarget.ServiceThrowInvalidParameter -> {
|
||||||
|
log.info("Throw InvalidParameterException")
|
||||||
|
throw InvalidParameterException("Toys out of pram")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// do nothing, everything else must be handled elsewhere
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
if (CreateStateFlow.getServiceExceptionHandlingTarget(contractState?.errorTarget)
|
||||||
|
== CreateStateFlow.ErrorTarget.ServiceSwallowErrors) {
|
||||||
|
log.warn("Service not letting errors escape", t)
|
||||||
|
} else {
|
||||||
|
throw t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -18,6 +18,7 @@ import net.corda.core.node.NetworkParameters
|
|||||||
import net.corda.core.node.NotaryInfo
|
import net.corda.core.node.NotaryInfo
|
||||||
import net.corda.core.node.services.NetworkMapCache
|
import net.corda.core.node.services.NetworkMapCache
|
||||||
import net.corda.core.utilities.NetworkHostAndPort
|
import net.corda.core.utilities.NetworkHostAndPort
|
||||||
|
import net.corda.core.utilities.Try
|
||||||
import net.corda.core.utilities.contextLogger
|
import net.corda.core.utilities.contextLogger
|
||||||
import net.corda.core.utilities.getOrThrow
|
import net.corda.core.utilities.getOrThrow
|
||||||
import net.corda.core.utilities.millis
|
import net.corda.core.utilities.millis
|
||||||
@ -57,6 +58,7 @@ import rx.schedulers.Schedulers
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.ConnectException
|
import java.net.ConnectException
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
|
import java.net.URLClassLoader
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
@ -124,6 +126,14 @@ class DriverDSLImpl(
|
|||||||
//TODO: remove this once we can bundle quasar properly.
|
//TODO: remove this once we can bundle quasar properly.
|
||||||
private val quasarJarPath: String by lazy { resolveJar("co.paralleluniverse.fibers.Suspendable") }
|
private val quasarJarPath: String by lazy { resolveJar("co.paralleluniverse.fibers.Suspendable") }
|
||||||
|
|
||||||
|
private val bytemanJarPath: String? by lazy {
|
||||||
|
try {
|
||||||
|
resolveJar("org.jboss.byteman.agent.Transformer")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun NodeConfig.checkAndOverrideForInMemoryDB(): NodeConfig = this.run {
|
private fun NodeConfig.checkAndOverrideForInMemoryDB(): NodeConfig = this.run {
|
||||||
if (inMemoryDB && corda.dataSourceProperties.getProperty("dataSource.url").startsWith("jdbc:h2:")) {
|
if (inMemoryDB && corda.dataSourceProperties.getProperty("dataSource.url").startsWith("jdbc:h2:")) {
|
||||||
val jdbcUrl = "jdbc:h2:mem:persistence${inMemoryCounter.getAndIncrement()};DB_CLOSE_ON_EXIT=FALSE;LOCK_TIMEOUT=10000;WRITE_DELAY=100"
|
val jdbcUrl = "jdbc:h2:mem:persistence${inMemoryCounter.getAndIncrement()};DB_CLOSE_ON_EXIT=FALSE;LOCK_TIMEOUT=10000;WRITE_DELAY=100"
|
||||||
@ -178,7 +188,9 @@ class DriverDSLImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun startNode(parameters: NodeParameters): CordaFuture<NodeHandle> {
|
override fun startNode(parameters: NodeParameters): CordaFuture<NodeHandle> = startNode(parameters, bytemanPort = null)
|
||||||
|
|
||||||
|
override fun startNode(parameters: NodeParameters, bytemanPort: Int?): CordaFuture<NodeHandle> {
|
||||||
val p2pAddress = portAllocation.nextHostAndPort()
|
val p2pAddress = portAllocation.nextHostAndPort()
|
||||||
// TODO: Derive name from the full picked name, don't just wrap the common name
|
// TODO: Derive name from the full picked name, don't just wrap the common name
|
||||||
val name = parameters.providedName ?: CordaX500Name("${oneOf(names).organisation}-${p2pAddress.port}", "London", "GB")
|
val name = parameters.providedName ?: CordaX500Name("${oneOf(names).organisation}-${p2pAddress.port}", "London", "GB")
|
||||||
@ -193,15 +205,17 @@ class DriverDSLImpl(
|
|||||||
return registrationFuture.flatMap {
|
return registrationFuture.flatMap {
|
||||||
networkMapAvailability.flatMap {
|
networkMapAvailability.flatMap {
|
||||||
// But starting the node proper does require the network map
|
// But starting the node proper does require the network map
|
||||||
startRegisteredNode(name, it, parameters, p2pAddress)
|
startRegisteredNode(name, it, parameters, p2pAddress, bytemanPort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("ComplexMethod")
|
||||||
private fun startRegisteredNode(name: CordaX500Name,
|
private fun startRegisteredNode(name: CordaX500Name,
|
||||||
localNetworkMap: LocalNetworkMap?,
|
localNetworkMap: LocalNetworkMap?,
|
||||||
parameters: NodeParameters,
|
parameters: NodeParameters,
|
||||||
p2pAddress: NetworkHostAndPort = portAllocation.nextHostAndPort()): CordaFuture<NodeHandle> {
|
p2pAddress: NetworkHostAndPort = portAllocation.nextHostAndPort(),
|
||||||
|
bytemanPort: Int? = null): CordaFuture<NodeHandle> {
|
||||||
val rpcAddress = portAllocation.nextHostAndPort()
|
val rpcAddress = portAllocation.nextHostAndPort()
|
||||||
val rpcAdminAddress = portAllocation.nextHostAndPort()
|
val rpcAdminAddress = portAllocation.nextHostAndPort()
|
||||||
val webAddress = portAllocation.nextHostAndPort()
|
val webAddress = portAllocation.nextHostAndPort()
|
||||||
@ -240,7 +254,7 @@ class DriverDSLImpl(
|
|||||||
allowMissingConfig = true,
|
allowMissingConfig = true,
|
||||||
configOverrides = if (overrides.hasPath("devMode")) overrides else overrides + mapOf("devMode" to true)
|
configOverrides = if (overrides.hasPath("devMode")) overrides else overrides + mapOf("devMode" to true)
|
||||||
)).checkAndOverrideForInMemoryDB()
|
)).checkAndOverrideForInMemoryDB()
|
||||||
return startNodeInternal(config, webAddress, localNetworkMap, parameters)
|
return startNodeInternal(config, webAddress, localNetworkMap, parameters, bytemanPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startNodeRegistration(
|
private fun startNodeRegistration(
|
||||||
@ -542,6 +556,8 @@ class DriverDSLImpl(
|
|||||||
config,
|
config,
|
||||||
quasarJarPath,
|
quasarJarPath,
|
||||||
debugPort,
|
debugPort,
|
||||||
|
bytemanJarPath,
|
||||||
|
null,
|
||||||
systemProperties,
|
systemProperties,
|
||||||
"512m",
|
"512m",
|
||||||
null,
|
null,
|
||||||
@ -553,10 +569,12 @@ class DriverDSLImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("ComplexMethod")
|
||||||
private fun startNodeInternal(config: NodeConfig,
|
private fun startNodeInternal(config: NodeConfig,
|
||||||
webAddress: NetworkHostAndPort,
|
webAddress: NetworkHostAndPort,
|
||||||
localNetworkMap: LocalNetworkMap?,
|
localNetworkMap: LocalNetworkMap?,
|
||||||
parameters: NodeParameters): CordaFuture<NodeHandle> {
|
parameters: NodeParameters,
|
||||||
|
bytemanPort: Int?): CordaFuture<NodeHandle> {
|
||||||
val visibilityHandle = networkVisibilityController.register(config.corda.myLegalName)
|
val visibilityHandle = networkVisibilityController.register(config.corda.myLegalName)
|
||||||
val baseDirectory = config.corda.baseDirectory.createDirectories()
|
val baseDirectory = config.corda.baseDirectory.createDirectories()
|
||||||
localNetworkMap?.networkParametersCopier?.install(baseDirectory)
|
localNetworkMap?.networkParametersCopier?.install(baseDirectory)
|
||||||
@ -602,7 +620,16 @@ class DriverDSLImpl(
|
|||||||
nodeFuture
|
nodeFuture
|
||||||
} else {
|
} else {
|
||||||
val debugPort = if (isDebug) debugPortAllocation.nextPort() else null
|
val debugPort = if (isDebug) debugPortAllocation.nextPort() else null
|
||||||
val process = startOutOfProcessNode(config, quasarJarPath, debugPort, systemProperties, parameters.maximumHeapSize, parameters.logLevelOverride)
|
val process = startOutOfProcessNode(
|
||||||
|
config,
|
||||||
|
quasarJarPath,
|
||||||
|
debugPort,
|
||||||
|
bytemanJarPath,
|
||||||
|
bytemanPort,
|
||||||
|
systemProperties,
|
||||||
|
parameters.maximumHeapSize,
|
||||||
|
parameters.logLevelOverride
|
||||||
|
)
|
||||||
|
|
||||||
// Destroy the child process when the parent exits.This is needed even when `waitForAllNodesToFinish` is
|
// Destroy the child process when the parent exits.This is needed even when `waitForAllNodesToFinish` is
|
||||||
// true because we don't want orphaned processes in the case that the parent process is terminated by the
|
// true because we don't want orphaned processes in the case that the parent process is terminated by the
|
||||||
@ -726,16 +753,21 @@ class DriverDSLImpl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("ComplexMethod", "MaxLineLength")
|
||||||
private fun startOutOfProcessNode(
|
private fun startOutOfProcessNode(
|
||||||
config: NodeConfig,
|
config: NodeConfig,
|
||||||
quasarJarPath: String,
|
quasarJarPath: String,
|
||||||
debugPort: Int?,
|
debugPort: Int?,
|
||||||
|
bytemanJarPath: String?,
|
||||||
|
bytemanPort: Int?,
|
||||||
overriddenSystemProperties: Map<String, String>,
|
overriddenSystemProperties: Map<String, String>,
|
||||||
maximumHeapSize: String,
|
maximumHeapSize: String,
|
||||||
logLevelOverride: String?,
|
logLevelOverride: String?,
|
||||||
vararg extraCmdLineFlag: String
|
vararg extraCmdLineFlag: String
|
||||||
): Process {
|
): Process {
|
||||||
log.info("Starting out-of-process Node ${config.corda.myLegalName.organisation}, debug port is " + (debugPort ?: "not enabled"))
|
log.info("Starting out-of-process Node ${config.corda.myLegalName.organisation}, " +
|
||||||
|
"debug port is " + (debugPort ?: "not enabled") + ", " +
|
||||||
|
"byteMan: " + if (bytemanJarPath == null) "not in classpath" else "port is " + (bytemanPort ?: "not enabled"))
|
||||||
// Write node.conf
|
// Write node.conf
|
||||||
writeConfig(config.corda.baseDirectory, "node.conf", config.typesafe.toNodeOnly())
|
writeConfig(config.corda.baseDirectory, "node.conf", config.typesafe.toNodeOnly())
|
||||||
|
|
||||||
@ -777,6 +809,20 @@ class DriverDSLImpl(
|
|||||||
it += extraCmdLineFlag
|
it += extraCmdLineFlag
|
||||||
}.toList()
|
}.toList()
|
||||||
|
|
||||||
|
val bytemanJvmArgs = {
|
||||||
|
val bytemanAgent = bytemanJarPath?.let {
|
||||||
|
bytemanPort?.let {
|
||||||
|
"-javaagent:$bytemanJarPath=port:$bytemanPort,listener:true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listOfNotNull(bytemanAgent) +
|
||||||
|
if (bytemanAgent != null && debugPort != null) listOf(
|
||||||
|
"-Dorg.jboss.byteman.verbose=true",
|
||||||
|
"-Dorg.jboss.byteman.debug=true"
|
||||||
|
)
|
||||||
|
else emptyList()
|
||||||
|
}.invoke()
|
||||||
|
|
||||||
// The following dependencies are excluded from the classpath of the created JVM, so that the environment resembles a real one as close as possible.
|
// The following dependencies are excluded from the classpath of the created JVM, so that the environment resembles a real one as close as possible.
|
||||||
// These are either classes that will be added as attachments to the node (i.e. samples, finance, opengamma etc.) or irrelevant testing libraries (test, corda-mock etc.).
|
// These are either classes that will be added as attachments to the node (i.e. samples, finance, opengamma etc.) or irrelevant testing libraries (test, corda-mock etc.).
|
||||||
// TODO: There is pending work to fix this issue without custom blacklisting. See: https://r3-cev.atlassian.net/browse/CORDA-2164.
|
// TODO: There is pending work to fix this issue without custom blacklisting. See: https://r3-cev.atlassian.net/browse/CORDA-2164.
|
||||||
@ -789,7 +835,7 @@ class DriverDSLImpl(
|
|||||||
className = "net.corda.node.Corda", // cannot directly get class for this, so just use string
|
className = "net.corda.node.Corda", // cannot directly get class for this, so just use string
|
||||||
arguments = arguments,
|
arguments = arguments,
|
||||||
jdwpPort = debugPort,
|
jdwpPort = debugPort,
|
||||||
extraJvmArguments = extraJvmArguments,
|
extraJvmArguments = extraJvmArguments + bytemanJvmArgs,
|
||||||
workingDirectory = config.corda.baseDirectory,
|
workingDirectory = config.corda.baseDirectory,
|
||||||
maximumHeapSize = maximumHeapSize,
|
maximumHeapSize = maximumHeapSize,
|
||||||
classPath = cp
|
classPath = cp
|
||||||
@ -952,6 +998,11 @@ interface InternalDriverDSL : DriverDSL {
|
|||||||
fun start()
|
fun start()
|
||||||
|
|
||||||
fun shutdown()
|
fun shutdown()
|
||||||
|
|
||||||
|
fun startNode(
|
||||||
|
parameters: NodeParameters = NodeParameters(),
|
||||||
|
bytemanPort: Int? = null
|
||||||
|
): CordaFuture<NodeHandle>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package net.corda.testing.node.internal
|
package net.corda.testing.node.internal
|
||||||
|
|
||||||
|
import net.corda.core.flows.StateMachineRunId
|
||||||
import net.corda.core.identity.CordaX500Name
|
import net.corda.core.identity.CordaX500Name
|
||||||
import net.corda.core.identity.PartyAndCertificate
|
import net.corda.core.identity.PartyAndCertificate
|
||||||
import net.corda.core.internal.PLATFORM_VERSION
|
import net.corda.core.internal.PLATFORM_VERSION
|
||||||
@ -268,6 +269,7 @@ class MockNodeMessagingService(private val configuration: NodeConfiguration,
|
|||||||
private inner class InMemoryDeduplicationHandler(override val receivedMessage: ReceivedMessage, val transfer: InMemoryMessagingNetwork.MessageTransfer) : DeduplicationHandler, ExternalEvent.ExternalMessageEvent {
|
private inner class InMemoryDeduplicationHandler(override val receivedMessage: ReceivedMessage, val transfer: InMemoryMessagingNetwork.MessageTransfer) : DeduplicationHandler, ExternalEvent.ExternalMessageEvent {
|
||||||
override val externalCause: ExternalEvent
|
override val externalCause: ExternalEvent
|
||||||
get() = this
|
get() = this
|
||||||
|
override val flowId: StateMachineRunId = StateMachineRunId.createRandom()
|
||||||
override val deduplicationHandler: DeduplicationHandler
|
override val deduplicationHandler: DeduplicationHandler
|
||||||
get() = this
|
get() = this
|
||||||
|
|
||||||
|
@ -135,6 +135,7 @@ class NodeController(check: atRuntime = ::checkExists) : Controller() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // initialising to max value
|
||||||
private fun makeNetworkParametersCopier(config: NodeConfigWrapper): NetworkParametersCopier {
|
private fun makeNetworkParametersCopier(config: NodeConfigWrapper): NetworkParametersCopier {
|
||||||
val identity = getNotaryIdentity(config)
|
val identity = getNotaryIdentity(config)
|
||||||
val parametersCopier = NetworkParametersCopier(NetworkParameters(
|
val parametersCopier = NetworkParametersCopier(NetworkParameters(
|
||||||
|
@ -241,6 +241,7 @@ class NodeTabView : Fragment() {
|
|||||||
CityDatabase.cityMap.values.map { it.countryCode }.toSet().map { it to Image(resources["/net/corda/demobench/flags/$it.png"]) }.toMap()
|
CityDatabase.cityMap.values.map { it.countryCode }.toSet().map { it to Image(resources["/net/corda/demobench/flags/$it.png"]) }.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // demobench UI magic
|
||||||
private fun Pane.nearestCityField(): ComboBox<WorldMapLocation> {
|
private fun Pane.nearestCityField(): ComboBox<WorldMapLocation> {
|
||||||
return combobox(model.nearestCity, CityDatabase.cityMap.values.toList().sortedBy { it.description }) {
|
return combobox(model.nearestCity, CityDatabase.cityMap.values.toList().sortedBy { it.description }) {
|
||||||
minWidth = textWidth
|
minWidth = textWidth
|
||||||
|
@ -70,6 +70,7 @@ fun main(args: Array<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // test constants
|
||||||
private fun runLoadTest(loadTestConfiguration: LoadTestConfiguration) {
|
private fun runLoadTest(loadTestConfiguration: LoadTestConfiguration) {
|
||||||
runLoadTests(loadTestConfiguration, listOf(
|
runLoadTests(loadTestConfiguration, listOf(
|
||||||
selfIssueTest to LoadTest.RunParameters(
|
selfIssueTest to LoadTest.RunParameters(
|
||||||
@ -131,6 +132,7 @@ private fun runLoadTest(loadTestConfiguration: LoadTestConfiguration) {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // test constants
|
||||||
private fun runStabilityTest(loadTestConfiguration: LoadTestConfiguration) {
|
private fun runStabilityTest(loadTestConfiguration: LoadTestConfiguration) {
|
||||||
runLoadTests(loadTestConfiguration, listOf(
|
runLoadTests(loadTestConfiguration, listOf(
|
||||||
// Self issue cash. This is a pre test step to make sure vault have enough cash to work with.
|
// Self issue cash. This is a pre test step to make sure vault have enough cash to work with.
|
||||||
|
@ -38,6 +38,7 @@ interface Volume {
|
|||||||
nodeInfoFile.readBytes().deserialize<SignedNodeInfo>().verified().let { NotaryInfo(it.legalIdentities.first(), validating) }
|
nodeInfoFile.readBytes().deserialize<SignedNodeInfo>().verified().let { NotaryInfo(it.legalIdentities.first(), validating) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("MagicNumber") // default config constants
|
||||||
return notaryInfos.let {
|
return notaryInfos.let {
|
||||||
NetworkParameters(
|
NetworkParameters(
|
||||||
minimumPlatformVersion = 1,
|
minimumPlatformVersion = 1,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user