mirror of
https://github.com/corda/corda.git
synced 2025-02-07 11:30:22 +00:00
* ENT-4237: Added timestamp to the node_transactions table. * ENT-4237: Clock for timestamp now retrieved from ServiceHub. And now record verification time as well. * ENT-4237: Fixed tests. Also enabled stream output in allParallelIntegrationTest. * ENT-4237: Changed timestamp to a val. * ENT-4237: Changed streamOutput to false for allParallelIntegrationTest * ENT-4237: Unit tests added for new timestamp column. Also now passing a clock into DBTransactionStorage. * ENT-4237: Added more unit tests to check timestamp * ENT-4237: Fix test to actually change clock time when testing transaction time does not change.
This commit is contained in:
parent
8f7346f84c
commit
4a35b99283
@ -846,7 +846,7 @@ abstract class AbstractNode<S>(val configuration: NodeConfiguration,
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected open fun makeTransactionStorage(transactionCacheSizeBytes: Long): WritableTransactionStorage {
|
protected open fun makeTransactionStorage(transactionCacheSizeBytes: Long): WritableTransactionStorage {
|
||||||
return DBTransactionStorage(database, cacheFactory)
|
return DBTransactionStorage(database, cacheFactory, platformClock)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun makeNetworkParametersStorage(): NetworkParametersStorage {
|
protected open fun makeNetworkParametersStorage(): NetworkParametersStorage {
|
||||||
|
@ -8,6 +8,7 @@ import liquibase.exception.ValidationErrors
|
|||||||
import liquibase.resource.ResourceAccessor
|
import liquibase.resource.ResourceAccessor
|
||||||
import net.corda.core.identity.CordaX500Name
|
import net.corda.core.identity.CordaX500Name
|
||||||
import net.corda.core.schemas.MappedSchema
|
import net.corda.core.schemas.MappedSchema
|
||||||
|
import net.corda.node.SimpleClock
|
||||||
import net.corda.node.services.identity.PersistentIdentityService
|
import net.corda.node.services.identity.PersistentIdentityService
|
||||||
import net.corda.node.services.persistence.*
|
import net.corda.node.services.persistence.*
|
||||||
import net.corda.nodeapi.internal.persistence.CordaPersistence
|
import net.corda.nodeapi.internal.persistence.CordaPersistence
|
||||||
@ -16,6 +17,7 @@ import net.corda.nodeapi.internal.persistence.SchemaMigration.Companion.NODE_X50
|
|||||||
import java.io.PrintWriter
|
import java.io.PrintWriter
|
||||||
import java.sql.Connection
|
import java.sql.Connection
|
||||||
import java.sql.SQLFeatureNotSupportedException
|
import java.sql.SQLFeatureNotSupportedException
|
||||||
|
import java.time.Clock
|
||||||
import java.util.logging.Logger
|
import java.util.logging.Logger
|
||||||
import javax.sql.DataSource
|
import javax.sql.DataSource
|
||||||
|
|
||||||
@ -62,7 +64,7 @@ abstract class CordaMigration : CustomTaskChange {
|
|||||||
|
|
||||||
cordaDB.transaction {
|
cordaDB.transaction {
|
||||||
identityService.ourNames = setOf(ourName)
|
identityService.ourNames = setOf(ourName)
|
||||||
val dbTransactions = DBTransactionStorage(cordaDB, cacheFactory)
|
val dbTransactions = DBTransactionStorage(cordaDB, cacheFactory, SimpleClock(Clock.systemUTC()))
|
||||||
val attachmentsService = NodeAttachmentService(metricRegistry, cacheFactory, cordaDB)
|
val attachmentsService = NodeAttachmentService(metricRegistry, cacheFactory, cordaDB)
|
||||||
_servicesForResolution = MigrationServicesForResolution(identityService, attachmentsService, dbTransactions, cordaDB, cacheFactory)
|
_servicesForResolution = MigrationServicesForResolution(identityService, attachmentsService, dbTransactions, cordaDB, cacheFactory)
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,7 @@ import net.corda.core.transactions.CoreTransaction
|
|||||||
import net.corda.core.transactions.SignedTransaction
|
import net.corda.core.transactions.SignedTransaction
|
||||||
import net.corda.core.utilities.contextLogger
|
import net.corda.core.utilities.contextLogger
|
||||||
import net.corda.core.utilities.debug
|
import net.corda.core.utilities.debug
|
||||||
|
import net.corda.node.CordaClock
|
||||||
import net.corda.node.services.api.WritableTransactionStorage
|
import net.corda.node.services.api.WritableTransactionStorage
|
||||||
import net.corda.node.services.statemachine.FlowStateMachineImpl
|
import net.corda.node.services.statemachine.FlowStateMachineImpl
|
||||||
import net.corda.node.utilities.AppendOnlyPersistentMapBase
|
import net.corda.node.utilities.AppendOnlyPersistentMapBase
|
||||||
@ -24,11 +25,13 @@ import net.corda.nodeapi.internal.persistence.*
|
|||||||
import net.corda.serialization.internal.CordaSerializationEncoding.SNAPPY
|
import net.corda.serialization.internal.CordaSerializationEncoding.SNAPPY
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.subjects.PublishSubject
|
import rx.subjects.PublishSubject
|
||||||
|
import java.time.Instant
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import javax.persistence.*
|
import javax.persistence.*
|
||||||
import kotlin.streams.toList
|
import kotlin.streams.toList
|
||||||
|
|
||||||
class DBTransactionStorage(private val database: CordaPersistence, cacheFactory: NamedCacheFactory) : WritableTransactionStorage, SingletonSerializeAsToken() {
|
class DBTransactionStorage(private val database: CordaPersistence, cacheFactory: NamedCacheFactory,
|
||||||
|
private val clock: CordaClock) : WritableTransactionStorage, SingletonSerializeAsToken() {
|
||||||
|
|
||||||
@Suppress("MagicNumber") // database column width
|
@Suppress("MagicNumber") // database column width
|
||||||
@Entity
|
@Entity
|
||||||
@ -47,8 +50,11 @@ class DBTransactionStorage(private val database: CordaPersistence, cacheFactory:
|
|||||||
|
|
||||||
@Column(name = "status", nullable = false, length = 1)
|
@Column(name = "status", nullable = false, length = 1)
|
||||||
@Convert(converter = TransactionStatusConverter::class)
|
@Convert(converter = TransactionStatusConverter::class)
|
||||||
val status: TransactionStatus
|
val status: TransactionStatus,
|
||||||
)
|
|
||||||
|
@Column(name = "timestamp", nullable = false)
|
||||||
|
val timestamp: Instant
|
||||||
|
)
|
||||||
|
|
||||||
enum class TransactionStatus {
|
enum class TransactionStatus {
|
||||||
UNVERIFIED,
|
UNVERIFIED,
|
||||||
@ -105,7 +111,7 @@ class DBTransactionStorage(private val database: CordaPersistence, cacheFactory:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createTransactionsMap(cacheFactory: NamedCacheFactory)
|
fun createTransactionsMap(cacheFactory: NamedCacheFactory, clock: CordaClock)
|
||||||
: AppendOnlyPersistentMapBase<SecureHash, TxCacheValue, DBTransaction, String> {
|
: AppendOnlyPersistentMapBase<SecureHash, TxCacheValue, DBTransaction, String> {
|
||||||
return WeightBasedAppendOnlyPersistentMap<SecureHash, TxCacheValue, DBTransaction, String>(
|
return WeightBasedAppendOnlyPersistentMap<SecureHash, TxCacheValue, DBTransaction, String>(
|
||||||
cacheFactory = cacheFactory,
|
cacheFactory = cacheFactory,
|
||||||
@ -121,7 +127,8 @@ class DBTransactionStorage(private val database: CordaPersistence, cacheFactory:
|
|||||||
txId = key.toString(),
|
txId = key.toString(),
|
||||||
stateMachineRunId = FlowStateMachineImpl.currentStateMachine()?.id?.uuid?.toString(),
|
stateMachineRunId = FlowStateMachineImpl.currentStateMachine()?.id?.uuid?.toString(),
|
||||||
transaction = value.toSignedTx().serialize(context = contextToUse().withEncoding(SNAPPY)).bytes,
|
transaction = value.toSignedTx().serialize(context = contextToUse().withEncoding(SNAPPY)).bytes,
|
||||||
status = value.status
|
status = value.status,
|
||||||
|
timestamp = clock.instant()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
persistentEntityClass = DBTransaction::class.java,
|
persistentEntityClass = DBTransaction::class.java,
|
||||||
@ -135,7 +142,7 @@ class DBTransactionStorage(private val database: CordaPersistence, cacheFactory:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val txStorage = ThreadBox(createTransactionsMap(cacheFactory))
|
private val txStorage = ThreadBox(createTransactionsMap(cacheFactory, clock))
|
||||||
|
|
||||||
private fun updateTransaction(txId: SecureHash): Boolean {
|
private fun updateTransaction(txId: SecureHash): Boolean {
|
||||||
val session = currentDBSession()
|
val session = currentDBSession()
|
||||||
@ -147,6 +154,7 @@ class DBTransactionStorage(private val database: CordaPersistence, cacheFactory:
|
|||||||
criteriaBuilder.equal(updateRoot.get<String>(DBTransaction::txId.name), txId.toString()),
|
criteriaBuilder.equal(updateRoot.get<String>(DBTransaction::txId.name), txId.toString()),
|
||||||
criteriaBuilder.equal(updateRoot.get<TransactionStatus>(DBTransaction::status.name), TransactionStatus.UNVERIFIED)
|
criteriaBuilder.equal(updateRoot.get<TransactionStatus>(DBTransaction::status.name), TransactionStatus.UNVERIFIED)
|
||||||
))
|
))
|
||||||
|
criteriaUpdate.set(updateRoot.get<Instant>(DBTransaction::timestamp.name), clock.instant())
|
||||||
val update = session.createQuery(criteriaUpdate)
|
val update = session.createQuery(criteriaUpdate)
|
||||||
val rowsUpdated = update.executeUpdate()
|
val rowsUpdated = update.executeUpdate()
|
||||||
return rowsUpdated != 0
|
return rowsUpdated != 0
|
||||||
|
@ -24,6 +24,7 @@
|
|||||||
<include file="migration/node-core.changelog-v13.xml"/>
|
<include file="migration/node-core.changelog-v13.xml"/>
|
||||||
<!-- This change should be done before the v14-data migration. -->
|
<!-- This change should be done before the v14-data migration. -->
|
||||||
<include file="migration/node-core.changelog-v15.xml"/>
|
<include file="migration/node-core.changelog-v15.xml"/>
|
||||||
|
<include file="migration/node-core.changelog-v16.xml"/>
|
||||||
|
|
||||||
<!-- This must run after node-core.changelog-init.xml, to prevent database columns being created twice. -->
|
<!-- This must run after node-core.changelog-init.xml, to prevent database columns being created twice. -->
|
||||||
<include file="migration/vault-schema.changelog-v9.xml"/>
|
<include file="migration/vault-schema.changelog-v9.xml"/>
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
<?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"
|
||||||
|
logicalFilePath="migration/node-services.changelog-init.xml">
|
||||||
|
|
||||||
|
<changeSet author="R3.Corda" id="add_timestamp_column_to_node_transactions">
|
||||||
|
<addColumn tableName="node_transactions">
|
||||||
|
<column name="timestamp" type="TIMESTAMP" defaultValueDate="2000-01-01 12:00:00">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
@ -50,6 +50,7 @@ import org.mockito.Mockito
|
|||||||
import java.security.KeyPair
|
import java.security.KeyPair
|
||||||
import java.time.Clock
|
import java.time.Clock
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFailsWith
|
import kotlin.test.assertFailsWith
|
||||||
@ -208,7 +209,8 @@ class VaultStateMigrationTest {
|
|||||||
txId = tx.id.toString(),
|
txId = tx.id.toString(),
|
||||||
stateMachineRunId = null,
|
stateMachineRunId = null,
|
||||||
transaction = tx.serialize(context = SerializationDefaults.STORAGE_CONTEXT).bytes,
|
transaction = tx.serialize(context = SerializationDefaults.STORAGE_CONTEXT).bytes,
|
||||||
status = DBTransactionStorage.TransactionStatus.VERIFIED
|
status = DBTransactionStorage.TransactionStatus.VERIFIED,
|
||||||
|
timestamp = Instant.now()
|
||||||
)
|
)
|
||||||
session.save(persistentTx)
|
session.save(persistentTx)
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,9 @@ import net.corda.core.crypto.TransactionSignature
|
|||||||
import net.corda.core.toFuture
|
import net.corda.core.toFuture
|
||||||
import net.corda.core.transactions.SignedTransaction
|
import net.corda.core.transactions.SignedTransaction
|
||||||
import net.corda.node.services.transactions.PersistentUniquenessProvider
|
import net.corda.node.services.transactions.PersistentUniquenessProvider
|
||||||
|
import net.corda.node.CordaClock
|
||||||
|
import net.corda.node.MutableClock
|
||||||
|
import net.corda.node.SimpleClock
|
||||||
import net.corda.nodeapi.internal.persistence.CordaPersistence
|
import net.corda.nodeapi.internal.persistence.CordaPersistence
|
||||||
import net.corda.nodeapi.internal.persistence.DatabaseConfig
|
import net.corda.nodeapi.internal.persistence.DatabaseConfig
|
||||||
import net.corda.testing.core.*
|
import net.corda.testing.core.*
|
||||||
@ -22,6 +25,8 @@ import org.junit.After
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
@ -51,6 +56,116 @@ class DBTransactionStorageTests {
|
|||||||
LogHelper.reset(PersistentUniquenessProvider::class)
|
LogHelper.reset(PersistentUniquenessProvider::class)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class TransactionClock(var timeNow: Instant,
|
||||||
|
override var delegateClock: Clock = systemUTC()) : MutableClock(delegateClock) {
|
||||||
|
override fun instant(): Instant = timeNow
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `create verified transaction and validate timestamp in db`() {
|
||||||
|
val now = Instant.ofEpochSecond(111222333L)
|
||||||
|
val transactionClock = TransactionClock(now)
|
||||||
|
newTransactionStorage(clock = transactionClock)
|
||||||
|
val transaction = newTransaction()
|
||||||
|
transactionStorage.addTransaction(transaction)
|
||||||
|
assertEquals(now, readTransactionTimestampFromDB(transaction.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `create unverified transaction and validate timestamp in db`() {
|
||||||
|
val now = Instant.ofEpochSecond(333444555L)
|
||||||
|
val transactionClock = TransactionClock(now)
|
||||||
|
newTransactionStorage(clock = transactionClock)
|
||||||
|
val transaction = newTransaction()
|
||||||
|
transactionStorage.addUnverifiedTransaction(transaction)
|
||||||
|
assertEquals(now, readTransactionTimestampFromDB(transaction.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `create unverified then verified transaction and validate timestamps in db`() {
|
||||||
|
val unverifiedTime = Instant.ofEpochSecond(555666777L)
|
||||||
|
val verifiedTime = Instant.ofEpochSecond(888999111L)
|
||||||
|
val transactionClock = TransactionClock(unverifiedTime)
|
||||||
|
newTransactionStorage(clock = transactionClock)
|
||||||
|
val transaction = newTransaction()
|
||||||
|
transactionStorage.addUnverifiedTransaction(transaction)
|
||||||
|
assertEquals(unverifiedTime, readTransactionTimestampFromDB(transaction.id))
|
||||||
|
transactionClock.timeNow = verifiedTime
|
||||||
|
transactionStorage.addTransaction(transaction)
|
||||||
|
assertEquals(verifiedTime, readTransactionTimestampFromDB(transaction.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `check timestamp does not change when attempting to move transaction from verified to unverified`() {
|
||||||
|
|
||||||
|
val verifiedTime = Instant.ofEpochSecond(555666222L)
|
||||||
|
val differentTime = Instant.ofEpochSecond(888777666L)
|
||||||
|
val transactionClock = TransactionClock(verifiedTime)
|
||||||
|
|
||||||
|
newTransactionStorage(clock = transactionClock)
|
||||||
|
val transaction = newTransaction()
|
||||||
|
database.transaction {
|
||||||
|
transactionStorage.addTransaction(transaction)
|
||||||
|
}
|
||||||
|
assertEquals(verifiedTime, readTransactionTimestampFromDB(transaction.id))
|
||||||
|
transactionClock.timeNow = differentTime
|
||||||
|
database.transaction {
|
||||||
|
transactionStorage.addUnverifiedTransaction(transaction)
|
||||||
|
}
|
||||||
|
assertTransactionIsRetrievable(transaction)
|
||||||
|
assertEquals(verifiedTime, readTransactionTimestampFromDB(transaction.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `check timestamp does not change when transaction saved twice in same DB transaction scope`() {
|
||||||
|
val verifiedTime = Instant.ofEpochSecond(3333666222L)
|
||||||
|
val differentTime = Instant.ofEpochSecond(111777666L)
|
||||||
|
val transactionClock = TransactionClock(verifiedTime)
|
||||||
|
newTransactionStorage(clock = transactionClock)
|
||||||
|
val firstTransaction = newTransaction()
|
||||||
|
database.transaction {
|
||||||
|
transactionStorage.addTransaction(firstTransaction)
|
||||||
|
transactionClock.timeNow = differentTime
|
||||||
|
transactionStorage.addTransaction(firstTransaction)
|
||||||
|
}
|
||||||
|
assertTransactionIsRetrievable(firstTransaction)
|
||||||
|
assertThat(transactionStorage.transactions).containsOnly(firstTransaction)
|
||||||
|
assertEquals(verifiedTime, readTransactionTimestampFromDB(firstTransaction.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `check timestamp does not change when transaction saved twice in two DB transaction scopes`() {
|
||||||
|
val verifiedTime = Instant.ofEpochSecond(11119999222L)
|
||||||
|
val differentTime = Instant.ofEpochSecond(666333222L)
|
||||||
|
val transactionClock = TransactionClock(verifiedTime)
|
||||||
|
newTransactionStorage(clock = transactionClock)
|
||||||
|
val firstTransaction = newTransaction()
|
||||||
|
val secondTransaction = newTransaction()
|
||||||
|
|
||||||
|
transactionStorage.addTransaction(firstTransaction)
|
||||||
|
assertEquals(verifiedTime, readTransactionTimestampFromDB(firstTransaction.id))
|
||||||
|
transactionClock.timeNow = differentTime
|
||||||
|
|
||||||
|
database.transaction {
|
||||||
|
transactionStorage.addTransaction(secondTransaction)
|
||||||
|
transactionStorage.addTransaction(firstTransaction)
|
||||||
|
}
|
||||||
|
assertTransactionIsRetrievable(firstTransaction)
|
||||||
|
assertThat(transactionStorage.transactions).containsOnly(firstTransaction, secondTransaction)
|
||||||
|
assertEquals(verifiedTime, readTransactionTimestampFromDB(firstTransaction.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readTransactionTimestampFromDB(id: SecureHash): Instant {
|
||||||
|
val fromDb = database.transaction {
|
||||||
|
session.createQuery(
|
||||||
|
"from ${DBTransactionStorage.DBTransaction::class.java.name} where tx_id = :transactionId",
|
||||||
|
DBTransactionStorage.DBTransaction::class.java
|
||||||
|
).setParameter("transactionId", id.toString()).resultList.map { it }
|
||||||
|
}
|
||||||
|
assertEquals(1, fromDb.size)
|
||||||
|
return fromDb[0].timestamp
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `empty store`() {
|
fun `empty store`() {
|
||||||
assertThat(transactionStorage.getTransaction(newTransaction().id)).isNull()
|
assertThat(transactionStorage.getTransaction(newTransaction().id)).isNull()
|
||||||
@ -198,9 +313,9 @@ class DBTransactionStorageTests {
|
|||||||
assertTransactionIsRetrievable(secondTransaction)
|
assertTransactionIsRetrievable(secondTransaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun newTransactionStorage(cacheSizeBytesOverride: Long? = null) {
|
private fun newTransactionStorage(cacheSizeBytesOverride: Long? = null, clock: CordaClock = SimpleClock(Clock.systemUTC())) {
|
||||||
transactionStorage = DBTransactionStorage(database, TestingNamedCacheFactory(cacheSizeBytesOverride
|
transactionStorage = DBTransactionStorage(database, TestingNamedCacheFactory(cacheSizeBytesOverride
|
||||||
?: 1024))
|
?: 1024), clock)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun assertTransactionIsRetrievable(transaction: SignedTransaction) {
|
private fun assertTransactionIsRetrievable(transaction: SignedTransaction) {
|
||||||
|
@ -34,6 +34,7 @@ import org.junit.Before
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.sql.SQLException
|
import java.sql.SQLException
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@ -284,7 +285,8 @@ class RetryFlowMockTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun doInsert() {
|
private fun doInsert() {
|
||||||
val tx = DBTransactionStorage.DBTransaction("Foo", null, Utils.EMPTY_BYTES, DBTransactionStorage.TransactionStatus.VERIFIED)
|
val tx = DBTransactionStorage.DBTransaction("Foo", null, Utils.EMPTY_BYTES,
|
||||||
|
DBTransactionStorage.TransactionStatus.VERIFIED, Instant.now())
|
||||||
contextTransaction.session.save(tx)
|
contextTransaction.session.save(tx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user