From 0501bbdfd860f2a8c4427d56bd40ab958e2724ab Mon Sep 17 00:00:00 2001 From: rotilho Date: Tue, 4 Aug 2026 19:21:02 +0200 Subject: [PATCH] Stabilize bootstrap persistence under MySQL load Replace unlimited discovery buffering with an observable bounded queue. Pause scheduled discovery at target capacity, preserve every admitted transaction across saturation and database retries, and release stalled gap requests for automatic recovery. Batch duplicate-tolerant inserts, bound cleanup transactions, avoid resolution scans when work cannot progress, and add backlog and query metrics. Add the oldest-selection index and verify clean and populated V10 migrations without changing unchecked rows. Rewrite gap selection as a deterministic single-result indexed query. On MySQL 8.4.9 with 577,168 unchecked rows, the no-gap path dropped from 8.448 s to 146 ms (about 58x), and account lookups dropped from 577,168 to 1,711 without a gap-specific index. Verification: ./gradlew test passed in 2m57s. --- README.md | 8 +- .../cash/atto/node/DuplicateDetector.kt | 4 + .../cash/atto/node/NodeConfiguration.kt | 2 +- .../discovery/DependencyDiscoverer.kt | 26 +- .../bootstrap/discovery/DiscoveryMetrics.kt | 118 + .../discovery/DiscoveryPersistenceWorker.kt | 105 + .../bootstrap/discovery/DiscoveryProcessor.kt | 71 - .../discovery/DiscoveryProperties.kt | 45 + .../bootstrap/discovery/DiscoveryQueue.kt | 112 + .../node/bootstrap/discovery/GapDiscoverer.kt | 349 ++- .../node/bootstrap/discovery/GapSession.kt | 144 + .../bootstrap/discovery/LastDiscoverer.kt | 38 +- .../bootstrap/discovery/PendingDiscovery.kt | 20 + .../bootstrap/discovery/SendDiscoverer.kt | 38 +- .../unchecked/UncheckedTransactionInserter.kt | 59 +- .../UncheckedTransactionProcessor.kt | 40 - .../UncheckedTransactionProcessorStarter.kt | 161 ++ .../UncheckedTransactionRepository.kt | 124 +- .../unchecked/UncheckedTransactionService.kt | 10 +- .../unchecked/UncheckedWorkTracker.kt | 32 + .../transaction/TransactionNetworkProvider.kt | 40 +- .../kotlin/cash/atto/protocol/AttoNode.kt | 6 + .../protocol/AttoTransactionStreamRequest.kt | 1 + .../cash.atto/node/reachability-metadata.json | 2493 ++++++++++------- src/main/resources/application.yaml | 8 + ...add_unchecked_transaction_oldest_index.sql | 2 + .../discovery/DependencyDiscovererTest.kt | 122 + .../bootstrap/discovery/DiscoveryQueueTest.kt | 426 +++ .../discovery/DiscoveryTestController.kt | 8 +- .../bootstrap/discovery/GapDiscovererTest.kt | 506 ++++ .../bootstrap/discovery/GapSessionTest.kt | 418 +++ .../bootstrap/discovery/LastDiscovererTest.kt | 238 ++ .../bootstrap/discovery/SendDiscovererTest.kt | 237 ++ .../UncheckedTransactionInserterTest.kt | 133 + .../UncheckedTransactionMigrationTest.kt | 694 +++++ ...ncheckedTransactionProcessorStarterTest.kt | 113 + .../UncheckedTransactionServiceTest.kt | 29 + .../TransactionNetworkProviderTest.kt | 203 ++ 38 files changed, 5800 insertions(+), 1383 deletions(-) create mode 100644 src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryMetrics.kt create mode 100644 src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryPersistenceWorker.kt delete mode 100644 src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryProcessor.kt create mode 100644 src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryProperties.kt create mode 100644 src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryQueue.kt create mode 100644 src/main/kotlin/cash/atto/node/bootstrap/discovery/GapSession.kt create mode 100644 src/main/kotlin/cash/atto/node/bootstrap/discovery/PendingDiscovery.kt create mode 100644 src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessorStarter.kt create mode 100644 src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedWorkTracker.kt create mode 100644 src/main/resources/db/migration/V11__add_unchecked_transaction_oldest_index.sql create mode 100644 src/test/kotlin/cash/atto/node/bootstrap/discovery/DependencyDiscovererTest.kt create mode 100644 src/test/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryQueueTest.kt create mode 100644 src/test/kotlin/cash/atto/node/bootstrap/discovery/GapDiscovererTest.kt create mode 100644 src/test/kotlin/cash/atto/node/bootstrap/discovery/GapSessionTest.kt create mode 100644 src/test/kotlin/cash/atto/node/bootstrap/discovery/LastDiscovererTest.kt create mode 100644 src/test/kotlin/cash/atto/node/bootstrap/discovery/SendDiscovererTest.kt create mode 100644 src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionInserterTest.kt create mode 100644 src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionMigrationTest.kt create mode 100644 src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessorStarterTest.kt create mode 100644 src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionServiceTest.kt create mode 100644 src/test/kotlin/cash/atto/node/transaction/TransactionNetworkProviderTest.kt diff --git a/README.md b/README.md index 4456d56e..ead1f703 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,12 @@ stores it as an **unchecked transaction**: - If a receiving node is behind, it starts a lightweight vote stream for the head to confirm it’s real, then turns it into a `TransactionDiscovered` event. -- **Persistence batching** (`DiscoveryProcessor`) - - Buffers discovered transactions and periodically persists them into the unchecked table in bulk. +- **Bounded persistence queue** (`DiscoveryQueue`, `DiscoveryPersistenceWorker`) + - `DiscoveryQueue` deduplicates and buffers discovered transactions. + - New gap, head, and send discovery pauses at the configured target capacity. Replies already in flight can use the + configured headroom and suspend without being discarded when the bounded queue is full. + - `DiscoveryPersistenceWorker` persists batches of up to 1,000 transactions and retries the same batch after a + database failure. The overall effect: nodes can reconnect, observe the network’s latest heads, request missing ranges, and replay into their ledger state. diff --git a/src/main/kotlin/cash/atto/node/DuplicateDetector.kt b/src/main/kotlin/cash/atto/node/DuplicateDetector.kt index a8108e18..198671e1 100644 --- a/src/main/kotlin/cash/atto/node/DuplicateDetector.kt +++ b/src/main/kotlin/cash/atto/node/DuplicateDetector.kt @@ -16,6 +16,10 @@ class DuplicateDetector( fun isDuplicate(t: T): Boolean = cache.putIfAbsent(t, t) != null + fun refresh(t: T) { + cache[t] = t + } + fun remove(t: T) { cache.remove(t) } diff --git a/src/main/kotlin/cash/atto/node/NodeConfiguration.kt b/src/main/kotlin/cash/atto/node/NodeConfiguration.kt index 3930ab1c..fa80e1a8 100644 --- a/src/main/kotlin/cash/atto/node/NodeConfiguration.kt +++ b/src/main/kotlin/cash/atto/node/NodeConfiguration.kt @@ -44,7 +44,7 @@ class NodeConfiguration( return AttoNode( network = nodeProperties.network!!, - protocolVersion = 0u, + protocolVersion = AttoNode.CURRENT_PROTOCOL_VERSION, algorithm = AttoAlgorithm.V1, publicKey = signer.publicKey, publicUri = URI(nodeProperties.publicUri!!), diff --git a/src/main/kotlin/cash/atto/node/bootstrap/discovery/DependencyDiscoverer.kt b/src/main/kotlin/cash/atto/node/bootstrap/discovery/DependencyDiscoverer.kt index 500d48f3..75ed7a4f 100644 --- a/src/main/kotlin/cash/atto/node/bootstrap/discovery/DependencyDiscoverer.kt +++ b/src/main/kotlin/cash/atto/node/bootstrap/discovery/DependencyDiscoverer.kt @@ -4,7 +4,6 @@ import cash.atto.commons.AttoAmount import cash.atto.commons.AttoHash import cash.atto.commons.AttoPublicKey import cash.atto.node.CacheSupport -import cash.atto.node.EventPublisher import cash.atto.node.bootstrap.TransactionDiscovered import cash.atto.node.transaction.Transaction import cash.atto.node.transaction.TransactionRejected @@ -24,7 +23,7 @@ import java.time.Duration @Component class DependencyDiscoverer( private val voteWeighter: VoteWeighter, - private val eventPublisher: EventPublisher, + private val discoveryQueue: DiscoveryQueue, ) : CacheSupport { private val logger = KotlinLogging.logger {} @@ -102,19 +101,20 @@ class DependencyDiscoverer( val weight = holder.getWeight() val minimalConfirmationWeight = voteWeighter.getMinimalConfirmationWeight() - if (weight >= minimalConfirmationWeight) { - transactionHolderMap.remove(hash) - logger.debug { "Discovered approved transaction that's missing some dependency $hash" } - eventPublisher.publish( - TransactionDiscovered( - holder.reason, - holder.transaction, - holder.votes.values.toList(), - ), - ) + if (weight < minimalConfirmationWeight) { + return } - return + logger.debug { "Discovered approved transaction that's missing some dependency $hash" } + discoveryQueue.queue( + TransactionDiscovered( + holder.reason, + holder.transaction, + holder.votes.values.toList(), + ), + DiscoverySource.DEPENDENCY, + ) + transactionHolderMap.remove(hash) } override fun clear() { diff --git a/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryMetrics.kt b/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryMetrics.kt new file mode 100644 index 00000000..dbb10647 --- /dev/null +++ b/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryMetrics.kt @@ -0,0 +1,118 @@ +package cash.atto.node.bootstrap.discovery + +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.DistributionSummary +import io.micrometer.core.instrument.Gauge +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Timer +import org.springframework.stereotype.Component +import java.time.Duration +import java.util.EnumMap + +@Component +class DiscoveryMetrics( + private val meterRegistry: MeterRegistry, +) { + private val admittedCounters = EnumMap(DiscoverySource::class.java) + private val persistedCounters = EnumMap(DiscoverySource::class.java) + private val persistenceFailureCounter: Counter + private val affectedRowsCounter: Counter + private val batchTimer: Timer + private val batchSizeSummary: DistributionSummary + private val queueWaitTimer: Timer + + init { + DiscoverySource.entries.forEach { source -> + admittedCounters[source] = + Counter + .builder("transactions.discovery.admitted") + .description("Discovered transactions admitted for unchecked persistence") + .tag("source", source.metricTag) + .register(meterRegistry) + persistedCounters[source] = + Counter + .builder("transactions.discovery.persisted") + .description("Discovered transactions committed to unchecked persistence") + .tag("source", source.metricTag) + .register(meterRegistry) + } + persistenceFailureCounter = + Counter + .builder("transactions.discovery.persistence.failures") + .description("Unchecked discovery persistence failures") + .register(meterRegistry) + affectedRowsCounter = + Counter + .builder("transactions.discovery.affected") + .description("Rows affected by unchecked discovery persistence") + .register(meterRegistry) + batchTimer = + Timer + .builder("transactions.discovery.batch") + .description("Time spent attempting an unchecked discovery persistence batch") + .register(meterRegistry) + batchSizeSummary = + DistributionSummary + .builder("transactions.discovery.batch.size") + .description("Discovered transactions attempted per persistence batch") + .register(meterRegistry) + queueWaitTimer = + Timer + .builder("transactions.discovery.queue.wait") + .description("Time a discovered transaction waited before entering a persistence batch") + .register(meterRegistry) + } + + internal fun bind(queue: DiscoveryQueue) { + Gauge + .builder("transactions.discovery.backlog.depth", queue) { + it.getBacklogDepth().toDouble() + }.description("Discovered transactions waiting to enter a persistence batch") + .register(meterRegistry) + Gauge + .builder("transactions.discovery.backlog.overshoot", queue) { + it.getBacklogOvershoot().toDouble() + }.description("Queued and suspended discoveries above the target capacity") + .register(meterRegistry) + Gauge + .builder("transactions.discovery.at.capacity", queue) { + if (it.isAtCapacity()) 1.0 else 0.0 + }.description("Whether new discovery should pause") + .register(meterRegistry) + } + + internal fun admitted(source: DiscoverySource) { + admittedCounters.getValue(source).increment() + } + + internal fun persisted(batch: List) { + batch + .groupingBy { it.source } + .eachCount() + .forEach { (source, count) -> + persistedCounters.getValue(source).increment(count.toDouble()) + } + } + + internal fun persistenceFailed() { + persistenceFailureCounter.increment() + } + + internal fun dequeued(wait: Duration) { + queueWaitTimer.record(if (wait.isNegative) Duration.ZERO else wait) + } + + internal fun affectedRows(count: Long) { + affectedRowsCounter.increment(count.toDouble()) + } + + internal fun startBatch(size: Int): Timer.Sample { + batchSizeSummary.record(size.toDouble()) + return Timer.start(meterRegistry) + } + + internal fun stopBatch(sample: Timer.Sample): Duration = + Duration.ofNanos( + sample.stop(batchTimer), + ) +} diff --git a/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryPersistenceWorker.kt b/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryPersistenceWorker.kt new file mode 100644 index 00000000..7ac5380c --- /dev/null +++ b/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryPersistenceWorker.kt @@ -0,0 +1,105 @@ +package cash.atto.node.bootstrap.discovery + +import cash.atto.node.CacheSupport +import cash.atto.node.bootstrap.unchecked.UncheckedTransactionService +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.concurrent.TimeUnit + +@Component +class DiscoveryPersistenceWorker( + private val uncheckedTransactionService: UncheckedTransactionService, + private val queue: DiscoveryQueue, + private val properties: DiscoveryProperties, + private val metrics: DiscoveryMetrics, + private val clock: Clock, +) : CacheSupport { + private val logger = KotlinLogging.logger {} + private val flushMutex = Mutex() + + private var nextRetryAt = Instant.EPOCH + private var retryBackoffInSeconds = properties.retryInitialBackoffInSeconds + private var retryBatch: List? = null + + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.SECONDS) + suspend fun flush() { + if (!flushMutex.tryLock()) { + return + } + + try { + if (clock.instant().isBefore(nextRetryAt)) { + return + } + + while (true) { + val batch = retryBatch ?: queue.takeBatch() + if (batch.isEmpty()) { + return + } + retryBatch = batch + + val sample = metrics.startBatch(batch.size) + val affectedRows = + try { + uncheckedTransactionService.save(batch.map { it.transaction }) + } catch (e: CancellationException) { + metrics.stopBatch(sample) + throw e + } catch (e: Exception) { + metrics.stopBatch(sample) + metrics.persistenceFailed() + scheduleRetry() + logger.warn(e) { + "Failed to save ${batch.size} unchecked transactions. Retrying the same batch after " + + "${Duration.between(clock.instant(), nextRetryAt)}" + } + return + } + + val elapsed = metrics.stopBatch(sample) + queue.acknowledge(batch) + retryBatch = null + metrics.persisted(batch) + metrics.affectedRows(affectedRows) + resetRetry() + logger.info { "Saved ${batch.size} unchecked transactions in $elapsed" } + } + } finally { + flushMutex.unlock() + } + } + + override fun clear() { + runBlocking { + flushMutex.withLock { + retryBatch = null + queue.reset() + resetRetry() + } + } + } + + private fun scheduleRetry() { + nextRetryAt = clock.instant().plusSeconds(retryBackoffInSeconds) + retryBackoffInSeconds = + if (retryBackoffInSeconds >= properties.retryMaxBackoffInSeconds / 2) { + properties.retryMaxBackoffInSeconds + } else { + minOf(retryBackoffInSeconds * 2, properties.retryMaxBackoffInSeconds) + } + } + + private fun resetRetry() { + nextRetryAt = Instant.EPOCH + retryBackoffInSeconds = properties.retryInitialBackoffInSeconds + } +} diff --git a/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryProcessor.kt b/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryProcessor.kt deleted file mode 100644 index 604d7c41..00000000 --- a/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryProcessor.kt +++ /dev/null @@ -1,71 +0,0 @@ -package cash.atto.node.bootstrap.discovery - -import cash.atto.commons.AttoHash -import cash.atto.node.CacheSupport -import cash.atto.node.DuplicateDetector -import cash.atto.node.bootstrap.TransactionDiscovered -import cash.atto.node.bootstrap.unchecked.UncheckedTransaction -import cash.atto.node.bootstrap.unchecked.UncheckedTransactionService -import cash.atto.node.bootstrap.unchecked.toUncheckedTransaction -import io.github.oshai.kotlinlogging.KotlinLogging -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.springframework.context.event.EventListener -import org.springframework.scheduling.annotation.Scheduled -import org.springframework.stereotype.Component -import java.util.concurrent.TimeUnit -import kotlin.time.Duration.Companion.minutes -import kotlin.time.measureTime - -@Component -class DiscoveryProcessor( - val uncheckedTransactionService: UncheckedTransactionService, -) : CacheSupport { - private val logger = KotlinLogging.logger {} - - private val mutex = Mutex() - private val duplicateDetector = DuplicateDetector(10.minutes) - private val buffer = Channel(Channel.UNLIMITED) - - override fun clear() { - duplicateDetector.clear() - do { - val transaction = buffer.tryReceive().getOrNull() - } while (transaction != null) - } - - @EventListener - suspend fun process(event: TransactionDiscovered) { - if (duplicateDetector.isDuplicate(event.transaction.hash)) { - logger.trace { "Ignoring duplicate transaction ${event.transaction.hash}" } - return - } - buffer.send(event.transaction.toUncheckedTransaction()) - } - - @Scheduled(fixedRate = 1, timeUnit = TimeUnit.SECONDS) - suspend fun flush() { - if (mutex.isLocked) { - return - } - mutex.withLock { - do { - val batch = mutableListOf() - - do { - val transaction = buffer.tryReceive().getOrNull() - transaction?.let { batch.add(it) } - } while (batch.size < 1000 && transaction != null) - - if (batch.isNotEmpty()) { - val elapsed = - measureTime { - uncheckedTransactionService.save(batch) - } - logger.info { "Saved ${batch.size} unchecked transactions in $elapsed" } - } - } while (batch.isNotEmpty()) - } - } -} diff --git a/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryProperties.kt b/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryProperties.kt new file mode 100644 index 00000000..caf0f9cb --- /dev/null +++ b/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryProperties.kt @@ -0,0 +1,45 @@ +package cash.atto.node.bootstrap.discovery + +import jakarta.annotation.PostConstruct +import kotlinx.coroutines.channels.Channel +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@ConfigurationProperties(prefix = "atto.bootstrap.discovery") +class DiscoveryProperties { + var capacity: Int = 10_000 + var headroom: Int = 2_000 + var batchSize: Int = 1_000 + var idleQueryFallbackInSeconds: Long = 30 + var retryInitialBackoffInSeconds: Long = 1 + var retryMaxBackoffInSeconds: Long = 30 + + @PostConstruct + fun validate() { + require(capacity > 0) { "Discovery capacity must be positive" } + require(headroom >= 0) { "Discovery headroom must be non-negative" } + require(capacity <= Int.MAX_VALUE - headroom) { + "Discovery capacity plus headroom exceeds the supported channel size" + } + require(capacity + headroom < Channel.UNLIMITED) { + "Discovery capacity plus headroom must be a bounded channel size" + } + require(batchSize in 1..minOf(capacity, MAX_BATCH_SIZE)) { + "Discovery batch size must be positive and no greater than capacity or $MAX_BATCH_SIZE" + } + require(idleQueryFallbackInSeconds > 0) { + "Discovery idle query fallback must be positive" + } + require(retryInitialBackoffInSeconds > 0) { + "Discovery retry initial backoff must be positive" + } + require(retryMaxBackoffInSeconds >= retryInitialBackoffInSeconds) { + "Discovery retry max backoff must be greater than or equal to initial backoff" + } + } + + private companion object { + const val MAX_BATCH_SIZE = 1_000 + } +} diff --git a/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryQueue.kt b/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryQueue.kt new file mode 100644 index 00000000..aef91c50 --- /dev/null +++ b/src/main/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryQueue.kt @@ -0,0 +1,112 @@ +package cash.atto.node.bootstrap.discovery + +import cash.atto.commons.AttoHash +import cash.atto.node.DuplicateDetector +import cash.atto.node.EventPublisher +import cash.atto.node.bootstrap.TransactionDiscovered +import cash.atto.node.bootstrap.unchecked.toUncheckedTransaction +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.annotation.PostConstruct +import kotlinx.coroutines.channels.Channel +import org.springframework.stereotype.Component +import java.time.Clock +import java.time.Duration +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.minutes + +@Component +class DiscoveryQueue( + private val eventPublisher: EventPublisher, + private val properties: DiscoveryProperties, + private val metrics: DiscoveryMetrics, + private val clock: Clock, +) { + private val logger = KotlinLogging.logger {} + private val duplicateDetector = DuplicateDetector(10.minutes) + private val queued = AtomicInteger() + private val buffer = + Channel( + capacity = properties.capacity + properties.headroom, + onUndeliveredElement = ::discard, + ) + + @PostConstruct + fun start() { + metrics.bind(this) + } + + /** + * Keeps an already-discovered transaction until it can enter the bounded + * persistence buffer. New discovery should stop when [isAtCapacity] is true, + * while replies already in flight are allowed to suspend here. + */ + suspend fun queue( + event: TransactionDiscovered, + source: DiscoverySource, + ): Boolean { + val transaction = event.transaction.toUncheckedTransaction() + val hash = transaction.hash + val discovery = + PendingDiscovery( + transaction = transaction, + source = source, + enqueuedAt = clock.instant(), + ) + + if (duplicateDetector.isDuplicate(hash)) { + return false + } + + queued.incrementAndGet() + buffer.send(discovery) + + metrics.admitted(source) + try { + eventPublisher.publish(event) + } catch (e: Exception) { + logger.error(e) { + "Transaction $hash entered the persistence buffer, but its discovery event could not be published" + } + } + return true + } + + fun isAtCapacity(): Boolean = queued.get() >= properties.capacity + + fun remainingTargetCapacity(): Int = maxOf(0, properties.capacity - queued.get()) + + internal fun takeBatch(): List = + buildList(properties.batchSize) { + while (size < properties.batchSize) { + val discovery = buffer.tryReceive().getOrNull() ?: break + check(queued.decrementAndGet() >= 0) { + "Discovery queue depth became negative" + } + metrics.dequeued(Duration.between(discovery.enqueuedAt, clock.instant())) + add(discovery) + } + } + + internal fun acknowledge(batch: List) { + batch.forEach { discovery -> + duplicateDetector.refresh(discovery.transaction.hash) + } + } + + internal fun reset() { + while (true) { + buffer.tryReceive().getOrNull() ?: break + queued.decrementAndGet() + } + duplicateDetector.clear() + } + + internal fun getBacklogDepth(): Int = queued.get() + + internal fun getBacklogOvershoot(): Int = maxOf(0, queued.get() - properties.capacity) + + private fun discard(discovery: PendingDiscovery) { + queued.decrementAndGet() + duplicateDetector.remove(discovery.transaction.hash) + } +} diff --git a/src/main/kotlin/cash/atto/node/bootstrap/discovery/GapDiscoverer.kt b/src/main/kotlin/cash/atto/node/bootstrap/discovery/GapDiscoverer.kt index 9afdc846..de9c9de7 100644 --- a/src/main/kotlin/cash/atto/node/bootstrap/discovery/GapDiscoverer.kt +++ b/src/main/kotlin/cash/atto/node/bootstrap/discovery/GapDiscoverer.kt @@ -1,33 +1,31 @@ package cash.atto.node.bootstrap.discovery -import cash.atto.commons.AttoHash import cash.atto.commons.AttoHeight import cash.atto.commons.AttoPublicKey -import cash.atto.commons.AttoTransaction -import cash.atto.commons.PreviousSupport import cash.atto.node.CacheSupport -import cash.atto.node.EventPublisher -import cash.atto.node.bootstrap.TransactionDiscovered -import cash.atto.node.bootstrap.UncheckedTransactionSaved import cash.atto.node.bootstrap.unchecked.GapView import cash.atto.node.bootstrap.unchecked.UncheckedTransactionRepository +import cash.atto.node.bootstrap.unchecked.UncheckedWorkTracker import cash.atto.node.network.DirectNetworkMessage import cash.atto.node.network.InboundNetworkMessage import cash.atto.node.network.NetworkMessagePublisher import cash.atto.node.network.NodeConnected import cash.atto.node.network.NodeDisconnected -import cash.atto.node.transaction.toTransaction +import cash.atto.protocol.AttoNode import cash.atto.protocol.AttoTransactionStreamRequest import cash.atto.protocol.AttoTransactionStreamResponse -import com.github.benmanes.caffeine.cache.Caffeine -import com.github.benmanes.caffeine.cache.Scheduler import io.github.oshai.kotlinlogging.KotlinLogging +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Timer +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.springframework.context.event.EventListener import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component import java.net.URI +import java.time.Clock import java.time.Duration import java.time.Instant import java.util.concurrent.ConcurrentHashMap @@ -38,40 +36,38 @@ import kotlin.random.Random class GapDiscoverer( private val uncheckedTransactionRepository: UncheckedTransactionRepository, private val networkMessagePublisher: NetworkMessagePublisher, - private val eventPublisher: EventPublisher, + private val discoveryQueue: DiscoveryQueue, + private val workTracker: UncheckedWorkTracker, + private val discoveryProperties: DiscoveryProperties, + meterRegistry: MeterRegistry, + private val clock: Clock, ) : CacheSupport { private val logger = KotlinLogging.logger {} - - private val peers = ConcurrentHashMap.newKeySet() - - private val mutex = Mutex() - - private val maxSize = 1_000L - private val pointerMap = - Caffeine - .newBuilder() - .scheduler(Scheduler.systemScheduler()) - .expireAfterAccess(Duration.ofMinutes(1)) - .maximumSize(maxSize) - .build() - .asMap() - - private val lastCompletedGaps = - Caffeine - .newBuilder() - .scheduler(Scheduler.systemScheduler()) - .expireAfterWrite(Duration.ofMinutes(2)) - .build() - .asMap() - - private val outOfOrderBuffer = - Caffeine - .newBuilder() - .scheduler(Scheduler.systemScheduler()) - .expireAfterWrite(Duration.ofMinutes(1)) - .maximumSize(maxSize * AttoTransactionStreamRequest.MAX_TRANSACTIONS.toLong()) - .build>() - .asMap() + private val resolveMutex = Mutex() + private val peers = ConcurrentHashMap() + private val activeSessions = ConcurrentHashMap() + private val requestBudget = + minOf( + AttoTransactionStreamRequest.MAX_TRANSACTIONS, + discoveryProperties.capacity.toULong(), + ).toInt() + + private val gapQueryTimer = + Timer + .builder("transactions.unchecked.gap.query") + .description("Time finding missing unchecked transaction ranges") + .register(meterRegistry) + private val gapRows = + Counter + .builder("transactions.unchecked.gap.rows") + .description("Missing unchecked transaction ranges returned") + .register(meterRegistry) + + @Volatile + private var idleGeneration = Long.MIN_VALUE + + @Volatile + private var nextMaintenanceAt = Instant.EPOCH @EventListener fun add(nodeEvent: NodeConnected) { @@ -79,158 +75,203 @@ class GapDiscoverer( if (!node.isHistorical()) { return } - peers.add(node.publicUri) - } - @EventListener - fun remove(nodeEvent: NodeDisconnected) { - val node = nodeEvent.node - peers.remove(node.publicUri) + peers[node.publicUri] = node + resetIdleSuppression() } @EventListener - fun process(event: UncheckedTransactionSaved) { - if (lastCompletedGaps.remove(event.transaction.publicKey, event.transaction.block.height)) { - logger.trace { - "Removed last completed gap for account ${event.transaction.publicKey} with height ${event.transaction.block.height}" - } - } + fun remove(nodeEvent: NodeDisconnected) { + val publicUri = nodeEvent.node.publicUri + peers.remove(publicUri) + activeSessions.values + .filter { it.peer == publicUri } + .forEach { activeSessions.remove(it.publicKey, it) } } @Scheduled(fixedRate = 1, timeUnit = TimeUnit.SECONDS) suspend fun resolve() { - if (mutex.isLocked) { + if (resolveMutex.isLocked) { return } - mutex.withLock { - val peers = peers.toList() - - if (peers.isEmpty()) { - return - } - val limit = maxSize - pointerMap.size + resolveMutex.withLock { + retryActiveSessions() - if (limit <= 0L) { - logger.debug { "Skipping gap discovery. Pointer map size is $maxSize" } + val slots = availableSessionSlots() + if (slots == 0 || shouldSkipIdleScan()) { return } - if (pointerMap.isNotEmpty() || lastCompletedGaps.isNotEmpty()) { - logger.trace { - "Pointer map size is ${pointerMap.size} and last completed gap is ${lastCompletedGaps.size}. Looking for $limit more gaps" + val startingGeneration = workTracker.currentGeneration() + val queryStartedAt = System.nanoTime() + val gaps = + try { + uncheckedTransactionRepository + .findGaps(slots + activeSessions.size) + .toList() + } finally { + gapQueryTimer.record(System.nanoTime() - queryStartedAt, TimeUnit.NANOSECONDS) } - } - val publicKeyToExclude = - (pointerMap.keys + lastCompletedGaps.keys) - .ifEmpty { setOf(AttoPublicKey(ByteArray(32))) } - - val gaps = uncheckedTransactionRepository.findGaps(publicKeyToExclude, limit) - - gaps.collect { view -> - pointerMap.computeIfAbsent(view.publicKey) { - val startHeight = view.startHeight() - val endHeight = view.endHeight() - val request = AttoTransactionStreamRequest(view.publicKey, startHeight, endHeight) - val message = - DirectNetworkMessage( - peers[Random.nextInt(peers.size)], - request, - expectedResponseCount = endHeight.value - startHeight.value + 1UL, - ) - networkMessagePublisher.publish(message) - val pointer = TransactionPointer(view.publicKey, startHeight, endHeight, endHeight, view.expectedEndHash) - logger.trace { - "Starting gap discovery for account ${view.publicKey}. Requesting transactions from $startHeight to $endHeight" - } - pointer - } + if (gaps.isNotEmpty()) { + gapRows.increment(gaps.size.toDouble()) + gaps + .asSequence() + .filterNot { activeSessions.containsKey(it.publicKey) } + .take(slots) + .forEach { startGapRequest(it) } } + + updateIdleSuppression(gaps.isNotEmpty(), startingGeneration) } } @EventListener - fun process(message: InboundNetworkMessage) { - val response = message.payload - val transaction = response.transaction - val block = transaction.block - - val pointer = - pointerMap.computeIfPresent(block.publicKey) { _, pointer -> - if (pointer.currentHeight != block.height && block.height in pointer.initialHeight..) { + val publicKey = message.payload.transaction.block.publicKey + val session = activeSessions[publicKey] ?: return + closeIfFinished(session, session.offer(message)) + } + + override fun clear() { + activeSessions.clear() + resetIdleSuppression() + } + + internal fun activeSessionCount(): Int = activeSessions.size + + private suspend fun retryActiveSessions() { + activeSessions.values.toList().forEach { session -> + if (session.isExpired(clock.instant(), SESSION_TIMEOUT)) { + activeSessions.remove(session.publicKey, session) + } else { + closeIfFinished(session, session.retry()) } + } + } - if (pointer == null) { - return + private fun availableSessionSlots(): Int { + if (peers.isEmpty()) { + return 0 + } + + val queueSlots = + maxOf( + 0, + discoveryQueue.remainingTargetCapacity() / requestBudget - activeSessions.size, + ) + if (queueSlots == 0) { + return 0 } - val nextMessage = outOfOrderBuffer.remove(pointer.currentHash) ?: return - process(nextMessage) + val sessionsByPeer = activeSessions.values.groupingBy(GapSession::peer).eachCount() + val peerSlots = + peers.values.sumOf { peer -> + maxOf(0, peer.parallelStreamLimit() - (sessionsByPeer[peer.publicUri] ?: 0)) + } + return minOf(queueSlots, peerSlots) } - private fun process( - pointer: TransactionPointer, - transaction: AttoTransaction, - ): TransactionPointer? { - if (transaction.hash != pointer.currentHash) { - logger.debug { "Expecting transaction with hash ${pointer.currentHash} but received hash $transaction" } - return null + private fun startGapRequest(view: GapView) { + val selectedPeer = selectPeer() ?: return + val startHeight = view.startHeight(requestBudget.toULong()) + val session = + GapSession( + publicKey = view.publicKey, + peer = selectedPeer, + startHeight = startHeight, + endHeight = view.endHeight, + initialExpectedHash = view.expectedEndHash, + discoveryQueue = discoveryQueue, + clock = clock, + ) + if (activeSessions.putIfAbsent(view.publicKey, session) != null) { + return + } + if (!peers.containsKey(selectedPeer)) { + activeSessions.remove(view.publicKey, session) + return } - val block = transaction.block - - val nextPointer = - if (pointer.initialHeight == block.height) { - logger.debug { "End of the gap reached for account ${transaction.block.publicKey}" } - null - } else { - pointer.copy( - currentHeight = block.height - 1UL, - currentHash = (block as PreviousSupport).previous, - timestamp = Instant.now(), - ) + val message = + DirectNetworkMessage( + selectedPeer, + AttoTransactionStreamRequest(view.publicKey, startHeight, view.endHeight), + expectedResponseCount = view.endHeight.value - startHeight.value + 1UL, + ) + try { + networkMessagePublisher.publish(message) + logger.trace { + "Starting gap discovery for account ${view.publicKey}. " + + "Requesting transactions from $startHeight to ${view.endHeight}" } + } catch (e: Exception) { + activeSessions.remove(view.publicKey, session) + throw e + } + } - logger.debug { "Discovered gap transaction ${transaction.hash} with height ${block.height}. New pointer: $nextPointer" } + private fun selectPeer(): URI? { + val sessionsByPeer = activeSessions.values.groupingBy(GapSession::peer).eachCount() + val candidates = + peers.values + .mapNotNull { peer -> + val count = sessionsByPeer[peer.publicUri] ?: 0 + if (count < peer.parallelStreamLimit()) peer.publicUri to count else null + } + val minimumCount = candidates.minOfOrNull { it.second } ?: return null + val leastLoaded = candidates.filter { it.second == minimumCount } + return leastLoaded[Random.nextInt(leastLoaded.size)].first + } - lastCompletedGaps[pointer.publicKey] = block.height + private fun closeIfFinished( + session: GapSession, + status: GapSessionStatus, + ) { + if (status != GapSessionStatus.ACTIVE) { + activeSessions.remove(session.publicKey, session) + } + } - eventPublisher.publish(TransactionDiscovered(null, transaction.toTransaction(), listOf())) + private fun updateIdleSuppression( + foundGaps: Boolean, + startingGeneration: Long, + ) { + val endingGeneration = workTracker.currentGeneration() + if (!foundGaps && startingGeneration == endingGeneration) { + idleGeneration = endingGeneration + nextMaintenanceAt = clock.instant().plusSeconds(discoveryProperties.idleQueryFallbackInSeconds) + } else { + resetIdleSuppression() + } + } - return nextPointer + private fun resetIdleSuppression() { + idleGeneration = Long.MIN_VALUE + nextMaintenanceAt = Instant.EPOCH } - override fun clear() { - pointerMap.clear() - lastCompletedGaps.clear() - outOfOrderBuffer.clear() + private fun shouldSkipIdleScan(): Boolean = + workTracker.currentGeneration() == idleGeneration && + clock.instant().isBefore(nextMaintenanceAt) + + private fun AttoNode.parallelStreamLimit(): Int = + if (supportsParallelTransactionStreams()) { + AttoTransactionStreamRequest.MAX_PARALLEL_STREAMS + } else { + 1 + } + + private companion object { + val SESSION_TIMEOUT: Duration = Duration.ofMinutes(1) } } -private data class TransactionPointer( - val publicKey: AttoPublicKey, - val initialHeight: AttoHeight, - val finalHeight: AttoHeight, - val currentHeight: AttoHeight, - val currentHash: AttoHash, - val timestamp: Instant = Instant.now(), -) - -private fun GapView.startHeight(): AttoHeight { - val maxCount = AttoTransactionStreamRequest.MAX_TRANSACTIONS - val count = this.endHeight - this.startHeight + 1U - if (count.value > maxCount) { - return this.endHeight - maxCount + 1U +private fun GapView.startHeight(requestBudget: ULong): AttoHeight { + val count = endHeight - startHeight + 1U + if (count.value > requestBudget) { + return endHeight - requestBudget + 1U } - return this.startHeight + return startHeight } - -private fun GapView.endHeight(): AttoHeight = this.endHeight diff --git a/src/main/kotlin/cash/atto/node/bootstrap/discovery/GapSession.kt b/src/main/kotlin/cash/atto/node/bootstrap/discovery/GapSession.kt new file mode 100644 index 00000000..fd0e98dc --- /dev/null +++ b/src/main/kotlin/cash/atto/node/bootstrap/discovery/GapSession.kt @@ -0,0 +1,144 @@ +package cash.atto.node.bootstrap.discovery + +import cash.atto.commons.AttoHash +import cash.atto.commons.AttoHeight +import cash.atto.commons.AttoPublicKey +import cash.atto.commons.PreviousSupport +import cash.atto.node.bootstrap.TransactionDiscovered +import cash.atto.node.network.InboundNetworkMessage +import cash.atto.node.transaction.toTransaction +import cash.atto.protocol.AttoTransactionStreamResponse +import kotlinx.coroutines.sync.Mutex +import java.net.URI +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference + +internal class GapSession( + val publicKey: AttoPublicKey, + val peer: URI, + val startHeight: AttoHeight, + val endHeight: AttoHeight, + val initialExpectedHash: AttoHash, + private val discoveryQueue: DiscoveryQueue, + private val clock: Clock, +) { + private val drainMutex = Mutex() + private val responses = ConcurrentHashMap>() + private val progress = AtomicReference(Progress.Active(endHeight, initialExpectedHash)) + + @Volatile + private var lastSuccessfulProgressAt: Instant = clock.instant() + + suspend fun offer(message: InboundNetworkMessage): GapSessionStatus { + val block = message.payload.transaction.block + val current = progress.get() + if ( + current !is Progress.Active || + message.publicUri != peer || + block.publicKey != publicKey || + block.height !in startHeight..endHeight || + block.height > current.expectedHeight + ) { + return status() + } + + val previous = responses.putIfAbsent(block.height, message) + val latest = progress.get() + if ( + previous == null && + (latest !is Progress.Active || block.height > latest.expectedHeight) + ) { + responses.remove(block.height, message) + return status() + } + + return drain() + } + + suspend fun retry(): GapSessionStatus = drain() + + fun isExpired( + now: Instant, + timeout: Duration, + ): Boolean = + !drainMutex.isLocked && + progress.get() is Progress.Active && + !lastSuccessfulProgressAt.isAfter(now.minus(timeout)) + + internal fun bufferedResponseCount(): Int = responses.size + + internal fun status(): GapSessionStatus = + when (progress.get()) { + is Progress.Active -> GapSessionStatus.ACTIVE + Progress.Completed -> GapSessionStatus.COMPLETED + Progress.Invalid -> GapSessionStatus.INVALID + } + + private suspend fun drain(): GapSessionStatus { + if (!drainMutex.tryLock()) { + return status() + } + + try { + while (true) { + val current = progress.get() + if (current !is Progress.Active) { + return status() + } + + val message = responses[current.expectedHeight] ?: return GapSessionStatus.ACTIVE + val transaction = message.payload.transaction + if (transaction.hash != current.expectedHash) { + progress.set(Progress.Invalid) + responses.clear() + return GapSessionStatus.INVALID + } + + discoveryQueue.queue( + TransactionDiscovered(null, transaction.toTransaction(), emptyList()), + DiscoverySource.GAP, + ) + + val next = + if (current.expectedHeight == startHeight) { + Progress.Completed + } else { + Progress.Active( + current.expectedHeight - 1UL, + (transaction.block as PreviousSupport).previous, + ) + } + progress.set(next) + lastSuccessfulProgressAt = clock.instant() + responses.remove(current.expectedHeight, message) + + if (next == Progress.Completed) { + responses.clear() + return GapSessionStatus.COMPLETED + } + } + } finally { + drainMutex.unlock() + } + } + + private sealed interface Progress { + data class Active( + val expectedHeight: AttoHeight, + val expectedHash: AttoHash, + ) : Progress + + data object Completed : Progress + + data object Invalid : Progress + } +} + +internal enum class GapSessionStatus { + ACTIVE, + COMPLETED, + INVALID, +} diff --git a/src/main/kotlin/cash/atto/node/bootstrap/discovery/LastDiscoverer.kt b/src/main/kotlin/cash/atto/node/bootstrap/discovery/LastDiscoverer.kt index c7298635..913dddbb 100644 --- a/src/main/kotlin/cash/atto/node/bootstrap/discovery/LastDiscoverer.kt +++ b/src/main/kotlin/cash/atto/node/bootstrap/discovery/LastDiscoverer.kt @@ -53,6 +53,7 @@ class LastDiscoverer( private val nodeConnectionManager: NodeConnectionManager, private val networkMessagePublisher: NetworkMessagePublisher, private val eventPublisher: EventPublisher, + private val discoveryQueue: DiscoveryQueue, private val voteConverter: VoteConverter, private val voteWeighter: VoteWeighter, ) : CacheSupport { @@ -68,7 +69,7 @@ class LastDiscoverer( .maximumSize(100_000) .removalListener { _: AttoHash?, election: TransactionElection?, cause -> val retryThreshold = voteWeighter.getMinimalToStaleWeight() - if (election != null && election.totalWeight >= retryThreshold) { + if (cause.wasEvicted() && election != null && election.totalWeight >= retryThreshold) { scope.launch { startElection(election.transaction) } } }.build() @@ -134,6 +135,10 @@ class LastDiscoverer( return } + if (discoveryQueue.isAtCapacity()) { + return + } + transactionElectionMap.compute(transaction.hash) { _, existing -> existing ?: TransactionElection(transaction) { voteWeighter.getMinimalConfirmationWeight() @@ -169,21 +174,31 @@ class LastDiscoverer( return } + var admission: PendingHeadAdmission? = null transactionElectionMap.computeIfPresent(blockHash) { _, election -> election.add(vote) if (election.isConsensusReached()) { logger.debug { "Discovered missing last transaction $blockHash" } - - val request = AttoVoteStreamCancel(blockHash) - networkMessagePublisher.publish(DirectNetworkMessage(message.publicUri, request)) - eventPublisher.publish(TransactionDiscovered(null, election.transaction, election.votes.values)) - - null + admission = + PendingHeadAdmission( + election, + TransactionDiscovered(null, election.transaction, election.votes.values.toList()), + ) } else { logger.trace { "Consensus not reached yet. Added to election queue" } - - election } + election + } + + val pending = admission ?: return + discoveryQueue.queue(pending.event, DiscoverySource.HEAD) + if (transactionElectionMap.remove(blockHash, pending.election)) { + networkMessagePublisher.publish( + DirectNetworkMessage( + message.publicUri, + AttoVoteStreamCancel(blockHash), + ), + ) } } @@ -191,3 +206,8 @@ class LastDiscoverer( eventPublisher.publish(TransactionReceived(transaction)) } } + +private data class PendingHeadAdmission( + val election: TransactionElection, + val event: TransactionDiscovered, +) diff --git a/src/main/kotlin/cash/atto/node/bootstrap/discovery/PendingDiscovery.kt b/src/main/kotlin/cash/atto/node/bootstrap/discovery/PendingDiscovery.kt new file mode 100644 index 00000000..dcd0d113 --- /dev/null +++ b/src/main/kotlin/cash/atto/node/bootstrap/discovery/PendingDiscovery.kt @@ -0,0 +1,20 @@ +package cash.atto.node.bootstrap.discovery + +import cash.atto.node.bootstrap.unchecked.UncheckedTransaction +import java.time.Instant + +enum class DiscoverySource { + GAP, + HEAD, + DEPENDENCY, + SEND, +} + +internal data class PendingDiscovery( + val transaction: UncheckedTransaction, + val source: DiscoverySource, + val enqueuedAt: Instant, +) + +internal val DiscoverySource.metricTag: String + get() = name.lowercase() diff --git a/src/main/kotlin/cash/atto/node/bootstrap/discovery/SendDiscoverer.kt b/src/main/kotlin/cash/atto/node/bootstrap/discovery/SendDiscoverer.kt index 9f13d687..aed2ccaa 100644 --- a/src/main/kotlin/cash/atto/node/bootstrap/discovery/SendDiscoverer.kt +++ b/src/main/kotlin/cash/atto/node/bootstrap/discovery/SendDiscoverer.kt @@ -5,9 +5,9 @@ import cash.atto.commons.AttoPublicKey import cash.atto.commons.ReceiveSupport import cash.atto.node.CacheSupport import cash.atto.node.DuplicateDetector -import cash.atto.node.EventPublisher import cash.atto.node.bootstrap.TransactionDiscovered import cash.atto.node.bootstrap.TransactionStuck +import cash.atto.node.bootstrap.unchecked.UncheckedTransactionRepository import cash.atto.node.network.BroadcastNetworkMessage import cash.atto.node.network.BroadcastStrategy import cash.atto.node.network.DirectNetworkMessage @@ -24,6 +24,8 @@ import cash.atto.protocol.AttoTransactionResponse import com.github.benmanes.caffeine.cache.Caffeine import com.github.benmanes.caffeine.cache.Scheduler import io.github.oshai.kotlinlogging.KotlinLogging +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry import org.springframework.context.event.EventListener import org.springframework.stereotype.Component import java.net.URI @@ -34,9 +36,16 @@ import kotlin.time.Duration.Companion.minutes @Component class SendDiscoverer( private val networkMessagePublisher: NetworkMessagePublisher, - private val eventPublisher: EventPublisher, + private val discoveryQueue: DiscoveryQueue, + private val uncheckedTransactionRepository: UncheckedTransactionRepository, + meterRegistry: MeterRegistry, ) : CacheSupport { private val logger = KotlinLogging.logger {} + private val skippedForGap = + Counter + .builder("transactions.discovery.send.skipped") + .description("Send responses skipped because a higher unchecked transaction lets gap discovery recover them") + .register(meterRegistry) private val peers = ConcurrentHashMap() @@ -82,6 +91,10 @@ class SendDiscoverer( return } + if (discoveryQueue.isAtCapacity()) { + return + } + val block = transaction.block as ReceiveSupport if (duplicateDetector.isDuplicate(transaction.hash)) { @@ -106,17 +119,30 @@ class SendDiscoverer( } @EventListener - fun process(message: InboundNetworkMessage) { + suspend fun process(message: InboundNetworkMessage) { val response = message.payload val transaction = response.transaction - if (unknownHashCache.remove(transaction.hash) == null) { + if (!unknownHashCache.containsKey(transaction.hash)) { return } - logger.debug { "Discovered missing send transaction ${transaction.hash}" } + val block = transaction.block + if (uncheckedTransactionRepository.hasHigherTransaction(block.publicKey, block.height) != 0L) { + skippedForGap.increment() + logger.debug { + "Skipping missing send transaction ${transaction.hash} at height ${block.height} because account " + + "${block.publicKey} has a higher unchecked transaction" + } + return + } - eventPublisher.publish(TransactionDiscovered(null, transaction.toTransaction(), listOf())) + logger.debug { "Discovered missing send transaction ${transaction.hash}" } + discoveryQueue.queue( + TransactionDiscovered(null, transaction.toTransaction(), listOf()), + DiscoverySource.SEND, + ) + unknownHashCache.remove(transaction.hash) } private fun randomUri(votes: Collection): URI? = diff --git a/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionInserter.kt b/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionInserter.kt index da831a38..041f28c1 100644 --- a/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionInserter.kt +++ b/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionInserter.kt @@ -9,18 +9,20 @@ import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Isolation import org.springframework.transaction.annotation.Transactional import reactor.core.publisher.Flux +import java.util.concurrent.ConcurrentHashMap @Component class UncheckedTransactionInserter( private val databaseClient: DatabaseClient, ) { - private val sql = + private val sqlByRowCount = ConcurrentHashMap() + private val valuePlaceholders = "(?, ?, ?, ?, ?, ?, ?)" + private val insertSql = """ INSERT INTO unchecked_transaction (hash, public_key, height, previous, `timestamp`, serialized, received_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON DUPLICATE KEY UPDATE hash = hash """.trimIndent() + private val duplicateUpdateSql = "ON DUPLICATE KEY UPDATE hash = hash" @Transactional(isolation = Isolation.READ_COMMITTED) suspend fun insert(uncheckedTransactions: Collection): Long { @@ -28,39 +30,44 @@ class UncheckedTransactionInserter( return databaseClient .inConnection { conn -> - val last = uncheckedTransactions.size - 1 + val statement = conn.createStatement(sql(uncheckedTransactions.size)) + var bindIndex = 0 - val statement = conn.createStatement(sql) - uncheckedTransactions.forEachIndexed { i, t -> - statement.apply { - bind(0, t.hash.value) - bind(1, t.publicKey.value) - bind(2, t.height.value.toBigInteger()) - if (t.previous != null) { - bind(3, t.previous.value) - } else { - bindNull(3, ByteArray::class.java) - } - bind(4, t.block.timestamp.toJavaInstant()) - bind( - 5, - t + uncheckedTransactions.forEach { transaction -> + val previous = transaction.previous + + statement + .bind(bindIndex++, transaction.hash.value) + .bind(bindIndex++, transaction.publicKey.value) + .bind(bindIndex++, transaction.height.value.toBigInteger()) + + if (previous != null) { + statement.bind(bindIndex++, previous.value) + } else { + statement.bindNull(bindIndex++, ByteArray::class.java) + } + + statement + .bind(bindIndex++, transaction.block.timestamp.toJavaInstant()) + .bind( + bindIndex++, + transaction .toTransaction() .toAttoTransaction() .toBuffer() .readByteArray(), - ) - bind(6, t.receivedAt) - - if (i != last) { - add() - } - } + ).bind(bindIndex++, transaction.receivedAt) } + Flux .from(statement.execute()) .flatMap { it.rowsUpdated } .reduce(0L, Long::plus) }.awaitSingle() } + + private fun sql(rowCount: Int): String = + sqlByRowCount.computeIfAbsent(rowCount) { + "$insertSql VALUES ${List(rowCount) { valuePlaceholders }.joinToString(", ")} $duplicateUpdateSql" + } } diff --git a/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessor.kt b/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessor.kt index b8ee4cf4..13d02d9b 100644 --- a/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessor.kt +++ b/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessor.kt @@ -12,16 +12,11 @@ import cash.atto.node.transaction.Transaction import cash.atto.node.transaction.TransactionSource import cash.atto.node.transaction.validation.TransactionValidationManager import io.github.oshai.kotlinlogging.KotlinLogging -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.toList import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Component import org.springframework.transaction.annotation.Isolation import org.springframework.transaction.annotation.Transactional -import java.util.concurrent.TimeUnit -import kotlin.time.measureTime private val logger = KotlinLogging.logger {} @@ -85,38 +80,3 @@ class UncheckedTransactionProcessor( } } } - -@Component -class UncheckedTransactionProcessorStarter( - private val uncheckedTransactionRepository: UncheckedTransactionRepository, - private val processor: UncheckedTransactionProcessor, - private val uncheckedTransactionService: UncheckedTransactionService, -) { - private val mutex = Mutex() - - @Scheduled(fixedRate = 1, timeUnit = TimeUnit.SECONDS) - suspend fun process() { - if (mutex.isLocked) { - return - } - mutex.withLock { - do { - val candidateTransactions = - uncheckedTransactionRepository - .findTopOldest(1000L) - .map { it.toTransaction() } - .toList() - - var resolvedCounter = 0 - val elapsed = - measureTime { - resolvedCounter = processor.process(candidateTransactions) - uncheckedTransactionService.cleanUp() - } - if (resolvedCounter > 0) { - logger.info { "Resolved $resolvedCounter unchecked transactions in $elapsed" } - } - } while (candidateTransactions.isNotEmpty() && resolvedCounter == candidateTransactions.size) - } - } -} diff --git a/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessorStarter.kt b/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessorStarter.kt new file mode 100644 index 00000000..c0a52c04 --- /dev/null +++ b/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessorStarter.kt @@ -0,0 +1,161 @@ +package cash.atto.node.bootstrap.unchecked + +import cash.atto.node.bootstrap.discovery.DiscoveryProperties +import cash.atto.node.transaction.Transaction +import io.github.oshai.kotlinlogging.KotlinLogging +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Timer +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.Clock +import java.time.Instant +import java.util.concurrent.TimeUnit +import kotlin.time.measureTime + +@Component +class UncheckedTransactionProcessorStarter( + private val uncheckedTransactionRepository: UncheckedTransactionRepository, + private val processor: UncheckedTransactionProcessor, + private val uncheckedTransactionService: UncheckedTransactionService, + private val workTracker: UncheckedWorkTracker, + private val discoveryProperties: DiscoveryProperties, + private val meterRegistry: MeterRegistry, + private val clock: Clock, +) { + private val logger = KotlinLogging.logger {} + private val mutex = Mutex() + private val batchSize = 1_000L + private val maxResolutionPassNanos = TimeUnit.MILLISECONDS.toNanos(500) + private val maxCleanupPassNanos = TimeUnit.MILLISECONDS.toNanos(500) + private val maxCleanupBatches = 10 + + private val selectionTimer = + Timer + .builder("transactions.unchecked.resolution.query") + .description("Time selecting unchecked transactions eligible for resolution") + .register(meterRegistry) + private val processingTimer = + Timer + .builder("transactions.unchecked.resolution.process") + .description("Time validating and resolving unchecked transactions") + .register(meterRegistry) + private val cleanupTimer = + Timer + .builder("transactions.unchecked.cleanup") + .description("Time deleting bounded batches of stale unchecked transactions") + .register(meterRegistry) + private val resolvedTransactions = + Counter + .builder("transactions.unchecked.resolved") + .description("Unchecked transactions successfully resolved") + .register(meterRegistry) + private val deletedTransactions = + Counter + .builder("transactions.unchecked.cleanup.deleted") + .description("Stale unchecked transactions deleted") + .register(meterRegistry) + + @Volatile + private var idleGeneration = Long.MIN_VALUE + + @Volatile + private var nextMaintenanceAt = Instant.EPOCH + + @Scheduled(fixedRate = 1, timeUnit = TimeUnit.SECONDS) + suspend fun process() { + if (mutex.isLocked || shouldSkipIdleScan()) { + return + } + mutex.withLock { + if (shouldSkipIdleScan()) { + return + } + + val startingGeneration = workTracker.currentGeneration() + val passStarted = System.nanoTime() + var madeProgress = false + var candidateTransactions: List + var resolvedCounter = 0 + + do { + candidateTransactions = + selectionTimer.recordSuspending { + uncheckedTransactionRepository + .findTopOldest(batchSize) + .map { it.toTransaction() } + .toList() + } + + val elapsed = + measureTime { + resolvedCounter = 0 + resolvedCounter = + processingTimer.recordSuspending { + processor.process(candidateTransactions) + } + } + if (resolvedCounter > 0) { + madeProgress = true + resolvedTransactions.increment(resolvedCounter.toDouble()) + logger.info { "Resolved $resolvedCounter unchecked transactions in $elapsed" } + } + + val deleted = cleanUp() + if (deleted > 0) { + madeProgress = true + deletedTransactions.increment(deleted.toDouble()) + } + } while ( + candidateTransactions.isNotEmpty() && + resolvedCounter == candidateTransactions.size && + System.nanoTime() - passStarted < maxResolutionPassNanos + ) + + val endingGeneration = workTracker.currentGeneration() + if (!madeProgress && startingGeneration == endingGeneration) { + idleGeneration = endingGeneration + nextMaintenanceAt = clock.instant().plusSeconds(discoveryProperties.idleQueryFallbackInSeconds) + } else { + idleGeneration = Long.MIN_VALUE + nextMaintenanceAt = Instant.EPOCH + } + } + } + + private suspend fun cleanUp(): Int { + val startedAt = System.nanoTime() + var deletedTotal = 0 + + repeat(maxCleanupBatches) { + val deleted = + cleanupTimer.recordSuspending { + uncheckedTransactionService.cleanUp(batchSize) + } + deletedTotal += deleted + + if (deleted < batchSize || System.nanoTime() - startedAt >= maxCleanupPassNanos) { + return deletedTotal + } + } + + return deletedTotal + } + + private fun shouldSkipIdleScan(): Boolean = + workTracker.currentGeneration() == idleGeneration && + clock.instant().isBefore(nextMaintenanceAt) +} + +private suspend fun Timer.recordSuspending(block: suspend () -> T): T { + val startedAt = System.nanoTime() + try { + return block() + } finally { + record(System.nanoTime() - startedAt, TimeUnit.NANOSECONDS) + } +} diff --git a/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionRepository.kt b/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionRepository.kt index 6eee3acb..0d19716f 100644 --- a/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionRepository.kt +++ b/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionRepository.kt @@ -14,67 +14,101 @@ interface UncheckedTransactionRepository : AttoRepository { @Query( """ - SELECT ut.* - FROM unchecked_transaction AS ut - LEFT JOIN account AS a - ON a.public_key = ut.public_key - WHERE ut.height > COALESCE(a.height, 0) - ORDER BY ut.timestamp - LIMIT :limit; + SELECT ut.serialized + FROM ( + SELECT candidate.hash, candidate.timestamp, candidate.public_key, candidate.height + FROM unchecked_transaction candidate + LEFT JOIN account a + ON a.public_key = candidate.public_key + WHERE candidate.height > COALESCE(a.height, 0) + ORDER BY candidate.timestamp, candidate.public_key, candidate.height + LIMIT :limit + ) oldest + JOIN unchecked_transaction ut + ON ut.hash = oldest.hash + ORDER BY oldest.timestamp, oldest.public_key, oldest.height """, ) suspend fun findTopOldest(limit: Long): Flow @Query( """ - WITH calculated_gaps AS ( - SELECT - ut.public_key, - GREATEST( - COALESCE(a.height, 0), - COALESCE(LAG(ut.height) OVER (PARTITION BY ut.public_key ORDER BY ut.height ASC), 0) - ) + 1 AS start_height, - ut.height - 1 AS end_height, - ut.previous AS expected_end_hash, - ut.timestamp AS transaction_timestamp - FROM unchecked_transaction ut - LEFT JOIN account a ON a.public_key = ut.public_key - WHERE ut.height > COALESCE(a.height, 0) - AND ut.public_key NOT IN (:publicKeyToExclude) + SELECT EXISTS ( + SELECT 1 + FROM unchecked_transaction + WHERE public_key = :publicKey + AND height > :height + ) + """, + ) + // MySQL exposes EXISTS as BIGINT, which asyncer R2DBC cannot decode directly as Boolean. + suspend fun hasHigherTransaction( + publicKey: AttoPublicKey, + height: AttoHeight, + ): Long - ), - ranked AS ( - SELECT - cg.*, - ROW_NUMBER() OVER ( - PARTITION BY cg.public_key - ORDER BY cg.transaction_timestamp ASC - ) AS rn - FROM calculated_gaps cg - WHERE cg.start_height <= cg.end_height + @Query( + """ + WITH first_gaps AS ( + SELECT + candidate.public_key, + MIN(candidate.height) AS height, + COALESCE(a.height, 0) AS account_height + FROM ( + SELECT DISTINCT public_key + FROM unchecked_transaction + ) account_keys + LEFT JOIN account a + ON a.public_key = account_keys.public_key + JOIN unchecked_transaction candidate + ON candidate.public_key = account_keys.public_key + AND candidate.height > COALESCE(a.height, 0) + 1 + LEFT JOIN unchecked_transaction immediate_previous + ON immediate_previous.public_key = candidate.public_key + AND immediate_previous.height = candidate.height - 1 + WHERE immediate_previous.hash IS NULL + GROUP BY candidate.public_key, COALESCE(a.height, 0) ) - SELECT public_key, start_height, end_height, expected_end_hash - FROM ranked - WHERE rn = 1 - LIMIT :limit; + SELECT + boundary.public_key, + COALESCE( + ( + SELECT previous_transaction.height + FROM unchecked_transaction previous_transaction + WHERE previous_transaction.public_key = boundary.public_key + AND previous_transaction.height > first_gaps.account_height + AND previous_transaction.height < boundary.height + ORDER BY previous_transaction.height DESC + LIMIT 1 + ), + first_gaps.account_height + ) + 1 AS start_height, + boundary.height - 1 AS end_height, + boundary.previous AS expected_end_hash + FROM first_gaps + JOIN unchecked_transaction boundary + ON boundary.public_key = first_gaps.public_key + AND boundary.height = first_gaps.height + ORDER BY boundary.timestamp, boundary.public_key, boundary.height + LIMIT :limit """, ) - suspend fun findGaps( - publicKeyToExclude: Collection, - limit: Long, - ): Flow + suspend fun findGaps(limit: Int): Flow @Modifying @Query( """ - DELETE ut - FROM unchecked_transaction ut - JOIN account a - ON a.public_key = ut.public_key - AND ut.height <= a.height + DELETE FROM unchecked_transaction + WHERE EXISTS ( + SELECT 1 + FROM account a + WHERE a.public_key = unchecked_transaction.public_key + AND unchecked_transaction.height <= a.height + ) + LIMIT :limit """, ) - suspend fun deleteExistingInTransaction(): Int + suspend fun deleteExistingInTransaction(limit: Long): Int } data class GapView( diff --git a/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionService.kt b/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionService.kt index aaae332b..f8ea45b2 100644 --- a/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionService.kt +++ b/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionService.kt @@ -19,21 +19,23 @@ class UncheckedTransactionService( @Transactional(isolation = Isolation.READ_COMMITTED) @Timed("unchecked_transactions_save", description = "Time taken to save an unchecked transaction") - suspend fun save(uncheckedTransactions: Collection) { - uncheckedTransactionInserter.insert(uncheckedTransactions) + suspend fun save(uncheckedTransactions: Collection): Long { + val affectedRows = uncheckedTransactionInserter.insert(uncheckedTransactions) executeAfterCommit { uncheckedTransactions.forEach { logger.debug { "Saved $it" } eventPublisher.publish(UncheckedTransactionSaved(it)) } } + return affectedRows } @Transactional(isolation = Isolation.READ_COMMITTED) - suspend fun cleanUp() { - val deletedCount = uncheckedTransactionRepository.deleteExistingInTransaction() + suspend fun cleanUp(limit: Long): Int { + val deletedCount = uncheckedTransactionRepository.deleteExistingInTransaction(limit) if (deletedCount > 0) { logger.debug { "Deleted $deletedCount unchecked transactions" } } + return deletedCount } } diff --git a/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedWorkTracker.kt b/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedWorkTracker.kt new file mode 100644 index 00000000..ac350d07 --- /dev/null +++ b/src/main/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedWorkTracker.kt @@ -0,0 +1,32 @@ +package cash.atto.node.bootstrap.unchecked + +import cash.atto.node.account.AccountUpdated +import cash.atto.node.bootstrap.UncheckedTransactionSaved +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import java.util.concurrent.atomic.AtomicLong + +@Component +class UncheckedWorkTracker { + private val generation = AtomicLong() + + fun currentGeneration(): Long = generation.get() + + fun markChanged() { + generation.incrementAndGet() + } + + @EventListener + fun process( + @Suppress("UNUSED_PARAMETER") event: AccountUpdated, + ) { + markChanged() + } + + @EventListener + fun process( + @Suppress("UNUSED_PARAMETER") event: UncheckedTransactionSaved, + ) { + markChanged() + } +} diff --git a/src/main/kotlin/cash/atto/node/transaction/TransactionNetworkProvider.kt b/src/main/kotlin/cash/atto/node/transaction/TransactionNetworkProvider.kt index 1c2ca447..86a8b8d5 100644 --- a/src/main/kotlin/cash/atto/node/transaction/TransactionNetworkProvider.kt +++ b/src/main/kotlin/cash/atto/node/transaction/TransactionNetworkProvider.kt @@ -11,9 +11,9 @@ import cash.atto.protocol.AttoTransactionResponse import cash.atto.protocol.AttoTransactionStreamRequest import cash.atto.protocol.AttoTransactionStreamResponse import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.takeWhile -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import org.springframework.context.event.EventListener import org.springframework.stereotype.Component import java.net.URI @@ -27,7 +27,7 @@ class TransactionNetworkProvider( private val networkMessagePublisher: NetworkMessagePublisher, ) { private val peers = ConcurrentHashMap.newKeySet() - private val mutex = Mutex() + private val requestPermits = Semaphore(AttoTransactionStreamRequest.MAX_PARALLEL_STREAMS) @EventListener fun add(nodeEvent: NodeConnected) { @@ -47,9 +47,9 @@ class TransactionNetworkProvider( return } - mutex.withLock { + requestPermits.withPermit { if (!peers.contains(message.publicUri)) { - return + return@withPermit } val request = message.payload @@ -67,26 +67,28 @@ class TransactionNetworkProvider( return } - mutex.withLock { + requestPermits.withPermit { if (!peers.contains(message.publicUri)) { - return + return@withPermit } val request = message.payload val transactions = - transactionRepository.findDesc( - request.publicKey, - request.startHeight, - request.endHeight, - ) + transactionRepository + .findDesc( + request.publicKey, + request.startHeight, + request.endHeight, + ).toList() - transactions - .takeWhile { peers.contains(message.publicUri) } - .collect { - val response = AttoTransactionStreamResponse(it.toAttoTransaction()) - networkMessagePublisher.publish(DirectNetworkMessage(message.publicUri, response)) - delay(10.milliseconds) + for (transaction in transactions) { + if (!peers.contains(message.publicUri)) { + break } + val response = AttoTransactionStreamResponse(transaction.toAttoTransaction()) + networkMessagePublisher.publish(DirectNetworkMessage(message.publicUri, response)) + delay(10.milliseconds) + } } } } diff --git a/src/main/kotlin/cash/atto/protocol/AttoNode.kt b/src/main/kotlin/cash/atto/protocol/AttoNode.kt index a04fcc6f..77e5bc2c 100644 --- a/src/main/kotlin/cash/atto/protocol/AttoNode.kt +++ b/src/main/kotlin/cash/atto/protocol/AttoNode.kt @@ -21,6 +21,10 @@ data class AttoNode( @ProtoNumber(5) @Serializable(with = URISerializer::class) val publicUri: URI, @ProtoNumber(6) val features: Set, ) { + companion object { + val CURRENT_PROTOCOL_VERSION: UShort = 1u + } + @Transient val minProtocolVersion: UShort = when (protocolVersion) { @@ -40,6 +44,8 @@ data class AttoNode( fun isNotHistorical(): Boolean = !isHistorical() + fun supportsParallelTransactionStreams(): Boolean = protocolVersion >= CURRENT_PROTOCOL_VERSION + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false diff --git a/src/main/kotlin/cash/atto/protocol/AttoTransactionStreamRequest.kt b/src/main/kotlin/cash/atto/protocol/AttoTransactionStreamRequest.kt index 767e175d..8c8e0ddd 100644 --- a/src/main/kotlin/cash/atto/protocol/AttoTransactionStreamRequest.kt +++ b/src/main/kotlin/cash/atto/protocol/AttoTransactionStreamRequest.kt @@ -21,6 +21,7 @@ data class AttoTransactionStreamRequest( ) : AttoMessage { companion object { const val MAX_TRANSACTIONS = 1000UL + const val MAX_PARALLEL_STREAMS = 10 } init { diff --git a/src/main/resources/META-INF/native-image/cash.atto/node/reachability-metadata.json b/src/main/resources/META-INF/native-image/cash.atto/node/reachability-metadata.json index 16a2764b..099456ae 100644 --- a/src/main/resources/META-INF/native-image/cash.atto/node/reachability-metadata.json +++ b/src/main/resources/META-INF/native-image/cash.atto/node/reachability-metadata.json @@ -73,9 +73,6 @@ { "type": "cash.atto.commons.AttoAddress__Accessor_b4yl3v" }, - { - "type": "cash.atto.commons.AttoAddress__Instantiator_b4yl3v" - }, { "type": "cash.atto.commons.AttoAlgorithm", "serializable": true @@ -101,9 +98,6 @@ { "type": "cash.atto.commons.AttoAmount__Accessor_uaf0up" }, - { - "type": "cash.atto.commons.AttoAmount__Instantiator_uaf0up" - }, { "type": "cash.atto.commons.AttoBlock" }, @@ -149,9 +143,6 @@ { "type": "cash.atto.commons.AttoHash__Accessor_jjvshl" }, - { - "type": "cash.atto.commons.AttoHash__Instantiator_jjvshl" - }, { "type": "cash.atto.commons.AttoHashable" }, @@ -170,9 +161,6 @@ { "type": "cash.atto.commons.AttoHeight__Accessor_xh7wf4" }, - { - "type": "cash.atto.commons.AttoHeight__Instantiator_xh7wf4" - }, { "type": "cash.atto.commons.AttoInstant" }, @@ -185,9 +173,6 @@ { "type": "cash.atto.commons.AttoInstant__Accessor_8jcxbs" }, - { - "type": "cash.atto.commons.AttoInstant__Instantiator_8jcxbs" - }, { "type": "cash.atto.commons.AttoNetwork" }, @@ -232,9 +217,6 @@ { "type": "cash.atto.commons.AttoPublicKey__Accessor_crz7dv" }, - { - "type": "cash.atto.commons.AttoPublicKey__Instantiator_crz7dv" - }, { "type": "cash.atto.commons.AttoReceivable", "fields": [ @@ -291,9 +273,6 @@ { "type": "cash.atto.commons.AttoSignature__Accessor_iy8n8h" }, - { - "type": "cash.atto.commons.AttoSignature__Instantiator_iy8n8h" - }, { "type": "cash.atto.commons.AttoSignedVote" }, @@ -307,7 +286,7 @@ "type": "cash.atto.commons.AttoSigner$DefaultImpls" }, { - "type": "cash.atto.commons.AttoSigner$Subclass6" + "type": "cash.atto.commons.AttoSigner$Subclass15" }, { "type": "cash.atto.commons.AttoTransaction", @@ -338,9 +317,6 @@ { "type": "cash.atto.commons.AttoTransaction__Accessor_7y9irf" }, - { - "type": "cash.atto.commons.AttoTransaction__Instantiator_7y9irf" - }, { "type": "cash.atto.commons.AttoUnit" }, @@ -350,9 +326,6 @@ { "type": "cash.atto.commons.AttoValidation$Error__Accessor_ytxlyu" }, - { - "type": "cash.atto.commons.AttoValidation$Error__Instantiator_ytxlyu" - }, { "type": "cash.atto.commons.AttoValidation$Ok__Accessor_ananfy" }, @@ -371,9 +344,6 @@ { "type": "cash.atto.commons.AttoVersion__Accessor_z3i9j5" }, - { - "type": "cash.atto.commons.AttoVersion__Instantiator_z3i9j5" - }, { "type": "cash.atto.commons.AttoVote" }, @@ -401,15 +371,9 @@ { "type": "cash.atto.commons.AttoWorkTarget__Accessor_g1qksl" }, - { - "type": "cash.atto.commons.AttoWorkTarget__Instantiator_g1qksl" - }, { "type": "cash.atto.commons.AttoWork__Accessor_jjlxbq" }, - { - "type": "cash.atto.commons.AttoWork__Instantiator_jjlxbq" - }, { "type": "cash.atto.commons.HeightSupport" }, @@ -1984,9 +1948,6 @@ { "type": "cash.atto.node.account.Account__Accessor_mv8nv" }, - { - "type": "cash.atto.node.account.Account__Instantiator_mv8nv" - }, { "type": "cash.atto.node.account.entry.AccountEntry", "fields": [ @@ -2023,6 +1984,26 @@ { "name": "timestamp" } + ], + "methods": [ + { + "name": "", + "parameterTypes": [ + "cash.atto.commons.AttoHash", + "cash.atto.commons.AttoAlgorithm", + "cash.atto.commons.AttoPublicKey", + "cash.atto.commons.AttoHeight", + "cash.atto.commons.AttoBlockType", + "cash.atto.commons.AttoAlgorithm", + "cash.atto.commons.AttoPublicKey", + "cash.atto.commons.AttoAmount", + "cash.atto.commons.AttoAmount", + "java.time.Instant", + "java.time.Instant", + "int", + "kotlin.jvm.internal.DefaultConstructorMarker" + ] + } ] }, { @@ -2186,13 +2167,7 @@ ] }, { - "type": "cash.atto.node.account.entry.AccountEntry__Instantiator_wao3d5", - "methods": [ - { - "name": "", - "parameterTypes": [] - } - ] + "type": "cash.atto.node.account.entry.AccountEntry__Instantiator_wao3d5" }, { "type": "cash.atto.node.bootstrap.TransactionDiscovered" @@ -2213,7 +2188,7 @@ "name": "", "parameterTypes": [ "cash.atto.node.vote.weight.VoteWeighter", - "cash.atto.node.EventPublisher" + "cash.atto.node.bootstrap.discovery.DiscoveryQueue" ] }, { @@ -2239,61 +2214,100 @@ "type": "cash.atto.node.bootstrap.discovery.DependencyDiscoverer$outOfOrderBuffer$1" }, { - "type": "cash.atto.node.bootstrap.discovery.DependencyDiscoverer$process$4" - }, - { - "type": "cash.atto.node.bootstrap.discovery.DependencyDiscoverer__TestContext001_BeanDefinitions" + "type": "cash.atto.node.bootstrap.discovery.DependencyDiscoverer$process$4", + "fields": [ + { + "name": "label" + } + ] }, { - "type": "cash.atto.node.bootstrap.discovery.DiscoveryProcessor", + "type": "cash.atto.node.bootstrap.discovery.DependencyDiscovererTest", "methods": [ { "name": "", - "parameterTypes": [ - "cash.atto.node.bootstrap.unchecked.UncheckedTransactionService" - ] + "parameterTypes": [] }, { - "name": "flush", - "parameterTypes": [ - "kotlin.coroutines.Continuation" - ] - }, + "name": "final consensus at capacity retains the holder when admission is cancelled", + "parameterTypes": [] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DependencyDiscovererTest$final consensus at capacity retains the holder when admission is cancelled$1", + "methods": [ { - "name": "process", + "name": "invokeSuspend", "parameterTypes": [ - "cash.atto.node.bootstrap.TransactionDiscovered", - "kotlin.coroutines.Continuation" + "java.lang.Object" ] } ] }, { - "type": "cash.atto.node.bootstrap.discovery.DiscoveryProcessor$flush$1" + "type": "cash.atto.node.bootstrap.discovery.DependencyDiscovererTest$final consensus at capacity retains the holder when admission is cancelled$1$2" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DependencyDiscovererTest$final consensus at capacity retains the holder when admission is cancelled$1$3", + "fields": [ + { + "name": "label" + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DependencyDiscovererTest$final consensus at capacity retains the holder when admission is cancelled$1$5" }, { - "type": "cash.atto.node.bootstrap.discovery.DiscoveryProcessor__TestContext001_BeanDefinitions" + "type": "cash.atto.node.bootstrap.discovery.DependencyDiscovererTest$final consensus at capacity retains the holder when admission is cancelled$1$6" }, { - "type": "cash.atto.node.bootstrap.discovery.DiscoveryTestController", + "type": "cash.atto.node.bootstrap.discovery.DependencyDiscovererTest$final consensus at capacity retains the holder when admission is cancelled$1$7" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DependencyDiscovererTest$final consensus at capacity retains the holder when admission is cancelled$1$admission$1", + "fields": [ + { + "name": "label" + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DependencyDiscoverer__TestContext001_BeanDefinitions" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryMetrics", "methods": [ { "name": "", "parameterTypes": [ - "cash.atto.node.bootstrap.discovery.DiscoveryProcessor", - "cash.atto.node.bootstrap.discovery.GapDiscoverer", - "cash.atto.node.bootstrap.discovery.LastDiscoverer", - "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter" + "io.micrometer.core.instrument.MeterRegistry" ] - }, + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryMetrics$persisted$$inlined$groupingBy$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryMetrics__TestContext001_BeanDefinitions" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryPersistenceWorker", + "methods": [ { - "name": "last", + "name": "", "parameterTypes": [ - "kotlin.coroutines.Continuation" + "cash.atto.node.bootstrap.unchecked.UncheckedTransactionService", + "cash.atto.node.bootstrap.discovery.DiscoveryQueue", + "cash.atto.node.bootstrap.discovery.DiscoveryProperties", + "cash.atto.node.bootstrap.discovery.DiscoveryMetrics", + "java.time.Clock" ] }, { - "name": "settle", + "name": "flush", "parameterTypes": [ "kotlin.coroutines.Continuation" ] @@ -2301,157 +2315,725 @@ ] }, { - "type": "cash.atto.node.bootstrap.discovery.DiscoveryTestController$settle$1" + "type": "cash.atto.node.bootstrap.discovery.DiscoveryPersistenceWorker$clear$1" }, { - "type": "cash.atto.node.bootstrap.discovery.DiscoveryTestController__TestContext001_BeanDefinitions" + "type": "cash.atto.node.bootstrap.discovery.DiscoveryPersistenceWorker$flush$1" }, { - "type": "cash.atto.node.bootstrap.discovery.FixedSizeHashMap" + "type": "cash.atto.node.bootstrap.discovery.DiscoveryPersistenceWorker__TestContext001_BeanDefinitions" }, { - "type": "cash.atto.node.bootstrap.discovery.GapDiscoverer", + "type": "cash.atto.node.bootstrap.discovery.DiscoveryProperties", "methods": [ { - "name": "", + "name": "setBatchSize", "parameterTypes": [ - "cash.atto.node.bootstrap.unchecked.UncheckedTransactionRepository", - "cash.atto.node.network.NetworkMessagePublisher", - "cash.atto.node.EventPublisher" + "int" ] }, { - "name": "add", + "name": "setCapacity", "parameterTypes": [ - "cash.atto.node.network.NodeConnected" + "int" ] }, { - "name": "process", + "name": "setHeadroom", "parameterTypes": [ - "cash.atto.node.bootstrap.UncheckedTransactionSaved" + "int" ] }, { - "name": "process", + "name": "setIdleQueryFallbackInSeconds", "parameterTypes": [ - "cash.atto.node.network.InboundNetworkMessage" + "long" ] }, { - "name": "remove", + "name": "setRetryInitialBackoffInSeconds", "parameterTypes": [ - "cash.atto.node.network.NodeDisconnected" + "long" ] }, { - "name": "resolve", + "name": "setRetryMaxBackoffInSeconds", "parameterTypes": [ - "kotlin.coroutines.Continuation" + "long" ] + }, + { + "name": "validate", + "parameterTypes": [] } ] }, { - "type": "cash.atto.node.bootstrap.discovery.GapDiscoverer$resolve$1" - }, - { - "type": "cash.atto.node.bootstrap.discovery.GapDiscoverer$resolve$2$3" - }, - { - "type": "cash.atto.node.bootstrap.discovery.GapDiscovererKt" - }, - { - "type": "cash.atto.node.bootstrap.discovery.GapDiscoverer__TestContext001_BeanDefinitions" - }, - { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer", - "methods": [ + "type": "cash.atto.node.bootstrap.discovery.DiscoveryProperties$$SpringCGLIB$$0", + "fields": [ { - "name": "", - "parameterTypes": [ - "cash.atto.protocol.AttoNode", - "cash.atto.node.account.AccountRepository", - "cash.atto.node.transaction.TransactionRepository", - "cash.atto.node.bootstrap.unchecked.UncheckedTransactionRepository", - "cash.atto.node.network.NodeConnectionManager", - "cash.atto.node.network.NetworkMessagePublisher", - "cash.atto.node.EventPublisher", - "cash.atto.node.vote.convertion.VoteConverter", - "cash.atto.node.vote.weight.VoteWeighter" - ] + "name": "$$beanFactory" }, { - "name": "broadcastSample", - "parameterTypes": [ - "kotlin.coroutines.Continuation" - ] + "name": "CGLIB$CALLBACK_FILTER" }, { - "name": "close", + "name": "CGLIB$FACTORY_DATA" + } + ], + "methods": [ + { + "name": "", "parameterTypes": [] }, { - "name": "processPush", + "name": "CGLIB$SET_STATIC_CALLBACKS", "parameterTypes": [ - "cash.atto.node.network.InboundNetworkMessage", - "kotlin.coroutines.Continuation" + "org.springframework.cglib.proxy.Callback[]" ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryProperties$$SpringCGLIB$$1", + "fields": [ + { + "name": "$$beanFactory" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] }, { - "name": "processVoteResponse", + "name": "CGLIB$SET_STATIC_CALLBACKS", "parameterTypes": [ - "cash.atto.node.network.InboundNetworkMessage", - "kotlin.coroutines.Continuation" + "org.springframework.cglib.proxy.Callback[]" ] } ] }, { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$1" - }, - { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$2$3" - }, - { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$lambda$0$$inlined$map$1" - }, - { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$lambda$0$$inlined$map$1$2" - }, - { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$lambda$0$$inlined$map$1$2$1" - }, - { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$lambda$0$$inlined$map$2" - }, - { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$lambda$0$$inlined$map$2$2" - }, - { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$lambda$0$$inlined$map$2$2$1" + "type": "cash.atto.node.bootstrap.discovery.DiscoveryProperties$Companion" }, { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$processPush$1" + "type": "cash.atto.node.bootstrap.discovery.DiscoveryProperties__TestContext001_BeanDefinitions" }, { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$transactionElectionMap$1" + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueue", + "methods": [ + { + "name": "", + "parameterTypes": [ + "cash.atto.node.EventPublisher", + "cash.atto.node.bootstrap.discovery.DiscoveryProperties", + "cash.atto.node.bootstrap.discovery.DiscoveryMetrics", + "java.time.Clock" + ] + }, + { + "name": "start", + "parameterTypes": [] + } + ] }, { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$transactionElectionMap$1$1" + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueue$buffer$1" }, { - "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer__TestContext001_BeanDefinitions" + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueue$queue$1", + "fields": [ + { + "name": "label" + } + ] }, { - "type": "cash.atto.node.bootstrap.discovery.SendDiscoverer", + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest", "methods": [ { "name": "", - "parameterTypes": [ - "cash.atto.node.network.NetworkMessagePublisher", - "cash.atto.node.EventPublisher" - ] + "parameterTypes": [] + }, + { + "name": "backlog metrics include suspended replies and record queue wait when drained", + "parameterTypes": [] + }, + { + "name": "cancelled save leaves the drained batch intact", + "parameterTypes": [] + }, + { + "name": "cancelling a suspended admission removes it without affecting buffered transactions", + "parameterTypes": [] + }, + { + "name": "capacity suspends the next admission without blocking unrelated work and preserves FIFO order", + "parameterTypes": [] + }, + { + "name": "duplicate transaction is published and persisted once", + "parameterTypes": [] + }, + { + "name": "headroom accepts in flight replies above the discovery target before suspending", + "parameterTypes": [] + }, + { + "name": "save failure retries the exact batch before newer transactions", + "parameterTypes": [] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$Fixture" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$MutableClock" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$backlog metrics include suspended replies and record queue wait when drained$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$backlog metrics include suspended replies and record queue wait when drained$1$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$backlog metrics include suspended replies and record queue wait when drained$1$suspended$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$cancelled save leaves the drained batch intact$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$cancelled save leaves the drained batch intact$1$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$cancelling a suspended admission removes it without affecting buffered transactions$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$cancelling a suspended admission removes it without affecting buffered transactions$1$suspended$1", + "fields": [ + { + "name": "label" + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$capacity suspends the next admission without blocking unrelated work and preserves FIFO order$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$capacity suspends the next admission without blocking unrelated work and preserves FIFO order$1$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$capacity suspends the next admission without blocking unrelated work and preserves FIFO order$1$3" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$capacity suspends the next admission without blocking unrelated work and preserves FIFO order$1$thirdAdmission$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$duplicate transaction is published and persisted once$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$duplicate transaction is published and persisted once$1$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$headroom accepts in flight replies above the discovery target before suspending$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$headroom accepts in flight replies above the discovery target before suspending$1$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$headroom accepts in flight replies above the discovery target before suspending$1$fifth$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$save failure retries the exact batch before newer transactions$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueueTest$save failure retries the exact batch before newer transactions$1$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryQueue__TestContext001_BeanDefinitions" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoverySource" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryTestController", + "methods": [ + { + "name": "", + "parameterTypes": [ + "cash.atto.node.bootstrap.discovery.DiscoveryPersistenceWorker", + "cash.atto.node.bootstrap.discovery.GapDiscoverer", + "cash.atto.node.bootstrap.discovery.LastDiscoverer", + "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter" + ] + }, + { + "name": "last", + "parameterTypes": [ + "kotlin.coroutines.Continuation" + ] + }, + { + "name": "settle", + "parameterTypes": [ + "kotlin.coroutines.Continuation" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryTestController$settle$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.DiscoveryTestController__TestContext001_BeanDefinitions" + }, + { + "type": "cash.atto.node.bootstrap.discovery.FixedSizeHashMap" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscoverer", + "methods": [ + { + "name": "", + "parameterTypes": [ + "cash.atto.node.bootstrap.unchecked.UncheckedTransactionRepository", + "cash.atto.node.network.NetworkMessagePublisher", + "cash.atto.node.bootstrap.discovery.DiscoveryQueue", + "cash.atto.node.bootstrap.unchecked.UncheckedWorkTracker", + "cash.atto.node.bootstrap.discovery.DiscoveryProperties", + "io.micrometer.core.instrument.MeterRegistry", + "java.time.Clock" + ] + }, + { + "name": "add", + "parameterTypes": [ + "cash.atto.node.network.NodeConnected" + ] + }, + { + "name": "process", + "parameterTypes": [ + "cash.atto.node.bootstrap.UncheckedTransactionSaved" + ] + }, + { + "name": "process", + "parameterTypes": [ + "cash.atto.node.network.InboundNetworkMessage", + "kotlin.coroutines.Continuation" + ] + }, + { + "name": "remove", + "parameterTypes": [ + "cash.atto.node.network.NodeDisconnected" + ] + }, + { + "name": "resolve", + "parameterTypes": [ + "kotlin.coroutines.Continuation" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscoverer$process$2" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscoverer$processOne$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscoverer$resolve$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscoverer$resolve$2$2" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererKt" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "cancelled admission retains the response and retries before querying again", + "parameterTypes": [] + }, + { + "name": "capacity stops gap discovery before running its query", + "parameterTypes": [] + }, + { + "name": "clear removes the active stream", + "parameterTypes": [] + }, + { + "name": "disconnect removes the active stream immediately and allows retry", + "parameterTypes": [] + }, + { + "name": "fixture", + "parameterTypes": [ + "kotlinx.coroutines.flow.Flow", + "boolean", + "int", + "java.time.Clock" + ] + }, + { + "name": "only one gap stream is active and it is capped to the protocol limit", + "parameterTypes": [] + }, + { + "name": "partial and out of order replies remain safe while processor is at capacity", + "parameterTypes": [] + }, + { + "name": "unanswered stream expires and retries automatically", + "parameterTypes": [] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$Companion" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$Fixture" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$MutableClock" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$cancelled admission retains the response and retries before querying again$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$cancelled admission retains the response and retries before querying again$1$3" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$capacity stops gap discovery before running its query$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$capacity stops gap discovery before running its query$1$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$clear removes the active stream$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$disconnect removes the active stream immediately and allows retry$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$fixture$3" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$fixture$4" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$fixture$5" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$only one gap stream is active and it is capped to the protocol limit$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$only one gap stream is active and it is capped to the protocol limit$1$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$only one gap stream is active and it is capped to the protocol limit$1$gaps$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$partial and out of order replies remain safe while processor is at capacity$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$unanswered stream expires and retries automatically$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscovererTest$unanswered stream expires and retries automatically$1$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.GapDiscoverer__TestContext001_BeanDefinitions" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer", + "methods": [ + { + "name": "", + "parameterTypes": [ + "cash.atto.protocol.AttoNode", + "cash.atto.node.account.AccountRepository", + "cash.atto.node.transaction.TransactionRepository", + "cash.atto.node.bootstrap.unchecked.UncheckedTransactionRepository", + "cash.atto.node.network.NodeConnectionManager", + "cash.atto.node.network.NetworkMessagePublisher", + "cash.atto.node.EventPublisher", + "cash.atto.node.bootstrap.discovery.DiscoveryQueue", + "cash.atto.node.vote.convertion.VoteConverter", + "cash.atto.node.vote.weight.VoteWeighter" + ] + }, + { + "name": "broadcastSample", + "parameterTypes": [ + "kotlin.coroutines.Continuation" + ] + }, + { + "name": "close", + "parameterTypes": [] + }, + { + "name": "processPush", + "parameterTypes": [ + "cash.atto.node.network.InboundNetworkMessage", + "kotlin.coroutines.Continuation" + ] + }, + { + "name": "processVoteResponse", + "parameterTypes": [ + "cash.atto.node.network.InboundNetworkMessage", + "kotlin.coroutines.Continuation" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$2$3" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$lambda$0$$inlined$map$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$lambda$0$$inlined$map$1$2" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$lambda$0$$inlined$map$1$2$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$lambda$0$$inlined$map$2" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$lambda$0$$inlined$map$2$2" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$broadcastSample$lambda$0$$inlined$map$2$2$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$processPush$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$processVoteResponse$1", + "fields": [ + { + "name": "label" + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$transactionElectionMap$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer$transactionElectionMap$1$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "capacity prevents a new head election and vote request", + "parameterTypes": [] + }, + { + "name": "consensus at capacity retains the head election when admission is cancelled", + "parameterTypes": [] + }, + { + "name": "fixture", + "parameterTypes": [ + "boolean" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest$Fixture" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest$capacity prevents a new head election and vote request$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest$capacity prevents a new head election and vote request$1$2" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest$consensus at capacity retains the head election when admission is cancelled$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest$consensus at capacity retains the head election when admission is cancelled$1$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest$consensus at capacity retains the head election when admission is cancelled$1$2", + "fields": [ + { + "name": "label" + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest$consensus at capacity retains the head election when admission is cancelled$1$3" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest$consensus at capacity retains the head election when admission is cancelled$1$5" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest$consensus at capacity retains the head election when admission is cancelled$1$6" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest$consensus at capacity retains the head election when admission is cancelled$1$admission$1", + "fields": [ + { + "name": "label" + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest$fixture$3" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscovererTest$fixture$8" + }, + { + "type": "cash.atto.node.bootstrap.discovery.LastDiscoverer__TestContext001_BeanDefinitions" + }, + { + "type": "cash.atto.node.bootstrap.discovery.PendingDiscovery" + }, + { + "type": "cash.atto.node.bootstrap.discovery.PendingDiscoveryKt" + }, + { + "type": "cash.atto.node.bootstrap.discovery.PendingGapRequest" + }, + { + "type": "cash.atto.node.bootstrap.discovery.PendingHeadAdmission" + }, + { + "type": "cash.atto.node.bootstrap.discovery.SendDiscoverer", + "methods": [ + { + "name": "", + "parameterTypes": [ + "cash.atto.node.network.NetworkMessagePublisher", + "cash.atto.node.bootstrap.discovery.DiscoveryQueue", + "cash.atto.node.bootstrap.unchecked.UncheckedTransactionRepository", + "io.micrometer.core.instrument.MeterRegistry" + ] }, { "name": "add", @@ -2474,7 +3056,8 @@ { "name": "process", "parameterTypes": [ - "cash.atto.node.network.InboundNetworkMessage" + "cash.atto.node.network.InboundNetworkMessage", + "kotlin.coroutines.Continuation" ] }, { @@ -2486,29 +3069,98 @@ ] }, { - "type": "cash.atto.node.bootstrap.discovery.SendDiscoverer__TestContext001_BeanDefinitions" + "type": "cash.atto.node.bootstrap.discovery.SendDiscoverer$process$1" }, { - "type": "cash.atto.node.bootstrap.discovery.TransactionHolder" + "type": "cash.atto.node.bootstrap.discovery.SendDiscovererTest", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "paused discovery does not suppress a send request after recovery", + "parameterTypes": [] + }, + { + "name": "response already in flight is admitted while discovery is paused", + "parameterTypes": [] + }, + { + "name": "send response is left for gap discovery when the account has a higher unchecked transaction", + "parameterTypes": [] + } + ] }, { - "type": "cash.atto.node.bootstrap.discovery.TransactionPointer" + "type": "cash.atto.node.bootstrap.discovery.SendDiscovererTest$response already in flight is admitted while discovery is paused$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] }, { - "type": "cash.atto.node.bootstrap.unchecked.GapView" + "type": "cash.atto.node.bootstrap.discovery.SendDiscovererTest$response already in flight is admitted while discovery is paused$1$3" }, { - "type": "cash.atto.node.bootstrap.unchecked.GapView__Accessor_dxhpik" + "type": "cash.atto.node.bootstrap.discovery.SendDiscovererTest$response already in flight is admitted while discovery is paused$1$4" + }, + { + "type": "cash.atto.node.bootstrap.discovery.SendDiscovererTest$response already in flight is admitted while discovery is paused$1$5" + }, + { + "type": "cash.atto.node.bootstrap.discovery.SendDiscovererTest$send response is left for gap discovery when the account has a higher unchecked transaction$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.discovery.SendDiscovererTest$send response is left for gap discovery when the account has a higher unchecked transaction$1$1" + }, + { + "type": "cash.atto.node.bootstrap.discovery.SendDiscovererTest$send response is left for gap discovery when the account has a higher unchecked transaction$1$2" + }, + { + "type": "cash.atto.node.bootstrap.discovery.SendDiscovererTest$send response is left for gap discovery when the account has a higher unchecked transaction$1$3" + }, + { + "type": "cash.atto.node.bootstrap.discovery.SendDiscoverer__TestContext001_BeanDefinitions" + }, + { + "type": "cash.atto.node.bootstrap.discovery.TransactionHolder" + }, + { + "type": "cash.atto.node.bootstrap.discovery.TransactionPointer" }, { - "type": "cash.atto.node.bootstrap.unchecked.GapView__Instantiator_dxhpik", + "type": "cash.atto.node.bootstrap.unchecked.GapView", "methods": [ { "name": "", - "parameterTypes": [] + "parameterTypes": [ + "cash.atto.commons.AttoPublicKey", + "cash.atto.commons.AttoHeight", + "cash.atto.commons.AttoHeight", + "cash.atto.commons.AttoHash" + ] } ] }, + { + "type": "cash.atto.node.bootstrap.unchecked.GapView__Accessor_dxhpik" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.GapView__Instantiator_dxhpik" + }, { "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransaction" }, @@ -2558,6 +3210,29 @@ { "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionInserter$insert$2$3" }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionInserterTest", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "does not acquire a connection for an empty collection", + "parameterTypes": [] + }, + { + "name": "inserts a collection with one multi-row statement", + "parameterTypes": [] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionInserterTest$does not acquire a connection for an empty collection$1" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionInserterTest$inserts a collection with one multi-row statement$1" + }, { "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionInserter__TestContext001_BeanDefinitions" }, @@ -2592,6 +3267,45 @@ { "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionMetricProvider__TestContext001_BeanDefinitions" }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionMigrationTest", + "fields": [ + { + "name": "mysql" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "cleanDatabase", + "parameterTypes": [] + }, + { + "name": "migrates a clean database through the oldest index", + "parameterTypes": [] + }, + { + "name": "migrates a populated V10 database without changing unchecked rows", + "parameterTypes": [] + }, + { + "name": "persists a full multi-row batch while ignoring duplicates through R2DBC", + "parameterTypes": [] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionMigrationTest$Companion" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionMigrationTest$hasHigherUncheckedTransaction$1" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionMigrationTest$persists a full multi-row batch while ignoring duplicates through R2DBC$1" + }, { "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessor", "methods": [ @@ -2649,7 +3363,11 @@ "parameterTypes": [ "cash.atto.node.bootstrap.unchecked.UncheckedTransactionRepository", "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessor", - "cash.atto.node.bootstrap.unchecked.UncheckedTransactionService" + "cash.atto.node.bootstrap.unchecked.UncheckedTransactionService", + "cash.atto.node.bootstrap.unchecked.UncheckedWorkTracker", + "cash.atto.node.bootstrap.discovery.DiscoveryProperties", + "io.micrometer.core.instrument.MeterRegistry", + "java.time.Clock" ] }, { @@ -2660,17 +3378,107 @@ } ] }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter$cleanUp$1" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter$cleanUp$2$deleted$1" + }, { "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter$process$1" }, { - "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter$process$lambda$0$$inlined$map$1" + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter$process$2$1" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter$process$2$1$invokeSuspend$$inlined$map$1" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter$process$2$1$invokeSuspend$$inlined$map$1$2" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter$process$2$1$invokeSuspend$$inlined$map$1$2$1" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter$process$2$elapsed$1$1" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterKt" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterKt$recordSuspending$1" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "bounds cleanup work per scheduled pass", + "parameterTypes": [] + }, + { + "name": "skips repeated scans until work changes or maintenance is due", + "parameterTypes": [] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$MutableClock" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$bounds cleanup work per scheduled pass$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$bounds cleanup work per scheduled pass$1$1" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$bounds cleanup work per scheduled pass$1$2" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$bounds cleanup work per scheduled pass$1$3" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$skips repeated scans until work changes or maintenance is due$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$skips repeated scans until work changes or maintenance is due$1$1" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$skips repeated scans until work changes or maintenance is due$1$2" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$skips repeated scans until work changes or maintenance is due$1$3" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$skips repeated scans until work changes or maintenance is due$1$4" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$skips repeated scans until work changes or maintenance is due$1$5" }, { - "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter$process$lambda$0$$inlined$map$1$2" + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$skips repeated scans until work changes or maintenance is due$1$6" }, { - "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter$process$lambda$0$$inlined$map$1$2$1" + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarterTest$skips repeated scans until work changes or maintenance is due$1$7" }, { "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionProcessorStarter__TestContext001_BeanDefinitions" @@ -2698,6 +3506,7 @@ { "name": "cleanUp", "parameterTypes": [ + "long", "kotlin.coroutines.Continuation" ] }, @@ -2738,6 +3547,36 @@ { "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionService$save$1" }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionServiceTest", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "cleans up at most the requested unchecked transactions", + "parameterTypes": [] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionServiceTest$cleans up at most the requested unchecked transactions$1", + "methods": [ + { + "name": "invokeSuspend", + "parameterTypes": [ + "java.lang.Object" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionServiceTest$cleans up at most the requested unchecked transactions$1$1" + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionServiceTest$cleans up at most the requested unchecked transactions$1$2" + }, { "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransactionService__TestContext001_BeanDefinitions" }, @@ -2745,7 +3584,28 @@ "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransaction__Accessor_eie46v" }, { - "type": "cash.atto.node.bootstrap.unchecked.UncheckedTransaction__Instantiator_eie46v" + "type": "cash.atto.node.bootstrap.unchecked.UncheckedWorkTracker", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "process", + "parameterTypes": [ + "cash.atto.node.account.AccountUpdated" + ] + }, + { + "name": "process", + "parameterTypes": [ + "cash.atto.node.bootstrap.UncheckedTransactionSaved" + ] + } + ] + }, + { + "type": "cash.atto.node.bootstrap.unchecked.UncheckedWorkTracker__TestContext001_BeanDefinitions" }, { "type": "cash.atto.node.convertion.ConversionsConfiguration", @@ -4825,7 +5685,26 @@ "type": "cash.atto.node.network.peer.PeerStepDefinition$startPeer$1" }, { - "type": "cash.atto.node.receivable.Receivable" + "type": "cash.atto.node.receivable.Receivable", + "methods": [ + { + "name": "", + "parameterTypes": [ + "cash.atto.commons.AttoHash", + "cash.atto.commons.AttoNetwork", + "cash.atto.commons.AttoVersion", + "cash.atto.commons.AttoAlgorithm", + "cash.atto.commons.AttoPublicKey", + "java.time.Instant", + "cash.atto.commons.AttoAlgorithm", + "cash.atto.commons.AttoPublicKey", + "cash.atto.commons.AttoAmount", + "java.time.Instant", + "int", + "kotlin.jvm.internal.DefaultConstructorMarker" + ] + } + ] }, { "type": "cash.atto.node.receivable.ReceivableBulkRepository", @@ -5051,14 +5930,8 @@ { "type": "cash.atto.node.receivable.Receivable__Accessor_f5qdgn" }, - { - "type": "cash.atto.node.receivable.Receivable__Instantiator_f5qdgn", - "methods": [ - { - "name": "", - "parameterTypes": [] - } - ] + { + "type": "cash.atto.node.receivable.Receivable__Instantiator_f5qdgn" }, { "type": "cash.atto.node.signature.MeteredAttoSigner" @@ -5493,9 +6366,6 @@ { "type": "cash.atto.node.transaction.PublicKeyHeight__Accessor_5yw21y" }, - { - "type": "cash.atto.node.transaction.PublicKeyHeight__Instantiator_5yw21y" - }, { "type": "cash.atto.node.transaction.Transaction" }, @@ -6323,9 +7193,6 @@ { "type": "cash.atto.node.transaction.Transaction__Accessor_fbvbqx" }, - { - "type": "cash.atto.node.transaction.Transaction__Instantiator_fbvbqx" - }, { "type": "cash.atto.node.transaction.prioritization.TransactionPrioritizationInfoContributor", "methods": [ @@ -6921,28 +7788,28 @@ "type": "cash.atto.node.unchecked.UncheckedTransactionStepDefinition$hasTransaction$$inlined$bodyToMono$1" }, { - "type": "cash.atto.node.vote.MissingVote" - }, - { - "type": "cash.atto.node.vote.MissingVote__Accessor_9ys8tp" - }, - { - "type": "cash.atto.node.vote.MissingVote__Instantiator_9ys8tp", + "type": "cash.atto.node.vote.MissingVote", "methods": [ { "name": "", - "parameterTypes": [] + "parameterTypes": [ + "cash.atto.commons.AttoHash", + "cash.atto.commons.AttoPublicKey" + ] } ] }, { - "type": "cash.atto.node.vote.PublicKeyHash" + "type": "cash.atto.node.vote.MissingVote__Accessor_9ys8tp" }, { - "type": "cash.atto.node.vote.PublicKeyHash__Accessor_s3dk6x" + "type": "cash.atto.node.vote.MissingVote__Instantiator_9ys8tp" + }, + { + "type": "cash.atto.node.vote.PublicKeyHash" }, { - "type": "cash.atto.node.vote.PublicKeyHash__Instantiator_s3dk6x" + "type": "cash.atto.node.vote.PublicKeyHash__Accessor_s3dk6x" }, { "type": "cash.atto.node.vote.StaleVoteBlock" @@ -7180,9 +8047,6 @@ { "type": "cash.atto.node.vote.StaleVoteBlock__Accessor_os7gk9" }, - { - "type": "cash.atto.node.vote.StaleVoteBlock__Instantiator_os7gk9" - }, { "type": "cash.atto.node.vote.Vote" }, @@ -7349,6 +8213,12 @@ "cash.atto.node.vote.VoteReceived" ] }, + { + "name": "process", + "parameterTypes": [ + "cash.atto.node.vote.VoteRejected" + ] + }, { "name": "process", "parameterTypes": [ @@ -7672,9 +8542,6 @@ { "type": "cash.atto.node.vote.Vote__Accessor_rljuqv" }, - { - "type": "cash.atto.node.vote.Vote__Instantiator_rljuqv" - }, { "type": "cash.atto.node.vote.convertion.VoteConverter", "methods": [ @@ -8239,9 +9106,6 @@ { "type": "cash.atto.node.vote.weight.Weight__Accessor_szje7" }, - { - "type": "cash.atto.node.vote.weight.Weight__Instantiator_szje7" - }, { "type": "cash.atto.protocol.AttoBootstrapTransactionPush" }, @@ -10769,6 +11633,17 @@ } ] }, + { + "type": "com.mysql.cj.exceptions.CJException", + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, { "type": "com.mysql.cj.jdbc.AbandonedConnectionCleanupThread" }, @@ -11875,14 +12750,6 @@ { "type": "io.ktor.client.HttpClient" }, - { - "type": "io.ktor.client.HttpClient$execute$1", - "fields": [ - { - "name": "label" - } - ] - }, { "type": "io.ktor.client.HttpClientConfig" }, @@ -11892,30 +12759,6 @@ { "type": "io.ktor.client.content.ProgressListener" }, - { - "type": "io.ktor.client.engine.HttpClientEngine$executeWithinCallContext$1", - "fields": [ - { - "name": "label" - } - ] - }, - { - "type": "io.ktor.client.engine.HttpClientEngine$executeWithinCallContext$2", - "fields": [ - { - "name": "label" - } - ] - }, - { - "type": "io.ktor.client.engine.HttpClientEngine$install$1", - "fields": [ - { - "name": "label" - } - ] - }, { "type": "io.ktor.client.engine.HttpClientEngineBase" }, @@ -11925,44 +12768,12 @@ { "type": "io.ktor.client.engine.HttpClientEngineConfig" }, - { - "type": "io.ktor.client.engine.cio.CIOEngine$execute$1", - "fields": [ - { - "name": "label" - } - ] - }, { "type": "io.ktor.client.engine.cio.CIOEngineContainer" }, { "type": "io.ktor.client.engine.cio.Endpoint" }, - { - "type": "io.ktor.client.engine.cio.Endpoint$connect$1", - "fields": [ - { - "name": "label" - } - ] - }, - { - "type": "io.ktor.client.engine.cio.Endpoint$execute$1", - "fields": [ - { - "name": "label" - } - ] - }, - { - "type": "io.ktor.client.engine.cio.Endpoint$makeDedicatedRequest$1", - "fields": [ - { - "name": "label" - } - ] - }, { "type": "io.ktor.client.engine.cio.Endpoint$timeout$1", "fields": [ @@ -11971,30 +12782,6 @@ } ] }, - { - "type": "io.ktor.client.plugins.HttpCallValidatorKt$HttpCallValidator$2$2", - "fields": [ - { - "name": "label" - } - ] - }, - { - "type": "io.ktor.client.plugins.HttpRedirectKt$HttpRedirect$2$1", - "fields": [ - { - "name": "label" - } - ] - }, - { - "type": "io.ktor.client.plugins.HttpRequestLifecycleKt$HttpRequestLifecycle$1$1", - "fields": [ - { - "name": "label" - } - ] - }, { "type": "io.ktor.client.plugins.HttpRetryDelayContext" }, @@ -12007,22 +12794,6 @@ { "type": "io.ktor.client.plugins.HttpSend" }, - { - "type": "io.ktor.client.plugins.HttpSend$DefaultSender$execute$1", - "fields": [ - { - "name": "label" - } - ] - }, - { - "type": "io.ktor.client.plugins.HttpSend$Plugin$install$1", - "fields": [ - { - "name": "label" - } - ] - }, { "type": "io.ktor.client.plugins.HttpTimeoutConfig" }, @@ -12034,36 +12805,12 @@ } ] }, - { - "type": "io.ktor.client.plugins.RequestError$install$1", - "fields": [ - { - "name": "label" - } - ] - }, - { - "type": "io.ktor.client.plugins.SetupRequestContext$install$1", - "fields": [ - { - "name": "label" - } - ] - }, { "type": "io.ktor.client.plugins.api.ClientPluginImpl" }, { "type": "io.ktor.client.plugins.api.ClientPluginInstance" }, - { - "type": "io.ktor.client.plugins.api.Send$install$1", - "fields": [ - { - "name": "label" - } - ] - }, { "type": "io.ktor.client.plugins.websocket.BuildersKt$webSocketSession$2", "fields": [ @@ -12078,14 +12825,6 @@ { "type": "io.ktor.client.plugins.websocket.WebSockets" }, - { - "type": "io.ktor.client.plugins.websocket.WebSockets$Plugin$install$1", - "fields": [ - { - "name": "label" - } - ] - }, { "type": "io.ktor.client.request.HttpRequest" }, @@ -12098,14 +12837,6 @@ { "type": "io.ktor.client.statement.HttpResponse" }, - { - "type": "io.ktor.client.statement.HttpStatement$fetchStreamingResponse$1", - "fields": [ - { - "name": "label" - } - ] - }, { "type": "io.ktor.http.ContentType" }, @@ -12458,14 +13189,6 @@ { "type": "io.ktor.util.internal.LockFreeLinkedListNode" }, - { - "type": "io.ktor.util.pipeline.DebugPipelineContext$proceedLoop$1", - "fields": [ - { - "name": "label" - } - ] - }, { "type": "io.ktor.util.reflect.TypeInfo" }, @@ -12526,6 +13249,14 @@ { "type": "io.ktor.websocket.DefaultWebSocketSessionImpl" }, + { + "type": "io.ktor.websocket.DefaultWebSocketSessionImpl$runIncomingProcessor$1", + "fields": [ + { + "name": "label" + } + ] + }, { "type": "io.ktor.websocket.DefaultWebSocketSessionImpl$runOutgoingProcessor$1", "fields": [ @@ -12626,6 +13357,9 @@ { "type": "io.micrometer.core.instrument.Clock$1" }, + { + "type": "io.micrometer.core.instrument.Counter" + }, { "type": "io.micrometer.core.instrument.DistributionSummary" }, @@ -12785,6 +13519,14 @@ { "type": "io.micrometer.tracing.Tracer" }, + { + "type": "io.mockk.CoFunctionAnswer$answer$1", + "fields": [ + { + "name": "label" + } + ] + }, { "type": "io.mockk.JvmCoroutineCall", "methods": [ @@ -12796,6 +13538,14 @@ } ] }, + { + "type": "io.mockk.MockKStubScope$coAnswers$1", + "fields": [ + { + "name": "label" + } + ] + }, { "type": "io.mockk.impl.annotations.InjectMockKs", "methods": [ @@ -12879,67 +13629,67 @@ "type": "io.mockk.proxy.jvm.dispatcher.JvmMockKWeakMap$WeakKey" }, { - "type": "io.mockk.renamed.java.util.Collection$Subclass7" + "type": "io.mockk.renamed.java.util.Collection$Subclass4" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4" + "type": "io.mockk.renamed.java.util.List$Subclass13" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$4cscpe10" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$4cscpe10" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$5j4bem00" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$5j4bem00" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$61en0h10" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$61en0h10" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$6n8od330" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$6n8od330" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$7m9oaq00" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$7m9oaq00" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$9pqdof10" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$9pqdof10" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$b0v96f10" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$b0v96f10" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$bcfo1810" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$bcfo1810" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$g7qoll10" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$g7qoll10" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$ialm8210" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$ialm8210" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$meruac30" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$meruac30" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$mf9m8g30" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$mf9m8g30" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$pjdfa310" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$pjdfa310" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$plikpt30" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$plikpt30" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$q3087630" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$q3087630" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$sgdndr30" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$sgdndr30" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$snj05d20" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$snj05d20" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$t8gh0920" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$t8gh0920" }, { - "type": "io.mockk.renamed.java.util.List$Subclass4$auxiliary$u4tse900" + "type": "io.mockk.renamed.java.util.List$Subclass13$auxiliary$u4tse900" }, { "type": "io.netty.buffer.AbstractByteBufAllocator" @@ -13269,57 +14019,117 @@ ] }, { - "type": "io.r2dbc.postgresql.codec.Box" + "type": "io.r2dbc.postgresql.codec.Box" + }, + { + "type": "io.r2dbc.postgresql.codec.Circle" + }, + { + "type": "io.r2dbc.postgresql.codec.Interval" + }, + { + "type": "io.r2dbc.postgresql.codec.Json" + }, + { + "type": "io.r2dbc.postgresql.codec.Line" + }, + { + "type": "io.r2dbc.postgresql.codec.Lseg" + }, + { + "type": "io.r2dbc.postgresql.codec.Path" + }, + { + "type": "io.r2dbc.postgresql.codec.Point" + }, + { + "type": "io.r2dbc.postgresql.codec.Polygon" + }, + { + "type": "io.r2dbc.proxy.ProxyConnectionFactory" + }, + { + "type": "io.r2dbc.spi.Batch" + }, + { + "type": "io.r2dbc.spi.Closeable" + }, + { + "type": "io.r2dbc.spi.Connection", + "methods": [ + { + "name": "createStatement", + "parameterTypes": [ + "java.lang.String" + ] + } + ] + }, + { + "type": "io.r2dbc.spi.ConnectionFactory" + }, + { + "type": "io.r2dbc.spi.ConnectionFactory$Subclass7" + }, + { + "type": "io.r2dbc.spi.ConnectionFactoryMetadata" + }, + { + "type": "io.r2dbc.spi.ConnectionFactoryMetadata$Subclass8" + }, + { + "type": "io.r2dbc.spi.ConnectionFactoryOptions" + }, + { + "type": "io.r2dbc.spi.ConnectionMetadata" }, { - "type": "io.r2dbc.postgresql.codec.Circle" + "type": "io.r2dbc.spi.IsolationLevel" }, { - "type": "io.r2dbc.postgresql.codec.Interval" + "type": "io.r2dbc.spi.Nullable" }, { - "type": "io.r2dbc.postgresql.codec.Json" + "type": "io.r2dbc.spi.Readable" }, { - "type": "io.r2dbc.postgresql.codec.Line" + "type": "io.r2dbc.spi.Result" }, { - "type": "io.r2dbc.postgresql.codec.Lseg" + "type": "io.r2dbc.spi.Result$Message" }, { - "type": "io.r2dbc.postgresql.codec.Path" + "type": "io.r2dbc.spi.Result$OutSegment" }, { - "type": "io.r2dbc.postgresql.codec.Point" + "type": "io.r2dbc.spi.Result$RowSegment" }, { - "type": "io.r2dbc.postgresql.codec.Polygon" + "type": "io.r2dbc.spi.Result$Segment" }, { - "type": "io.r2dbc.proxy.ProxyConnectionFactory" + "type": "io.r2dbc.spi.Result$Subclass12" }, { - "type": "io.r2dbc.spi.Closeable" + "type": "io.r2dbc.spi.Result$UpdateCount" }, { - "type": "io.r2dbc.spi.Connection", - "methods": [ - { - "name": "createStatement", - "parameterTypes": [ - "java.lang.String" - ] - } - ] + "type": "io.r2dbc.spi.Row" }, { - "type": "io.r2dbc.spi.ConnectionFactory" + "type": "io.r2dbc.spi.RowMetadata" }, { - "type": "io.r2dbc.spi.ConnectionFactoryOptions" + "type": "io.r2dbc.spi.Statement" }, { - "type": "io.r2dbc.spi.Nullable" + "type": "io.r2dbc.spi.Statement$Subclass11" + }, + { + "type": "io.r2dbc.spi.TransactionDefinition" + }, + { + "type": "io.r2dbc.spi.ValidationDepth" }, { "type": "io.r2dbc.spi.Wrapped" @@ -15789,17 +16599,6 @@ { "type": "java.math.BigInteger" }, - { - "type": "java.net.ConnectException", - "methods": [ - { - "name": "", - "parameterTypes": [ - "java.lang.String" - ] - } - ] - }, { "type": "java.net.InetAddress" }, @@ -15820,9 +16619,6 @@ "type": "java.net.PortUnreachableException", "jniAccessible": true }, - { - "type": "java.net.SocketException" - }, { "type": "java.net.StandardSocketOptions" }, @@ -16173,12 +16969,21 @@ "type": "java.util.concurrent.locks.ReentrantLock$Sync", "serializable": true }, + { + "type": "java.util.function.BiFunction" + }, { "type": "java.util.function.BiPredicate" }, { "type": "java.util.function.Consumer" }, + { + "type": "java.util.function.Function" + }, + { + "type": "java.util.function.Predicate" + }, { "type": "java.util.function.Supplier" }, @@ -16387,9 +17192,6 @@ { "type": "kotlin.annotation.AnnotationTarget" }, - { - "type": "kotlin.annotation.AnnotationTarget[]" - }, { "type": "kotlin.annotation.MustBeDocumented" }, @@ -16450,6 +17252,9 @@ { "type": "kotlin.coroutines.SuspendFunction0" }, + { + "type": "kotlin.coroutines.intrinsics.CoroutineSingletons" + }, { "type": "kotlin.coroutines.jvm.internal.BaseContinuationImpl" }, @@ -16592,6 +17397,14 @@ { "type": "kotlinx.coroutines.CoroutineScope" }, + { + "type": "kotlinx.coroutines.DelayKt$awaitCancellation$1", + "fields": [ + { + "name": "label" + } + ] + }, { "type": "kotlinx.coroutines.DispatchedCoroutine" }, @@ -16659,6 +17472,9 @@ { "type": "kotlinx.coroutines.flow.ChannelAsFlow" }, + { + "type": "kotlinx.coroutines.flow.EmptyFlow" + }, { "type": "kotlinx.coroutines.flow.Flow" }, @@ -16726,6 +17542,9 @@ { "type": "kotlinx.coroutines.flow.MutableSharedFlow" }, + { + "type": "kotlinx.coroutines.flow.SafeFlow" + }, { "type": "kotlinx.coroutines.flow.SharedFlowImpl$collect$1", "fields": [ @@ -17279,811 +18098,468 @@ "type": "net.bytebuddy.implementation.bind.annotation.SuperCallHandle" }, { - "type": "net.bytebuddy.implementation.bind.annotation.SuperMethod" - }, - { - "type": "net.bytebuddy.implementation.bind.annotation.SuperMethodHandle" - }, - { - "type": "net.bytebuddy.implementation.bind.annotation.This", - "methods": [ - { - "name": "optional", - "parameterTypes": [] - } - ] - }, - { - "type": "net.bytebuddy.jar.asmjdkbridge.JdkClassReader" - }, - { - "type": "net.bytebuddy.utility.Invoker" - }, - { - "type": "net.bytebuddy.utility.Invoker$Dispatcher", - "methods": [ - { - "name": "", - "parameterTypes": [] - } - ] - }, - { - "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher" - }, - { - "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher$OfClassDesc" - }, - { - "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher$OfDirectMethodHandleDesc" - }, - { - "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher$OfDirectMethodHandleDesc$ForKind" - }, - { - "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher$OfDynamicConstantDesc" - }, - { - "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher$OfMethodHandleDesc" - }, - { - "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher$OfMethodTypeDesc" - }, - { - "type": "net.bytebuddy.utility.JavaModule$Module" - }, - { - "type": "net.bytebuddy.utility.JavaModule$Resolver" - }, - { - "type": "oracle.jdbc.OracleConnection" - }, - { - "type": "oracle.ucp.jdbc.PoolDataSource" - }, - { - "type": "oracle.ucp.jdbc.PoolDataSourceImpl" - }, - { - "type": "org.HdrHistogram.AbstractHistogram" - }, - { - "type": "org.HdrHistogram.ConcurrentHistogram", - "methods": [ - { - "name": "", - "parameterTypes": [ - "long", - "long", - "int" - ] - }, - { - "name": "", - "parameterTypes": [ - "org.HdrHistogram.AbstractHistogram" - ] - } - ] - }, - { - "type": "org.HdrHistogram.DoubleRecorder[]" - }, - { - "type": "org.HdrHistogram.Histogram", - "methods": [ - { - "name": "", - "parameterTypes": [ - "long", - "long", - "int" - ] - } - ] - }, - { - "type": "org.HdrHistogram.WriterReaderPhaser" - }, - { - "type": "org.aopalliance.aop.Advice" - }, - { - "type": "org.aopalliance.intercept.Interceptor" - }, - { - "type": "org.aopalliance.intercept.MethodInterceptor" - }, - { - "type": "org.apache.commons.dbcp2.BasicDataSource" - }, - { - "type": "org.apache.commons.logging.LogFactory" - }, - { - "type": "org.apache.commons.logging.impl.Slf4jLogFactory", - "methods": [ - { - "name": "", - "parameterTypes": [] - } - ] - }, - { - "type": "org.apache.commons.logging.impl.WeakHashtable", - "methods": [ - { - "name": "", - "parameterTypes": [] - } - ] - }, - { - "type": "org.apache.el.ExpressionFactoryImpl", - "methods": [ - { - "name": "", - "parameterTypes": [] - } - ] - }, - { - "type": "org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient" - }, - { - "type": "org.apache.logging.log4j.core.LoggerContext" - }, - { - "type": "org.apache.logging.log4j.core.impl.Log4jContextFactory" - }, - { - "type": "org.apache.logging.log4j.util.EnvironmentPropertySource" - }, - { - "type": "org.apache.logging.log4j.util.SystemPropertiesPropertySource" - }, - { - "type": "org.apache.logging.slf4j.SLF4JProvider" - }, - { - "type": "org.apache.tomcat.jdbc.pool.DataSource" - }, - { - "type": "org.apiguardian.api.API" - }, - { - "type": "org.apiguardian.api.API$Status" - }, - { - "type": "org.aspectj.weaver.Advice" - }, - { - "type": "org.awaitility.core.ConditionFactory$1" - }, - { - "type": "org.brotli.dec.BrotliInputStream" - }, - { - "type": "org.crac.Resource" - }, - { - "type": "org.eclipse.collections.api.list.ImmutableList" - }, - { - "type": "org.eclipse.core.runtime.FileLocator" - }, - { - "type": "org.eclipse.jetty.client.HttpClient" + "type": "net.bytebuddy.implementation.bind.annotation.SuperMethod" }, { - "type": "org.eclipse.jetty.ee11.websocket.server.JettyWebSocketServerContainer" + "type": "net.bytebuddy.implementation.bind.annotation.SuperMethodHandle" }, { - "type": "org.eclipse.jetty.websocket.server.ServerWebSocketContainer" + "type": "net.bytebuddy.implementation.bind.annotation.This", + "methods": [ + { + "name": "optional", + "parameterTypes": [] + } + ] }, { - "type": "org.flywaydb.core.Flyway" + "type": "net.bytebuddy.jar.asmjdkbridge.JdkClassReader" }, { - "type": "org.flywaydb.core.api.migration.baseline.BaselineAppliedMigration" + "type": "net.bytebuddy.utility.Invoker" }, { - "type": "org.flywaydb.core.api.migration.baseline.BaselineMigrationConfigurationExtension", + "type": "net.bytebuddy.utility.Invoker$Dispatcher", "methods": [ { "name": "", "parameterTypes": [] - }, - { - "name": "getBaselineMigrationPrefix", - "parameterTypes": [] - }, - { - "name": "setBaselineMigrationPrefix", - "parameterTypes": [ - "java.lang.String" - ] } ] }, { - "type": "org.flywaydb.core.api.migration.baseline.BaselineMigrationResolver" + "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher" }, { - "type": "org.flywaydb.core.api.migration.baseline.BaselineResourceTypeProvider" + "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher$OfClassDesc" }, { - "type": "org.flywaydb.core.extensibility.ConfigurationExtension" + "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher$OfDirectMethodHandleDesc" }, { - "type": "org.flywaydb.core.extensibility.Plugin" + "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher$OfDirectMethodHandleDesc$ForKind" }, { - "type": "org.flywaydb.core.internal.NullFlywayTelemetryManager" + "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher$OfDynamicConstantDesc" }, { - "type": "org.flywaydb.core.internal.configuration.resolvers.EnvironmentProvisionerNone" + "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher$OfMethodHandleDesc" }, { - "type": "org.flywaydb.core.internal.configuration.resolvers.EnvironmentVariableResolver" + "type": "net.bytebuddy.utility.JavaConstant$Simple$Dispatcher$OfMethodTypeDesc" }, { - "type": "org.flywaydb.core.internal.configuration.resolvers.PlaceholderPropertyResolver" + "type": "net.bytebuddy.utility.JavaModule$Module" }, { - "type": "org.flywaydb.core.internal.database.base.TestContainersDatabaseType" + "type": "net.bytebuddy.utility.JavaModule$Resolver" }, { - "type": "org.flywaydb.core.internal.database.h2.H2DatabaseType" + "type": "oracle.jdbc.OracleConnection" }, { - "type": "org.flywaydb.core.internal.database.sqlite.SQLiteDatabaseType" + "type": "oracle.ucp.jdbc.PoolDataSource" }, { - "type": "org.flywaydb.core.internal.exception.sqlExceptions.FlywaySqlNoDriversForInteractiveAuthException", - "methods": [ - { - "name": "isFlywaySpecificVersionOf", - "parameterTypes": [ - "java.sql.SQLException" - ] - } - ] + "type": "oracle.ucp.jdbc.PoolDataSourceImpl" }, { - "type": "org.flywaydb.core.internal.exception.sqlExceptions.FlywaySqlNoIntegratedAuthException", + "type": "org.HdrHistogram.AbstractHistogram" + }, + { + "type": "org.HdrHistogram.ConcurrentHistogram", "methods": [ { - "name": "isFlywaySpecificVersionOf", + "name": "", "parameterTypes": [ - "java.sql.SQLException" + "long", + "long", + "int" ] - } - ] - }, - { - "type": "org.flywaydb.core.internal.exception.sqlExceptions.FlywaySqlServerUntrustedCertificateSqlException", - "methods": [ + }, { - "name": "isFlywaySpecificVersionOf", + "name": "", "parameterTypes": [ - "java.sql.SQLException" + "org.HdrHistogram.AbstractHistogram" ] } ] }, { - "type": "org.flywaydb.core.internal.jdbc.ErrorOverrideInitializerStub" + "type": "org.HdrHistogram.DoubleRecorder[]" }, { - "type": "org.flywaydb.core.internal.logging.slf4j.Slf4jLogCreator", + "type": "org.HdrHistogram.Histogram", "methods": [ { "name": "", - "parameterTypes": [] + "parameterTypes": [ + "long", + "long", + "int" + ] } ] }, { - "type": "org.flywaydb.core.internal.proprietaryStubs.AuthCommandExtensionStub" - }, - { - "type": "org.flywaydb.core.internal.proprietaryStubs.CheckCommandExtensionStub" - }, - { - "type": "org.flywaydb.core.internal.proprietaryStubs.DeployCommandExtensionStub" - }, - { - "type": "org.flywaydb.core.internal.proprietaryStubs.DiffCommandExtensionStub" - }, - { - "type": "org.flywaydb.core.internal.proprietaryStubs.DiffTextCommandExtensionStub" - }, - { - "type": "org.flywaydb.core.internal.proprietaryStubs.GenerateCommandExtensionStub" - }, - { - "type": "org.flywaydb.core.internal.proprietaryStubs.LicensingConfigurationExtensionStub" + "type": "org.HdrHistogram.WriterReaderPhaser" }, { - "type": "org.flywaydb.core.internal.proprietaryStubs.ModelCommandExtensionStub" + "type": "org.aopalliance.aop.Advice" }, { - "type": "org.flywaydb.core.internal.proprietaryStubs.OfflinePermitConfigurationExtensionStub" + "type": "org.aopalliance.intercept.Interceptor" }, { - "type": "org.flywaydb.core.internal.proprietaryStubs.PATTokenConfigurationExtensionStub" + "type": "org.aopalliance.intercept.MethodInterceptor" }, { - "type": "org.flywaydb.core.internal.proprietaryStubs.PrepareCommandExtensionStub" + "type": "org.apache.commons.dbcp2.BasicDataSource" }, { - "type": "org.flywaydb.core.internal.proprietaryStubs.UndoCommandExtensionStub" + "type": "org.apache.commons.logging.LogFactory" }, { - "type": "org.flywaydb.core.internal.publishing.PublishingConfigurationExtension", + "type": "org.apache.commons.logging.impl.Slf4jLogFactory", "methods": [ { "name": "", "parameterTypes": [] - }, + } + ] + }, + { + "type": "org.apache.commons.logging.impl.WeakHashtable", + "methods": [ { - "name": "isCheckDriftOnMigrate", + "name": "", "parameterTypes": [] - }, + } + ] + }, + { + "type": "org.apache.el.ExpressionFactoryImpl", + "methods": [ { - "name": "isPublishResult", + "name": "", "parameterTypes": [] - }, - { - "name": "setCheckDriftOnMigrate", - "parameterTypes": [ - "boolean" - ] - }, - { - "name": "setPublishResult", - "parameterTypes": [ - "boolean" - ] } ] }, { - "type": "org.flywaydb.core.internal.resource.CoreResourceTypeProvider" + "type": "org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient" }, { - "type": "org.flywaydb.core.internal.scanner.classpath.ClasspathLocationHandlerImpl" + "type": "org.apache.logging.log4j.core.LoggerContext" }, { - "type": "org.flywaydb.core.internal.scanner.filesystem.FilesystemLocationHandler" + "type": "org.apache.logging.log4j.core.impl.Log4jContextFactory" }, { - "type": "org.flywaydb.core.internal.schemahistory.BaseAppliedMigration" + "type": "org.apache.logging.log4j.util.EnvironmentPropertySource" }, { - "type": "org.flywaydb.database.mysql.MySQLDatabaseType" + "type": "org.apache.logging.log4j.util.SystemPropertiesPropertySource" }, { - "type": "org.flywaydb.database.mysql.mariadb.MariaDBDatabaseType" + "type": "org.apache.logging.slf4j.SLF4JProvider" }, { - "type": "org.flywaydb.database.oracle.OracleConfigurationExtension" + "type": "org.apache.tomcat.jdbc.pool.DataSource" }, { - "type": "org.flywaydb.database.postgresql.PostgreSQLConfigurationExtension" + "type": "org.apiguardian.api.API" }, { - "type": "org.flywaydb.database.sqlserver.SQLServerConfigurationExtension" + "type": "org.apiguardian.api.API$Status" }, { - "type": "org.gradle.api.internal.file.temp.GradleUserHomeTemporaryFileProvider" + "type": "org.aspectj.weaver.Advice" }, { - "type": "org.gradle.api.internal.tasks.testing.TestDefinition" + "type": "org.awaitility.core.ConditionFactory$1" }, { - "type": "org.gradle.api.internal.tasks.testing.TestDefinitionProcessor", - "methods": [ - { - "name": "processTestDefinition", - "parameterTypes": [ - "org.gradle.api.internal.tasks.testing.TestDefinition" - ] - }, - { - "name": "startProcessing", - "parameterTypes": [ - "org.gradle.api.internal.tasks.testing.TestResultProcessor" - ] - }, - { - "name": "stop", - "parameterTypes": [] - } - ] + "type": "org.brotli.dec.BrotliInputStream" }, { - "type": "org.gradle.api.internal.tasks.testing.TestResultProcessor", - "methods": [ - { - "name": "completed", - "parameterTypes": [ - "java.lang.Object", - "org.gradle.api.internal.tasks.testing.TestCompleteEvent" - ] - }, - { - "name": "started", - "parameterTypes": [ - "org.gradle.api.internal.tasks.testing.TestDescriptorInternal", - "org.gradle.api.internal.tasks.testing.TestStartEvent" - ] - } - ] + "type": "org.crac.Resource" }, { - "type": "org.gradle.api.internal.tasks.testing.filter.TestFilterSpec", - "serializable": true + "type": "org.eclipse.collections.api.list.ImmutableList" }, { - "type": "org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformSpec", - "serializable": true + "type": "org.eclipse.core.runtime.FileLocator" }, { - "type": "org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestDefinitionProcessorFactory", - "serializable": true + "type": "org.eclipse.jetty.client.HttpClient" }, { - "type": "org.gradle.api.internal.tasks.testing.worker.RemoteTestDefinitionProcessor", - "methods": [ - { - "name": "processTestDefinition", - "parameterTypes": [ - "org.gradle.api.internal.tasks.testing.TestDefinition" - ] - }, - { - "name": "startProcessing", - "parameterTypes": [] - }, - { - "name": "stop", - "parameterTypes": [] - } - ] + "type": "org.eclipse.jetty.ee11.websocket.server.JettyWebSocketServerContainer" }, { - "type": "org.gradle.api.internal.tasks.testing.worker.TestWorker", - "serializable": true + "type": "org.eclipse.jetty.websocket.server.ServerWebSocketContainer" }, { - "type": "org.gradle.api.internal.tasks.testing.worker.TestWorker$State", - "serializable": true + "type": "org.flywaydb.core.Flyway" + }, + { + "type": "org.flywaydb.core.api.migration.baseline.BaselineAppliedMigration" }, { - "type": "org.gradle.api.internal.tasks.testing.worker.TestWorker$TestFrameworkServiceRegistry", + "type": "org.flywaydb.core.api.migration.baseline.BaselineMigrationConfigurationExtension", "methods": [ { - "name": "createActorFactory", - "parameterTypes": [ - "org.gradle.internal.concurrent.ExecutorFactory" - ] - }, - { - "name": "createClock", + "name": "", "parameterTypes": [] }, { - "name": "createExecutorFactory", + "name": "getBaselineMigrationPrefix", "parameterTypes": [] }, { - "name": "createIdGenerator", - "parameterTypes": [] - } - ] - }, - { - "type": "org.gradle.internal.file.StatStatistics$Collector" - }, - { - "type": "org.gradle.internal.logging.services.LoggingServiceRegistry", - "methods": [ - { - "name": "createLoggingManagerFactory", + "name": "setBaselineMigrationPrefix", "parameterTypes": [ - "org.gradle.internal.time.Clock" + "java.lang.String" ] - }, - { - "name": "createTimeProvider", - "parameterTypes": [] } ] }, { - "type": "org.gradle.internal.logging.services.LoggingServiceRegistry$CommandLineLogging" + "type": "org.flywaydb.core.api.migration.baseline.BaselineMigrationResolver" }, { - "type": "org.gradle.internal.logging.slf4j.StaticLoggerProvider" + "type": "org.flywaydb.core.api.migration.baseline.BaselineResourceTypeProvider" }, { - "type": "org.gradle.internal.nativeintegration.filesystem.services.FileSystemServices", - "methods": [ - { - "name": "configure", - "parameterTypes": [ - "org.gradle.internal.service.ServiceRegistration" - ] - } - ] + "type": "org.flywaydb.core.extensibility.ConfigurationExtension" }, { - "type": "org.gradle.internal.nativeintegration.filesystem.services.GenericFileSystem$Factory" + "type": "org.flywaydb.core.extensibility.Plugin" }, { - "type": "org.gradle.internal.nativeintegration.services.NativeServices", - "methods": [ - { - "name": "createNativeCapabilities", - "parameterTypes": [] - }, - { - "name": "createSystemInfo", - "parameterTypes": [ - "org.gradle.internal.os.OperatingSystem" - ] - } - ] + "type": "org.flywaydb.core.internal.NullFlywayTelemetryManager" }, { - "type": "org.gradle.internal.nativeintegration.services.NativeServices$1", - "methods": [ - { - "name": "configure", - "parameterTypes": [ - "org.gradle.internal.service.ServiceRegistration" - ] - } - ] + "type": "org.flywaydb.core.internal.configuration.resolvers.EnvironmentProvisionerNone" }, { - "type": "org.gradle.internal.nativeintegration.services.NativeServices$NativeFeatures$1$2" + "type": "org.flywaydb.core.internal.configuration.resolvers.EnvironmentVariableResolver" }, { - "type": "org.gradle.internal.remote.services.MessagingServices", - "methods": [ - { - "name": "createMessagingClient", - "parameterTypes": [ - "org.gradle.internal.remote.internal.OutgoingConnector", - "org.gradle.internal.concurrent.ExecutorFactory" - ] - }, - { - "name": "createOutgoingConnector", - "parameterTypes": [] - } - ] + "type": "org.flywaydb.core.internal.configuration.resolvers.PlaceholderPropertyResolver" }, { - "type": "org.gradle.internal.service.MethodHandleBasedServiceMethodFactory", - "methods": [ - { - "name": "", - "parameterTypes": [] - } - ] + "type": "org.flywaydb.core.internal.database.base.TestContainersDatabaseType" }, { - "type": "org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker", - "methods": [ - { - "name": "", - "parameterTypes": [ - "java.io.DataInputStream" - ] - } - ] + "type": "org.flywaydb.core.internal.database.h2.H2DatabaseType" }, { - "type": "org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker$2", + "type": "org.flywaydb.core.internal.database.sqlite.SQLiteDatabaseType" + }, + { + "type": "org.flywaydb.core.internal.exception.sqlExceptions.FlywaySqlNoDriversForInteractiveAuthException", "methods": [ { - "name": "configure", + "name": "isFlywaySpecificVersionOf", "parameterTypes": [ - "org.gradle.internal.service.ServiceRegistration" + "java.sql.SQLException" ] } ] }, { - "type": "org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker$WorkerServices", + "type": "org.flywaydb.core.internal.exception.sqlExceptions.FlywaySqlNoIntegratedAuthException", "methods": [ { - "name": "createGradleUserHomeDirProvider", - "parameterTypes": [] - }, - { - "name": "createWorkerDirectoryProvider", + "name": "isFlywaySpecificVersionOf", "parameterTypes": [ - "org.gradle.initialization.GradleUserHomeDirProvider" + "java.sql.SQLException" ] - }, - { - "name": "createWorkerLogEventListener", - "parameterTypes": [] } ] }, { - "type": "org.hibernate.validator.HibernateValidator" - }, - { - "type": "org.hibernate.validator.internal.engine.AbstractConfigurationImpl", + "type": "org.flywaydb.core.internal.exception.sqlExceptions.FlywaySqlServerUntrustedCertificateSqlException", "methods": [ { - "name": "externalClassLoader", + "name": "isFlywaySpecificVersionOf", "parameterTypes": [ - "java.lang.ClassLoader" + "java.sql.SQLException" ] } ] }, { - "type": "org.hibernate.validator.internal.engine.ConfigurationImpl" + "type": "org.flywaydb.core.internal.jdbc.ErrorOverrideInitializerStub" }, { - "type": "org.hibernate.validator.internal.util.logging.Log_$logger", + "type": "org.flywaydb.core.internal.logging.slf4j.Slf4jLogCreator", "methods": [ { "name": "", - "parameterTypes": [ - "org.jboss.logging.Logger" - ] + "parameterTypes": [] } ] }, { - "type": "org.hibernate.validator.internal.util.logging.Log_$logger_en" + "type": "org.flywaydb.core.internal.proprietaryStubs.AuthCommandExtensionStub" }, { - "type": "org.hibernate.validator.internal.util.logging.Messages_$bundle", - "fields": [ - { - "name": "INSTANCE" - } - ] + "type": "org.flywaydb.core.internal.proprietaryStubs.CheckCommandExtensionStub" }, { - "type": "org.hibernate.validator.internal.util.logging.Messages_$bundle_en" + "type": "org.flywaydb.core.internal.proprietaryStubs.DeployCommandExtensionStub" }, { - "type": "org.jboss.logging.Logger" + "type": "org.flywaydb.core.internal.proprietaryStubs.DiffCommandExtensionStub" }, { - "type": "org.jmolecules.ddd.annotation.Identity" + "type": "org.flywaydb.core.internal.proprietaryStubs.DiffTextCommandExtensionStub" }, { - "type": "org.jmolecules.ddd.types.Association" + "type": "org.flywaydb.core.internal.proprietaryStubs.GenerateCommandExtensionStub" }, { - "type": "org.jmolecules.spring.IdentifierToPrimitivesConverter" + "type": "org.flywaydb.core.internal.proprietaryStubs.LicensingConfigurationExtensionStub" }, { - "type": "org.joda.time.ReadableInstant" + "type": "org.flywaydb.core.internal.proprietaryStubs.ModelCommandExtensionStub" }, { - "type": "org.jspecify.annotations.NonNull" + "type": "org.flywaydb.core.internal.proprietaryStubs.OfflinePermitConfigurationExtensionStub" }, { - "type": "org.jspecify.annotations.NullMarked" + "type": "org.flywaydb.core.internal.proprietaryStubs.PATTokenConfigurationExtensionStub" }, { - "type": "org.jspecify.annotations.Nullable" + "type": "org.flywaydb.core.internal.proprietaryStubs.PrepareCommandExtensionStub" }, { - "type": "org.junit.AssumptionViolatedException" + "type": "org.flywaydb.core.internal.proprietaryStubs.UndoCommandExtensionStub" }, { - "type": "org.junit.internal.AssumptionViolatedException" + "type": "org.flywaydb.core.internal.publishing.PublishingConfigurationExtension", + "methods": [ + { + "name": "", + "parameterTypes": [] + }, + { + "name": "isCheckDriftOnMigrate", + "parameterTypes": [] + }, + { + "name": "isPublishResult", + "parameterTypes": [] + }, + { + "name": "setCheckDriftOnMigrate", + "parameterTypes": [ + "boolean" + ] + }, + { + "name": "setPublishResult", + "parameterTypes": [ + "boolean" + ] + } + ] }, { - "type": "org.junit.jupiter.api.AfterAll" + "type": "org.flywaydb.core.internal.resource.CoreResourceTypeProvider" }, { - "type": "org.junit.jupiter.api.BeforeAll" + "type": "org.flywaydb.core.internal.scanner.classpath.ClasspathLocationHandlerImpl" }, { - "type": "org.junit.jupiter.api.BeforeEach" + "type": "org.flywaydb.core.internal.scanner.filesystem.FilesystemLocationHandler" }, { - "type": "org.junit.jupiter.api.Test" + "type": "org.flywaydb.core.internal.schemahistory.BaseAppliedMigration" }, { - "type": "org.junit.jupiter.api.TestTemplate" + "type": "org.flywaydb.database.mysql.MySQLDatabaseType" }, { - "type": "org.junit.jupiter.api.Timeout" + "type": "org.flywaydb.database.mysql.mariadb.MariaDBDatabaseType" }, { - "type": "org.junit.jupiter.api.extension.ExtendWith" + "type": "org.flywaydb.database.oracle.OracleConfigurationExtension" }, { - "type": "org.junit.jupiter.engine.JupiterTestEngine" + "type": "org.flywaydb.database.postgresql.PostgreSQLConfigurationExtension" }, { - "type": "org.junit.jupiter.engine.extension.TimeoutInvocationFactory$SingleThreadExecutorResource", - "methods": [ - { - "name": "", - "parameterTypes": [] - } - ] + "type": "org.flywaydb.database.sqlserver.SQLServerConfigurationExtension" }, { - "type": "org.junit.jupiter.params.ParameterizedTest" + "type": "org.hibernate.validator.HibernateValidator" }, { - "type": "org.junit.jupiter.params.ParameterizedTestExtension", + "type": "org.hibernate.validator.internal.engine.AbstractConfigurationImpl", "methods": [ { - "name": "", - "parameterTypes": [] + "name": "externalClassLoader", + "parameterTypes": [ + "java.lang.ClassLoader" + ] } ] }, { - "type": "org.junit.jupiter.params.provider.AnnotationBasedArgumentsProvider" - }, - { - "type": "org.junit.jupiter.params.provider.ArgumentsProvider" - }, - { - "type": "org.junit.jupiter.params.provider.ArgumentsSource" + "type": "org.hibernate.validator.internal.engine.ConfigurationImpl" }, { - "type": "org.junit.jupiter.params.provider.MethodArgumentsProvider", + "type": "org.hibernate.validator.internal.util.logging.Log_$logger", "methods": [ { "name": "", - "parameterTypes": [] + "parameterTypes": [ + "org.jboss.logging.Logger" + ] } ] }, { - "type": "org.junit.jupiter.params.provider.MethodSource" + "type": "org.hibernate.validator.internal.util.logging.Log_$logger_en" }, { - "type": "org.junit.jupiter.params.support.AnnotationConsumer" + "type": "org.hibernate.validator.internal.util.logging.Messages_$bundle", + "fields": [ + { + "name": "INSTANCE" + } + ] }, { - "type": "org.junit.platform.commons.annotation.Testable" + "type": "org.hibernate.validator.internal.util.logging.Messages_$bundle_en" }, { - "type": "org.junit.platform.launcher.TestIdentifier" + "type": "org.jboss.logging.Logger" }, { - "type": "org.junit.platform.launcher.core.LauncherFactory" + "type": "org.jmolecules.ddd.annotation.Identity" }, { - "type": "org.junit.platform.launcher.listeners.UniqueIdTrackingListener" + "type": "org.jmolecules.ddd.types.Association" }, { - "type": "org.junit.platform.suite.api.IncludeEngines" + "type": "org.jmolecules.spring.IdentifierToPrimitivesConverter" }, { - "type": "org.junit.platform.suite.api.SelectPackages" + "type": "org.joda.time.ReadableInstant" }, { - "type": "org.junit.platform.suite.api.Suite" + "type": "org.jspecify.annotations.NonNull" }, { - "type": "org.junit.platform.suite.engine.SuiteTestEngine" + "type": "org.jspecify.annotations.NullMarked" }, { - "type": "org.junit.vintage.engine.VintageTestEngine" + "type": "org.jspecify.annotations.Nullable" }, { "type": "org.locationtech.jts.geom.Geometry" @@ -18100,6 +18576,12 @@ { "type": "org.reactivestreams.Publisher" }, + { + "type": "org.reactivestreams.Publisher$Subclass9" + }, + { + "type": "org.slf4j.Logger" + }, { "type": "org.slf4j.LoggerFactory" }, @@ -18109,9 +18591,15 @@ { "type": "org.slf4j.helpers.Log4jLoggerFactory" }, + { + "type": "org.slf4j.impl.StaticLoggerBinder" + }, { "type": "org.slf4j.spi.LocationAwareLogger" }, + { + "type": "org.slf4j.spi.SLF4JServiceProvider" + }, { "type": "org.springdoc.api.AbstractOpenApiResource" }, @@ -26283,6 +26771,21 @@ { "type": "org.testcontainers.dockerclient.UnixSocketClientProviderStrategy" }, + { + "type": "org.testcontainers.junit.jupiter.Container" + }, + { + "type": "org.testcontainers.junit.jupiter.Testcontainers" + }, + { + "type": "org.testcontainers.junit.jupiter.TestcontainersExtension", + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "org.testcontainers.lifecycle.Startable" }, @@ -26447,6 +26950,9 @@ { "type": "reactor.core.publisher.FluxIterable$IterableSubscription" }, + { + "type": "reactor.core.publisher.FluxJust" + }, { "type": "reactor.core.publisher.FluxOnErrorReturn$ReturnSubscriber" }, @@ -26507,6 +27013,9 @@ { "type": "reactor.core.publisher.MonoDelay$MonoDelayRunnable" }, + { + "type": "reactor.core.publisher.MonoEmpty" + }, { "type": "reactor.core.publisher.MonoFlatMap$FlatMapMain" }, @@ -26516,6 +27025,9 @@ { "type": "reactor.core.publisher.MonoIgnoreThen$ThenIgnoreMain" }, + { + "type": "reactor.core.publisher.MonoJust" + }, { "type": "reactor.core.publisher.MonoNext$NextSubscriber" }, @@ -27606,36 +28118,6 @@ ] } }, - { - "type": { - "proxy": [ - "org.gradle.api.internal.tasks.testing.TestDefinitionProcessor" - ] - } - }, - { - "type": { - "proxy": [ - "org.gradle.api.internal.tasks.testing.TestResultProcessor", - "org.gradle.internal.concurrent.ThreadSafe" - ] - } - }, - { - "type": { - "proxy": [ - "org.gradle.internal.logging.events.OutputEventListener" - ] - } - }, - { - "type": { - "proxy": [ - "org.gradle.process.internal.worker.child.WorkerLoggingProtocol", - "org.gradle.internal.concurrent.ThreadSafe" - ] - } - }, { "type": { "proxy": [ @@ -28322,7 +28804,22 @@ "glob": "cash/atto/node/bootstrap/discovery/DependencyDiscoverer.class" }, { - "glob": "cash/atto/node/bootstrap/discovery/DiscoveryProcessor.class" + "glob": "cash/atto/node/bootstrap/discovery/DiscoveryMetrics.class" + }, + { + "glob": "cash/atto/node/bootstrap/discovery/DiscoveryPersistenceWorker.class" + }, + { + "glob": "cash/atto/node/bootstrap/discovery/DiscoveryProperties$Companion.class" + }, + { + "glob": "cash/atto/node/bootstrap/discovery/DiscoveryProperties.class" + }, + { + "glob": "cash/atto/node/bootstrap/discovery/DiscoveryQueue.class" + }, + { + "glob": "cash/atto/node/bootstrap/discovery/DiscoveryQueueTest.class" }, { "glob": "cash/atto/node/bootstrap/discovery/DiscoveryTestController.class" @@ -28330,9 +28827,15 @@ { "glob": "cash/atto/node/bootstrap/discovery/GapDiscoverer.class" }, + { + "glob": "cash/atto/node/bootstrap/discovery/GapDiscovererTest.class" + }, { "glob": "cash/atto/node/bootstrap/discovery/LastDiscoverer.class" }, + { + "glob": "cash/atto/node/bootstrap/discovery/LastDiscovererTest.class" + }, { "glob": "cash/atto/node/bootstrap/discovery/SendDiscoverer.class" }, @@ -28345,18 +28848,27 @@ { "glob": "cash/atto/node/bootstrap/unchecked/UncheckedTransactionMetricProvider.class" }, + { + "glob": "cash/atto/node/bootstrap/unchecked/UncheckedTransactionMigrationTest.class" + }, { "glob": "cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessor.class" }, { "glob": "cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessorStarter.class" }, + { + "glob": "cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessorStarterTest.class" + }, { "glob": "cash/atto/node/bootstrap/unchecked/UncheckedTransactionRepository.class" }, { "glob": "cash/atto/node/bootstrap/unchecked/UncheckedTransactionService.class" }, + { + "glob": "cash/atto/node/bootstrap/unchecked/UncheckedWorkTracker.class" + }, { "glob": "cash/atto/node/convertion/ConversionsConfiguration.class" }, @@ -28771,6 +29283,9 @@ { "glob": "db/migration/V10__add_stale_vote_block.sql" }, + { + "glob": "db/migration/V11__add_unchecked_transaction_oldest_index.sql" + }, { "glob": "db/migration/V1__init.sql" }, @@ -28900,6 +29415,9 @@ { "glob": "kotlin/coroutines/coroutines.kotlin_builtins" }, + { + "glob": "kotlin/coroutines/intrinsics/intrinsics.kotlin_builtins" + }, { "glob": "kotlin/coroutines/jvm/internal/BaseContinuationImpl.class" }, @@ -30118,9 +30636,6 @@ { "glob": "org/springframework/data/mapping/PersistentPropertyAccessor.class" }, - { - "glob": "org/springframework/data/mapping/model/ClassGeneratingEntityInstantiator$ObjectInstantiator.class" - }, { "glob": "org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactoryBean.class" }, diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index fc8ef906..31c8f07c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -27,6 +27,14 @@ spring: atto: use-x-forwarded-for: ${ATTO_USE_X_FORWARDED_FOR:false} + bootstrap: + discovery: + capacity: ${ATTO_DISCOVERY_CAPACITY:10000} + headroom: ${ATTO_DISCOVERY_HEADROOM:2000} + batch-size: ${ATTO_DISCOVERY_BATCH_SIZE:1000} + idle-query-fallback-in-seconds: ${ATTO_DISCOVERY_IDLE_QUERY_FALLBACK_SECONDS:30} + retry-initial-backoff-in-seconds: ${ATTO_DISCOVERY_RETRY_INITIAL_BACKOFF_SECONDS:1} + retry-max-backoff-in-seconds: ${ATTO_DISCOVERY_RETRY_MAX_BACKOFF_SECONDS:30} signer: backend: ${ATTO_SIGNER_BACKEND:local} remote-url: ${ATTO_SIGNER_REMOTE_URL:} diff --git a/src/main/resources/db/migration/V11__add_unchecked_transaction_oldest_index.sql b/src/main/resources/db/migration/V11__add_unchecked_transaction_oldest_index.sql new file mode 100644 index 00000000..f3d5ef91 --- /dev/null +++ b/src/main/resources/db/migration/V11__add_unchecked_transaction_oldest_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX unchecked_transaction_oldest + ON unchecked_transaction (`timestamp`, public_key, height); diff --git a/src/test/kotlin/cash/atto/node/bootstrap/discovery/DependencyDiscovererTest.kt b/src/test/kotlin/cash/atto/node/bootstrap/discovery/DependencyDiscovererTest.kt new file mode 100644 index 00000000..fb24a5ae --- /dev/null +++ b/src/test/kotlin/cash/atto/node/bootstrap/discovery/DependencyDiscovererTest.kt @@ -0,0 +1,122 @@ +package cash.atto.node.bootstrap.discovery + +import cash.atto.commons.AttoAlgorithm +import cash.atto.commons.AttoAmount +import cash.atto.commons.AttoHash +import cash.atto.commons.AttoInstant +import cash.atto.commons.AttoNetwork +import cash.atto.commons.AttoPublicKey +import cash.atto.commons.AttoReceiveBlock +import cash.atto.commons.AttoSignature +import cash.atto.commons.AttoVote +import cash.atto.commons.AttoWork +import cash.atto.commons.toAttoHeight +import cash.atto.commons.toAttoVersion +import cash.atto.commons.toJavaInstant +import cash.atto.node.election.ElectionVoter +import cash.atto.node.transaction.Transaction +import cash.atto.node.transaction.TransactionRejectionReason +import cash.atto.node.vote.Vote +import cash.atto.node.vote.VoteDropReason +import cash.atto.node.vote.VoteDropped +import cash.atto.node.vote.weight.VoteWeighter +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import java.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class DependencyDiscovererTest { + @Test + fun `final consensus at capacity retains the holder when admission is cancelled`() = + runTest { + // Given + val discoveryQueue = mockk() + every { discoveryQueue.isAtCapacity() } returns true + coEvery { + discoveryQueue.queue(any(), DiscoverySource.DEPENDENCY) + } coAnswers { + awaitCancellation() + } + val voteWeighter = mockk() + every { voteWeighter.getMinimalConfirmationWeight() } returns ElectionVoter.MIN_WEIGHT + val discoverer = DependencyDiscoverer(voteWeighter, discoveryQueue) + val transaction = transaction(1) + val finalVote = finalVote(transaction.hash) + val voteDropped = VoteDropped(finalVote, VoteDropReason.TRANSACTION_DROPPED) + discoverer.add(TransactionRejectionReason.PREVIOUS_NOT_FOUND, transaction) + + // When + val admission = + launch { + discoverer.process(voteDropped) + } + runCurrent() + + // Then + assertFalse(admission.isCompleted) + coVerify(exactly = 1) { + discoveryQueue.queue( + match { it.transaction.hash == transaction.hash }, + DiscoverySource.DEPENDENCY, + ) + } + + // When + admission.cancelAndJoin() + coEvery { + discoveryQueue.queue(any(), DiscoverySource.DEPENDENCY) + } returns true + discoverer.process(voteDropped) + + // Then + coVerify(exactly = 2) { + discoveryQueue.queue( + match { it.transaction.hash == transaction.hash }, + DiscoverySource.DEPENDENCY, + ) + } + } + + private fun transaction(marker: Byte): Transaction = + Transaction( + block = + AttoReceiveBlock( + version = 0U.toAttoVersion(), + network = AttoNetwork.LOCAL, + algorithm = AttoAlgorithm.V1, + publicKey = AttoPublicKey(ByteArray(32) { marker }), + height = 2U.toAttoHeight(), + balance = AttoAmount.MAX, + timestamp = AttoInstant.now(), + previous = AttoHash(ByteArray(32) { (marker + 1).toByte() }), + sendHashAlgorithm = AttoAlgorithm.V1, + sendHash = AttoHash(ByteArray(32) { (marker + 2).toByte() }), + ), + signature = AttoSignature(ByteArray(64) { (marker + 3).toByte() }), + work = AttoWork(ByteArray(8) { (marker + 4).toByte() }), + ) + + private fun finalVote(blockHash: AttoHash): Vote = + Vote( + hash = AttoHash(ByteArray(32) { 5 }), + version = 0U.toAttoVersion(), + algorithm = AttoAlgorithm.V1, + publicKey = AttoPublicKey(ByteArray(32) { 6 }), + blockAlgorithm = AttoAlgorithm.V1, + blockHash = blockHash, + timestamp = AttoVote.finalTimestamp.toJavaInstant(), + signature = AttoSignature(ByteArray(64) { 7 }), + weight = ElectionVoter.MIN_WEIGHT, + receivedAt = Instant.now(), + ) +} diff --git a/src/test/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryQueueTest.kt b/src/test/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryQueueTest.kt new file mode 100644 index 00000000..04fc4be2 --- /dev/null +++ b/src/test/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryQueueTest.kt @@ -0,0 +1,426 @@ +package cash.atto.node.bootstrap.discovery + +import cash.atto.commons.AttoAlgorithm +import cash.atto.commons.AttoAmount +import cash.atto.commons.AttoHash +import cash.atto.commons.AttoInstant +import cash.atto.commons.AttoNetwork +import cash.atto.commons.AttoPublicKey +import cash.atto.commons.AttoReceiveBlock +import cash.atto.commons.AttoSignature +import cash.atto.commons.AttoWork +import cash.atto.commons.toAttoHeight +import cash.atto.commons.toAttoVersion +import cash.atto.node.EventPublisher +import cash.atto.node.bootstrap.TransactionDiscovered +import cash.atto.node.bootstrap.unchecked.UncheckedTransaction +import cash.atto.node.bootstrap.unchecked.UncheckedTransactionService +import cash.atto.node.transaction.Transaction +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.fail +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset + +@OptIn(ExperimentalCoroutinesApi::class) +class DiscoveryQueueTest { + @Test + fun `remaining target capacity is clamped at zero for suspended admissions`() = + runTest { + // Given + val service = mockk(relaxed = true) + val fixture = fixture(service, properties(capacity = 2, batchSize = 1)) + val events = (20..22).map { event(it.toByte()) } + + // When + fixture.queue.queue(events[0], DiscoverySource.GAP) + fixture.queue.queue(events[1], DiscoverySource.GAP) + val suspended = + launch { + fixture.queue.queue(events[2], DiscoverySource.GAP) + } + runCurrent() + + // Then + assertEquals(0, fixture.queue.remainingTargetCapacity()) + + // When + suspended.cancelAndJoin() + fixture.worker.flush() + + // Then + assertEquals(2, fixture.queue.remainingTargetCapacity()) + } + + @Test + fun `capacity suspends the next admission without blocking unrelated work and preserves FIFO order`() = + runTest { + // Given + val savedHashes = mutableListOf() + val service = mockk() + coEvery { service.save(any()) } answers { + firstArg>() + .also { batch -> savedHashes += batch.map { it.hash } } + .size + .toLong() + } + val fixture = fixture(service, properties(capacity = 2, batchSize = 1)) + val events = (1..3).map { event(it.toByte()) } + fixture.queue.queue(events[0], DiscoverySource.GAP) + fixture.queue.queue(events[1], DiscoverySource.GAP) + + // When + val thirdAdmission = + async { + fixture.queue.queue(events[2], DiscoverySource.GAP) + } + var unrelatedWorkCompleted = false + launch { unrelatedWorkCompleted = true } + runCurrent() + + // Then + assertTrue(fixture.queue.isAtCapacity()) + assertFalse(thirdAdmission.isCompleted) + assertTrue(unrelatedWorkCompleted) + assertEquals(3.0, fixture.gauge("transactions.discovery.backlog.depth")) + assertEquals(1.0, fixture.gauge("transactions.discovery.backlog.overshoot")) + + // When + fixture.worker.flush() + assertTrue(thirdAdmission.await()) + fixture.worker.flush() + + // Then + assertEquals(events.map { it.transaction.hash }, savedHashes) + assertFalse(fixture.queue.isAtCapacity()) + assertEquals(0.0, fixture.gauge("transactions.discovery.backlog.depth")) + assertEquals(3.0, fixture.counter("transactions.discovery.persisted", "source", "gap")) + } + + @Test + fun `headroom accepts in flight replies above the discovery target before suspending`() = + runTest { + // Given + val service = mockk() + coEvery { service.save(any()) } answers { + firstArg>().size.toLong() + } + val fixture = + fixture( + service, + properties(capacity = 2, headroom = 2, batchSize = 2), + ) + val events = (30..34).map { event(it.toByte()) } + + // When + events.take(4).forEach { + fixture.queue.queue(it, DiscoverySource.GAP) + } + val fifth = + async { + fixture.queue.queue(events.last(), DiscoverySource.GAP) + } + runCurrent() + + // Then + assertTrue(fixture.queue.isAtCapacity()) + assertEquals(5.0, fixture.gauge("transactions.discovery.backlog.depth")) + assertEquals(3.0, fixture.gauge("transactions.discovery.backlog.overshoot")) + assertFalse(fifth.isCompleted) + + // When + fixture.worker.flush() + fifth.await() + fixture.worker.flush() + + // Then + assertEquals(0.0, fixture.gauge("transactions.discovery.backlog.depth")) + } + + @Test + fun `save failure retries the exact batch before newer transactions`() = + runTest { + // Given + val attempts = mutableListOf>() + val service = mockk() + coEvery { service.save(any()) } answers { + val batch = firstArg>() + attempts += batch.map { it.hash } + if (attempts.size == 1) { + error("simulated MySQL failure") + } + batch.size.toLong() + } + val clock = MutableClock() + val fixture = + fixture( + service, + properties(capacity = 3, batchSize = 2), + clock = clock, + ) + val events = (4..6).map { event(it.toByte()) } + fixture.queue.queue(events[0], DiscoverySource.DEPENDENCY) + fixture.queue.queue(events[1], DiscoverySource.DEPENDENCY) + + // When + fixture.worker.flush() + fixture.queue.queue(events[2], DiscoverySource.DEPENDENCY) + fixture.worker.flush() + + // Then + assertEquals(1, attempts.size) + assertFalse(fixture.queue.isAtCapacity()) + assertEquals(1.0, fixture.gauge("transactions.discovery.backlog.depth")) + + // When + clock.advance(Duration.ofSeconds(1)) + fixture.worker.flush() + + // Then + assertEquals( + listOf( + events.take(2).map { it.transaction.hash }, + events.take(2).map { it.transaction.hash }, + listOf(events[2].transaction.hash), + ), + attempts, + ) + assertEquals(0.0, fixture.gauge("transactions.discovery.backlog.depth")) + assertEquals(1.0, fixture.counter("transactions.discovery.persistence.failures")) + } + + @Test + fun `cancelled save leaves the drained batch intact`() = + runTest { + // Given + val attempts = mutableListOf>() + var cancelNext = true + val service = mockk() + coEvery { service.save(any()) } answers { + val batch = firstArg>() + attempts += batch.map { it.hash } + if (cancelNext) { + cancelNext = false + throw CancellationException("test cancellation") + } + batch.size.toLong() + } + val fixture = fixture(service, properties(capacity = 2, batchSize = 2)) + val events = listOf(event(7), event(8)) + events.forEach { fixture.queue.queue(it, DiscoverySource.SEND) } + + // When + try { + fixture.worker.flush() + fail("Expected cancellation") + } catch (_: CancellationException) { + // Expected. The worker must retain the batch. + } + fixture.worker.flush() + + // Then + val expected = events.map { it.transaction.hash } + assertEquals(listOf(expected, expected), attempts) + assertEquals(0.0, fixture.gauge("transactions.discovery.backlog.depth")) + } + + @Test + fun `cancelling a suspended admission removes it without affecting buffered transactions`() = + runTest { + // Given + val service = mockk(relaxed = true) + val fixture = fixture(service, properties(capacity = 1, batchSize = 1)) + fixture.queue.queue(event(9), DiscoverySource.HEAD) + val suspended = + launch { + fixture.queue.queue(event(10), DiscoverySource.HEAD) + } + runCurrent() + assertEquals(2.0, fixture.gauge("transactions.discovery.backlog.depth")) + + // When + suspended.cancel() + suspended.join() + + // Then + assertEquals(1.0, fixture.gauge("transactions.discovery.backlog.depth")) + assertTrue(fixture.queue.isAtCapacity()) + } + + @Test + fun `duplicate transaction is published and persisted once`() = + runTest { + // Given + val service = mockk() + coEvery { service.save(any()) } answers { + firstArg>().size.toLong() + } + val eventPublisher = mockk(relaxed = true) + val fixture = fixture(service, eventPublisher = eventPublisher) + val event = event(11) + + // When + val first = fixture.queue.queue(event, DiscoverySource.SEND) + val pendingDuplicate = fixture.queue.queue(event, DiscoverySource.SEND) + fixture.worker.flush() + val committedDuplicate = fixture.queue.queue(event, DiscoverySource.SEND) + + // Then + assertTrue(first) + assertFalse(pendingDuplicate) + assertFalse(committedDuplicate) + verify(exactly = 1) { eventPublisher.publish(event) } + assertEquals(1.0, fixture.counter("transactions.discovery.admitted", "source", "send")) + assertEquals(1.0, fixture.counter("transactions.discovery.persisted", "source", "send")) + } + + @Test + fun `backlog metrics include suspended replies and record queue wait when drained`() = + runTest { + // Given + val service = mockk() + coEvery { service.save(any()) } answers { + firstArg>().size.toLong() + } + val clock = MutableClock() + val fixture = + fixture( + service, + properties(capacity = 2, batchSize = 2), + clock = clock, + ) + fixture.queue.queue(event(12), DiscoverySource.GAP) + fixture.queue.queue(event(13), DiscoverySource.GAP) + val suspended = + async { + fixture.queue.queue(event(14), DiscoverySource.GAP) + } + runCurrent() + + // When + clock.advance(Duration.ofSeconds(5)) + + // Then + assertEquals(3.0, fixture.gauge("transactions.discovery.backlog.depth")) + assertEquals(1.0, fixture.gauge("transactions.discovery.backlog.overshoot")) + assertEquals(1.0, fixture.gauge("transactions.discovery.at.capacity")) + + // When + fixture.worker.flush() + suspended.await() + fixture.worker.flush() + + // Then + assertEquals(0.0, fixture.gauge("transactions.discovery.backlog.depth")) + assertEquals(0.0, fixture.gauge("transactions.discovery.at.capacity")) + val queueWait = fixture.registry.get("transactions.discovery.queue.wait").timer() + assertEquals(3L, queueWait.count()) + assertTrue(queueWait.max(java.util.concurrent.TimeUnit.SECONDS) >= 5.0) + } + + private fun fixture( + service: UncheckedTransactionService, + properties: DiscoveryProperties = properties(), + eventPublisher: EventPublisher = mockk(relaxed = true), + registry: SimpleMeterRegistry = SimpleMeterRegistry(), + clock: Clock = MutableClock(), + ): Fixture { + properties.validate() + val metrics = DiscoveryMetrics(registry) + val queue = + DiscoveryQueue(eventPublisher, properties, metrics, clock) + .also { it.start() } + val worker = + DiscoveryPersistenceWorker( + uncheckedTransactionService = service, + queue = queue, + properties = properties, + metrics = metrics, + clock = clock, + ) + return Fixture(queue, worker, registry) + } + + private fun properties( + capacity: Int = 10, + headroom: Int = 0, + batchSize: Int = 10, + ): DiscoveryProperties = + DiscoveryProperties().apply { + this.capacity = capacity + this.headroom = headroom + this.batchSize = batchSize + } + + private fun event(marker: Byte): TransactionDiscovered = + TransactionDiscovered( + reason = null, + transaction = + Transaction( + block = + AttoReceiveBlock( + version = 0U.toAttoVersion(), + network = AttoNetwork.LOCAL, + algorithm = AttoAlgorithm.V1, + publicKey = AttoPublicKey(ByteArray(32) { marker }), + height = 2U.toAttoHeight(), + balance = AttoAmount.MAX, + timestamp = AttoInstant.now(), + previous = AttoHash(ByteArray(32) { (marker + 1).toByte() }), + sendHashAlgorithm = AttoAlgorithm.V1, + sendHash = AttoHash(ByteArray(32) { (marker + 2).toByte() }), + ), + signature = AttoSignature(ByteArray(64) { (marker + 3).toByte() }), + work = AttoWork(ByteArray(8) { (marker + 4).toByte() }), + ), + votes = emptyList(), + ) + + private data class Fixture( + val queue: DiscoveryQueue, + val worker: DiscoveryPersistenceWorker, + val registry: SimpleMeterRegistry, + ) { + fun gauge(name: String): Double = registry.get(name).gauge().value() + + fun counter( + name: String, + vararg tags: String, + ): Double = + registry + .get(name) + .tags(*tags) + .counter() + .count() + } + + private class MutableClock( + private var current: Instant = Instant.parse("2026-08-03T00:00:00Z"), + ) : Clock() { + override fun getZone(): ZoneId = ZoneOffset.UTC + + override fun withZone(zone: ZoneId): Clock = this + + override fun instant(): Instant = current + + fun advance(duration: Duration) { + current = current.plus(duration) + } + } +} diff --git a/src/test/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryTestController.kt b/src/test/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryTestController.kt index 6d335f07..55702b37 100644 --- a/src/test/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryTestController.kt +++ b/src/test/kotlin/cash/atto/node/bootstrap/discovery/DiscoveryTestController.kt @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/unchecked-transactions/discoveries") class DiscoveryTestController( - val discoveryProcessor: DiscoveryProcessor, + val discoveryPersistenceWorker: DiscoveryPersistenceWorker, val gapDiscoverer: GapDiscoverer, val lastDiscoverer: LastDiscoverer, val uncheckedTransactionProcessorStarter: UncheckedTransactionProcessorStarter, @@ -29,16 +29,16 @@ class DiscoveryTestController( @PostMapping("/flush") @Operation(description = "Flush discovered transactions") suspend fun flush() { - discoveryProcessor.flush() + discoveryPersistenceWorker.flush() } @PostMapping("/settle") @Operation(description = "Flush discoveries, resolve gaps, and process unchecked transactions") suspend fun settle() { - discoveryProcessor.flush() + discoveryPersistenceWorker.flush() uncheckedTransactionProcessorStarter.process() gapDiscoverer.resolve() - discoveryProcessor.flush() + discoveryPersistenceWorker.flush() uncheckedTransactionProcessorStarter.process() } } diff --git a/src/test/kotlin/cash/atto/node/bootstrap/discovery/GapDiscovererTest.kt b/src/test/kotlin/cash/atto/node/bootstrap/discovery/GapDiscovererTest.kt new file mode 100644 index 00000000..c463c1ff --- /dev/null +++ b/src/test/kotlin/cash/atto/node/bootstrap/discovery/GapDiscovererTest.kt @@ -0,0 +1,506 @@ +package cash.atto.node.bootstrap.discovery + +import cash.atto.commons.AttoAlgorithm +import cash.atto.commons.AttoAmount +import cash.atto.commons.AttoHash +import cash.atto.commons.AttoInstant +import cash.atto.commons.AttoNetwork +import cash.atto.commons.AttoPublicKey +import cash.atto.commons.AttoSendBlock +import cash.atto.commons.AttoSignature +import cash.atto.commons.AttoTransaction +import cash.atto.commons.AttoWork +import cash.atto.commons.toAttoHeight +import cash.atto.commons.toAttoVersion +import cash.atto.node.bootstrap.unchecked.GapView +import cash.atto.node.bootstrap.unchecked.UncheckedTransactionRepository +import cash.atto.node.bootstrap.unchecked.UncheckedWorkTracker +import cash.atto.node.network.DirectNetworkMessage +import cash.atto.node.network.InboundNetworkMessage +import cash.atto.node.network.MessageSource +import cash.atto.node.network.NetworkMessagePublisher +import cash.atto.node.network.NodeConnected +import cash.atto.node.network.NodeDisconnected +import cash.atto.protocol.AttoNode +import cash.atto.protocol.AttoTransactionStreamRequest +import cash.atto.protocol.AttoTransactionStreamResponse +import cash.atto.protocol.NodeFeature +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.fail +import org.junit.jupiter.api.Test +import java.net.InetSocketAddress +import java.net.URI +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.concurrent.atomic.AtomicInteger + +class GapDiscovererTest { + @Test + fun `no historical peer prevents the gap query`() = + runTest { + // given + val fixture = + fixture( + gaps = listOf(gap(publicKey(1), 1U, 1U, hash(1))), + connect = false, + ) + + // when + fixture.discoverer.resolve() + + // then + coVerify(exactly = 0) { fixture.repository.findGaps(any()) } + assertTrue(fixture.requests.isEmpty()) + } + + @Test + fun `insufficient full request margin prevents the gap query`() = + runTest { + // given + val fixture = + fixture( + gaps = listOf(gap(publicKey(1), 1U, 1U, hash(1))), + capacity = 100, + remainingCapacity = 99, + ) + + // when + fixture.discoverer.resolve() + + // then + coVerify(exactly = 0) { fixture.repository.findGaps(any()) } + assertTrue(fixture.requests.isEmpty()) + } + + @Test + fun `parallel peer starts one session for every queue sized slot`() = + runTest { + // given + val gaps = (1..3).map { gap(publicKey(it.toByte()), 1U, 1U, hash(it.toByte())) } + val fixture = fixture(gaps = gaps, capacity = 3_000) + + // when + fixture.discoverer.resolve() + fixture.discoverer.resolve() + + // then + assertEquals(3, fixture.requests.size) + assertEquals(3, fixture.discoverer.activeSessionCount()) + coVerify(exactly = 1) { fixture.repository.findGaps(3) } + } + + @Test + fun `legacy peer is limited to one active session`() = + runTest { + // given + val gaps = (1..3).map { gap(publicKey(it.toByte()), 1U, 1U, hash(it.toByte())) } + val fixture = + fixture( + gaps = gaps, + capacity = 3_000, + nodes = listOf(node(100, protocolVersion = 0u)), + ) + + // when + fixture.discoverer.resolve() + + // then + assertEquals(1, fixture.requests.size) + assertEquals(1, fixture.discoverer.activeSessionCount()) + coVerify(exactly = 1) { fixture.repository.findGaps(1) } + } + + @Test + fun `legacy peers each accept one active session`() = + runTest { + // given + val gaps = (1..3).map { gap(publicKey(it.toByte()), 1U, 1U, hash(it.toByte())) } + val nodes = (100..102).map { node(it.toByte(), protocolVersion = 0u) } + val fixture = fixture(gaps = gaps, capacity = 3_000, nodes = nodes) + + // when + fixture.discoverer.resolve() + + // then + assertEquals(3, fixture.requests.size) + assertEquals(nodes.map(AttoNode::publicUri).toSet(), fixture.requests.map { it.publicUri }.toSet()) + } + + @Test + fun `active accounts are skipped while filling new session slots`() = + runTest { + // given + val firstGap = gap(publicKey(1), 1U, 1U, hash(1)) + val secondGap = gap(publicKey(2), 1U, 1U, hash(2)) + val fixture = fixture(gaps = emptyList(), capacity = 2_000) + coEvery { fixture.repository.findGaps(2) } returnsMany + listOf( + listOf(firstGap).asFlow(), + listOf(firstGap, secondGap).asFlow(), + ) + + // when + fixture.discoverer.resolve() + fixture.discoverer.resolve() + + // then + assertEquals( + listOf(firstGap.publicKey, secondGap.publicKey), + fixture.requests.map { it.payload.publicKey }, + ) + assertEquals(2, fixture.discoverer.activeSessionCount()) + } + + @Test + fun `request range is capped to the configured capacity budget`() = + runTest { + // given + val fixture = + fixture( + gaps = listOf(gap(publicKey(1), 1U, 100U, hash(100))), + capacity = 25, + ) + + // when + fixture.discoverer.resolve() + + // then + val request = fixture.requests.single() + assertEquals(25UL, request.expectedResponseCount) + assertEquals(76U.toAttoHeight(), request.payload.startHeight) + assertEquals(100U.toAttoHeight(), request.payload.endHeight) + } + + @Test + fun `protocol maximum caps the request when target capacity is larger`() = + runTest { + // given + val fixture = + fixture( + gaps = listOf(gap(publicKey(1), 1U, 1_500U, hash(1))), + ) + + // when + fixture.discoverer.resolve() + + // then + val request = fixture.requests.single() + assertEquals(1_000UL, request.expectedResponseCount) + assertEquals(501U.toAttoHeight(), request.payload.startHeight) + assertEquals(1_500U.toAttoHeight(), request.payload.endHeight) + } + + @Test + fun `completed session allows another query without waiting for persistence`() = + runTest { + // given + val publicKey = publicKey(1) + val transaction = transaction(publicKey, 1U, hash(0), 1) + val fixture = + fixture( + gaps = listOf(gap(publicKey, 1U, 1U, transaction.hash)), + capacity = 1_000, + queueResult = false, + ) + fixture.discoverer.resolve() + + // when + fixture.discoverer.process(fixture.response(transaction)) + fixture.discoverer.resolve() + + // then + assertEquals(2, fixture.requests.size) + assertEquals(1, fixture.discoverer.activeSessionCount()) + coVerify(exactly = 2) { fixture.repository.findGaps(1) } + } + + @Test + fun `peer disconnect releases all its sessions and allows retry`() = + runTest { + // given + val gaps = (1..3).map { gap(publicKey(it.toByte()), 1U, 2U, hash(it.toByte())) } + val fixture = fixture(gaps = gaps, capacity = 3_000) + fixture.discoverer.resolve() + + // when + fixture.disconnect() + + // then + assertEquals(0, fixture.discoverer.activeSessionCount()) + + // when + fixture.connect() + fixture.discoverer.resolve() + + // then + assertEquals(6, fixture.requests.size) + assertEquals(3, fixture.discoverer.activeSessionCount()) + } + + @Test + fun `one minute without progress releases sessions and retries`() = + runTest { + // given + val clock = MutableClock() + val gaps = (1..2).map { gap(publicKey(it.toByte()), 1U, 2U, hash(it.toByte())) } + val fixture = fixture(gaps = gaps, capacity = 2_000, clock = clock) + fixture.discoverer.resolve() + + // when + clock.advance(Duration.ofSeconds(61)) + fixture.discoverer.resolve() + + // then + assertEquals(4, fixture.requests.size) + assertEquals(2, fixture.discoverer.activeSessionCount()) + coVerify(exactly = 2) { fixture.repository.findGaps(2) } + } + + @Test + fun `publication failure releases the session`() = + runTest { + // given + val fixture = + fixture( + gaps = listOf(gap(publicKey(1), 1U, 1U, hash(1))), + capacity = 1_000, + publicationFailures = 1, + ) + + // when + try { + fixture.discoverer.resolve() + fail("Expected request publication to fail") + } catch (_: IllegalStateException) { + // expected + } + + // then + assertEquals(0, fixture.discoverer.activeSessionCount()) + + // when + fixture.discoverer.resolve() + + // then + assertEquals(1, fixture.requests.size) + assertEquals(1, fixture.discoverer.activeSessionCount()) + } + + @Test + fun `cache clearing releases all active sessions`() = + runTest { + // given + val gaps = (1..3).map { gap(publicKey(it.toByte()), 1U, 1U, hash(it.toByte())) } + val fixture = fixture(gaps = gaps, capacity = 3_000) + fixture.discoverer.resolve() + + // when + fixture.discoverer.clear() + + // then + assertEquals(0, fixture.discoverer.activeSessionCount()) + } + + @Test + fun `empty query suppression wakes on work changes and fallback expiry`() = + runTest { + // given + val clock = MutableClock() + val fixture = fixture(gaps = emptyList(), clock = clock) + + // when + fixture.discoverer.resolve() + fixture.discoverer.resolve() + + // then + coVerify(exactly = 1) { fixture.repository.findGaps(any()) } + + // when + fixture.workTracker.markChanged() + fixture.discoverer.resolve() + + // then + coVerify(exactly = 2) { fixture.repository.findGaps(any()) } + + // when + clock.advance(Duration.ofSeconds(31)) + fixture.discoverer.resolve() + + // then + coVerify(exactly = 3) { fixture.repository.findGaps(any()) } + } + + private fun fixture( + gaps: List, + capacity: Int = 10_000, + remainingCapacity: Int = capacity, + queueResult: Boolean = true, + publicationFailures: Int = 0, + connect: Boolean = true, + clock: Clock = MutableClock(), + nodes: List = listOf(node(100)), + ): Fixture { + val properties = + DiscoveryProperties().apply { + this.capacity = capacity + batchSize = minOf(capacity, 1_000) + } + val availableCapacity = AtomicInteger(remainingCapacity) + val queue = mockk() + every { queue.remainingTargetCapacity() } answers { availableCapacity.get() } + coEvery { queue.queue(any(), DiscoverySource.GAP) } returns queueResult + + val repository = mockk() + coEvery { repository.findGaps(any()) } answers { gaps.asFlow() } + + val requests = mutableListOf>() + var publicationAttempts = 0 + val publisher = mockk() + every { publisher.publish(any()) } answers { + publicationAttempts++ + if (publicationAttempts <= publicationFailures) { + error("simulated publication failure") + } + requests += firstArg>() + } + + val workTracker = UncheckedWorkTracker() + val discoverer = + GapDiscoverer( + uncheckedTransactionRepository = repository, + networkMessagePublisher = publisher, + discoveryQueue = queue, + workTracker = workTracker, + discoveryProperties = properties, + meterRegistry = SimpleMeterRegistry(), + clock = clock, + ) + return Fixture( + discoverer = discoverer, + repository = repository, + requests = requests, + workTracker = workTracker, + nodes = nodes, + ).also { + if (connect) { + it.connect() + } + } + } + + private data class Fixture( + val discoverer: GapDiscoverer, + val repository: UncheckedTransactionRepository, + val requests: List>, + val workTracker: UncheckedWorkTracker, + val nodes: List, + ) { + fun connect() { + nodes.forEach { node -> + discoverer.add(NodeConnected(socketAddress(node), node)) + } + } + + fun disconnect(node: AttoNode = nodes.first()) { + discoverer.remove(NodeDisconnected(socketAddress(node), node)) + } + + fun response(transaction: AttoTransaction): InboundNetworkMessage { + val request = + requests.last { it.payload.publicKey == transaction.block.publicKey } + return InboundNetworkMessage( + source = MessageSource.WEBSOCKET, + publicUri = request.publicUri, + socketAddress = InetSocketAddress(request.publicUri.host, request.publicUri.port), + payload = AttoTransactionStreamResponse(transaction), + ) + } + + private fun socketAddress(node: AttoNode): InetSocketAddress = InetSocketAddress(node.publicUri.host, node.publicUri.port) + } + + private class MutableClock( + private var now: Instant = Instant.parse("2026-08-04T00:00:00Z"), + private val zone: ZoneId = ZoneOffset.UTC, + ) : Clock() { + override fun getZone(): ZoneId = zone + + override fun withZone(zone: ZoneId): Clock = MutableClock(now, zone) + + override fun instant(): Instant = now + + fun advance(duration: Duration) { + now = now.plus(duration) + } + } + + private companion object { + fun gap( + publicKey: AttoPublicKey, + startHeight: UInt, + endHeight: UInt, + expectedEndHash: AttoHash, + ): GapView = + GapView( + publicKey = publicKey, + startHeight = startHeight.toAttoHeight(), + endHeight = endHeight.toAttoHeight(), + expectedEndHash = expectedEndHash, + ) + + fun node( + marker: Byte, + protocolVersion: UShort = AttoNode.CURRENT_PROTOCOL_VERSION, + ): AttoNode = + AttoNode( + network = AttoNetwork.LOCAL, + protocolVersion = protocolVersion, + algorithm = AttoAlgorithm.V1, + publicKey = publicKey(marker), + publicUri = URI("ws://127.0.0.1:${8_000 + marker}"), + features = setOf(NodeFeature.HISTORICAL), + ) + + fun transaction( + publicKey: AttoPublicKey, + height: UInt, + previous: AttoHash, + marker: Byte, + ): AttoTransaction { + val block = + AttoSendBlock( + version = 0U.toAttoVersion(), + network = AttoNetwork.LOCAL, + algorithm = AttoAlgorithm.V1, + publicKey = publicKey, + height = height.toAttoHeight(), + balance = AttoAmount.MAX, + timestamp = AttoInstant.now(), + previous = previous, + receiverAlgorithm = AttoAlgorithm.V1, + receiverPublicKey = publicKey((marker + 20).toByte()), + amount = AttoAmount.MAX, + ) + return AttoTransaction( + block = block, + signature = AttoSignature(ByteArray(64) { (marker + 1).toByte() }), + work = AttoWork(ByteArray(8) { (marker + 2).toByte() }), + ) + } + + fun publicKey(marker: Byte): AttoPublicKey = AttoPublicKey(ByteArray(32) { marker }) + + fun hash(marker: Byte): AttoHash = AttoHash(ByteArray(32) { marker }) + } +} diff --git a/src/test/kotlin/cash/atto/node/bootstrap/discovery/GapSessionTest.kt b/src/test/kotlin/cash/atto/node/bootstrap/discovery/GapSessionTest.kt new file mode 100644 index 00000000..bc7fac2a --- /dev/null +++ b/src/test/kotlin/cash/atto/node/bootstrap/discovery/GapSessionTest.kt @@ -0,0 +1,418 @@ +package cash.atto.node.bootstrap.discovery + +import cash.atto.commons.AttoAlgorithm +import cash.atto.commons.AttoAmount +import cash.atto.commons.AttoHash +import cash.atto.commons.AttoInstant +import cash.atto.commons.AttoNetwork +import cash.atto.commons.AttoPublicKey +import cash.atto.commons.AttoSendBlock +import cash.atto.commons.AttoSignature +import cash.atto.commons.AttoTransaction +import cash.atto.commons.AttoWork +import cash.atto.commons.toAttoHeight +import cash.atto.commons.toAttoVersion +import cash.atto.node.bootstrap.TransactionDiscovered +import cash.atto.node.network.InboundNetworkMessage +import cash.atto.node.network.MessageSource +import cash.atto.protocol.AttoTransactionStreamResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.net.InetSocketAddress +import java.net.URI +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.concurrent.atomic.AtomicInteger + +@OptIn(ExperimentalCoroutinesApi::class) +class GapSessionTest { + @Test + fun `in order descending responses are queued and complete the session`() = + runTest { + // Given + val chain = chain() + val admitted = mutableListOf() + val session = session(chain, queue(admitted)) + + // When + session.offer(response(chain.third)) + session.offer(response(chain.second)) + val status = session.offer(response(chain.first)) + + // Then + assertEquals(GapSessionStatus.COMPLETED, status) + assertEquals( + listOf(chain.third.hash, chain.second.hash, chain.first.hash), + admitted, + ) + assertEquals(0, session.bufferedResponseCount()) + } + + @Test + fun `out of order responses are retained and drained in descending order`() = + runTest { + // Given + val chain = chain() + val admitted = mutableListOf() + val session = session(chain, queue(admitted)) + + // When + session.offer(response(chain.first)) + session.offer(response(chain.second)) + val status = session.offer(response(chain.third)) + + // Then + assertEquals(GapSessionStatus.COMPLETED, status) + assertEquals( + listOf(chain.third.hash, chain.second.hash, chain.first.hash), + admitted, + ) + } + + @Test + fun `saturated queue does not block offering a later response`() = + runTest { + // Given + val chain = chain() + val enteredQueue = CompletableDeferred() + val releaseQueue = CompletableDeferred() + val admitted = mutableListOf() + val queue = mockk() + coEvery { queue.queue(any(), DiscoverySource.GAP) } coAnswers { + val hash = firstArg().transaction.hash + if (hash == chain.third.hash) { + enteredQueue.complete(Unit) + releaseQueue.await() + } + admitted += hash + true + } + val session = session(chain, queue) + val expectedOffer = async { session.offer(response(chain.third)) } + enteredQueue.await() + + // When + val laterOffer = async { session.offer(response(chain.first)) } + runCurrent() + + // Then + assertTrue(laterOffer.isCompleted) + assertEquals(GapSessionStatus.ACTIVE, laterOffer.await()) + assertEquals(2, session.bufferedResponseCount()) + + // When + releaseQueue.complete(Unit) + assertEquals(GapSessionStatus.ACTIVE, expectedOffer.await()) + val status = session.offer(response(chain.second)) + + // Then + assertEquals(GapSessionStatus.COMPLETED, status) + assertEquals( + listOf(chain.third.hash, chain.second.hash, chain.first.hash), + admitted, + ) + } + + @Test + fun `cancelled admission retains expected and later responses for retry`() = + runTest { + // Given + val chain = chain() + val enteredQueue = CompletableDeferred() + val attempts = AtomicInteger() + val admitted = mutableListOf() + val queue = mockk() + coEvery { queue.queue(any(), DiscoverySource.GAP) } coAnswers { + if (attempts.getAndIncrement() == 0) { + enteredQueue.complete(Unit) + awaitCancellation() + } + admitted += firstArg().transaction.hash + true + } + val session = session(chain, queue) + val expectedOffer = async { session.offer(response(chain.third)) } + enteredQueue.await() + session.offer(response(chain.first)) + + // When + expectedOffer.cancelAndJoin() + + // Then + assertEquals(2, session.bufferedResponseCount()) + assertEquals(GapSessionStatus.ACTIVE, session.status()) + + // When + session.retry() + val status = session.offer(response(chain.second)) + + // Then + assertEquals(GapSessionStatus.COMPLETED, status) + assertEquals( + listOf(chain.third.hash, chain.second.hash, chain.first.hash), + admitted, + ) + } + + @Test + fun `wrong peer account range and processed duplicates are ignored`() = + runTest { + // Given + val chain = chain() + val admitted = mutableListOf() + val session = session(chain, queue(admitted)) + val wrongAccount = transaction(publicKey(9), 3U, chain.second.hash, 9) + val outsideRange = transaction(chain.publicKey, 4U, chain.third.hash, 4) + + // When + session.offer(response(chain.third, WRONG_PEER)) + session.offer(response(wrongAccount)) + session.offer(response(outsideRange)) + session.offer(response(chain.third)) + session.offer(response(chain.third)) + session.offer(response(chain.second)) + val status = session.offer(response(chain.first)) + + // Then + assertEquals(GapSessionStatus.COMPLETED, status) + assertEquals( + listOf(chain.third.hash, chain.second.hash, chain.first.hash), + admitted, + ) + } + + @Test + fun `wrong hash at expected height invalidates the session`() = + runTest { + // Given + val chain = chain() + val queue = mockk() + val session = + GapSession( + publicKey = chain.publicKey, + peer = PEER, + startHeight = 1U.toAttoHeight(), + endHeight = 3U.toAttoHeight(), + initialExpectedHash = hash(99), + discoveryQueue = queue, + clock = Clock.systemUTC(), + ) + + // When + val status = session.offer(response(chain.third)) + + // Then + assertEquals(GapSessionStatus.INVALID, status) + assertEquals(0, session.bufferedResponseCount()) + coVerify(exactly = 0) { queue.queue(any(), any()) } + } + + @Test + fun `irrelevant traffic does not extend the timeout`() = + runTest { + // Given + val chain = chain() + val clock = MutableClock() + val session = session(chain, mockk(relaxed = true), clock) + clock.advance(Duration.ofSeconds(59)) + + // When + session.offer(response(chain.third, WRONG_PEER)) + clock.advance(Duration.ofSeconds(2)) + + // Then + assertTrue(session.isExpired(clock.instant(), Duration.ofMinutes(1))) + } + + @Test + fun `active queue admission suppresses timeout expiry`() = + runTest { + // Given + val chain = chain() + val clock = MutableClock() + val enteredQueue = CompletableDeferred() + val releaseQueue = CompletableDeferred() + val queue = mockk() + coEvery { queue.queue(any(), DiscoverySource.GAP) } coAnswers { + enteredQueue.complete(Unit) + releaseQueue.await() + true + } + val session = session(chain, queue, clock) + val offer = async { session.offer(response(chain.third)) } + enteredQueue.await() + + // When + clock.advance(Duration.ofMinutes(2)) + + // Then + assertFalse(session.isExpired(clock.instant(), Duration.ofMinutes(1))) + + // When + releaseQueue.complete(Unit) + offer.await() + + // Then + assertFalse(session.isExpired(clock.instant(), Duration.ofMinutes(1))) + } + + @Test + fun `response map cannot exceed the requested height range`() = + runTest { + // Given + val publicKey = publicKey(1) + val transactions = + (1U..5U).associateWith { height -> + transaction( + publicKey, + height, + hash((height - 1U).toByte()), + height.toByte(), + ) + } + val enteredQueue = CompletableDeferred() + val queue = mockk() + coEvery { queue.queue(any(), DiscoverySource.GAP) } coAnswers { + enteredQueue.complete(Unit) + awaitCancellation() + } + val session = + GapSession( + publicKey = publicKey, + peer = PEER, + startHeight = 1U.toAttoHeight(), + endHeight = 5U.toAttoHeight(), + initialExpectedHash = transactions.getValue(5U).hash, + discoveryQueue = queue, + clock = Clock.systemUTC(), + ) + val expectedOffer = async { session.offer(response(transactions.getValue(5U))) } + enteredQueue.await() + + // When + (1U..4U).forEach { height -> + session.offer(response(transactions.getValue(height))) + session.offer(response(transactions.getValue(height))) + } + session.offer(response(transaction(publicKey, 6U, hash(5), 6))) + + // Then + assertEquals(5, session.bufferedResponseCount()) + + expectedOffer.cancelAndJoin() + } + + private fun session( + chain: Chain, + queue: DiscoveryQueue, + clock: Clock = Clock.systemUTC(), + ): GapSession = + GapSession( + publicKey = chain.publicKey, + peer = PEER, + startHeight = 1U.toAttoHeight(), + endHeight = 3U.toAttoHeight(), + initialExpectedHash = chain.third.hash, + discoveryQueue = queue, + clock = clock, + ) + + private fun queue(admitted: MutableList): DiscoveryQueue = + mockk().also { queue -> + coEvery { queue.queue(any(), DiscoverySource.GAP) } coAnswers { + admitted += firstArg().transaction.hash + true + } + } + + private fun chain(): Chain { + val publicKey = publicKey(1) + val first = transaction(publicKey, 1U, hash(0), 1) + val second = transaction(publicKey, 2U, first.hash, 2) + val third = transaction(publicKey, 3U, second.hash, 3) + return Chain(publicKey, first, second, third) + } + + private fun response( + transaction: AttoTransaction, + peer: URI = PEER, + ): InboundNetworkMessage = + InboundNetworkMessage( + source = MessageSource.WEBSOCKET, + publicUri = peer, + socketAddress = InetSocketAddress("127.0.0.1", 8080), + payload = AttoTransactionStreamResponse(transaction), + ) + + private data class Chain( + val publicKey: AttoPublicKey, + val first: AttoTransaction, + val second: AttoTransaction, + val third: AttoTransaction, + ) + + private class MutableClock( + private var now: Instant = Instant.parse("2026-08-04T00:00:00Z"), + ) : Clock() { + override fun getZone(): ZoneId = ZoneOffset.UTC + + override fun withZone(zone: ZoneId): Clock = this + + override fun instant(): Instant = now + + fun advance(duration: Duration) { + now = now.plus(duration) + } + } + + private companion object { + val PEER: URI = URI("ws://127.0.0.1:8080") + val WRONG_PEER: URI = URI("ws://127.0.0.1:8081") + + fun transaction( + publicKey: AttoPublicKey, + height: UInt, + previous: AttoHash, + marker: Byte, + ): AttoTransaction { + val block = + AttoSendBlock( + version = 0U.toAttoVersion(), + network = AttoNetwork.LOCAL, + algorithm = AttoAlgorithm.V1, + publicKey = publicKey, + height = height.toAttoHeight(), + balance = AttoAmount.MAX, + timestamp = AttoInstant.now(), + previous = previous, + receiverAlgorithm = AttoAlgorithm.V1, + receiverPublicKey = publicKey((marker + 20).toByte()), + amount = AttoAmount.MAX, + ) + return AttoTransaction( + block = block, + signature = AttoSignature(ByteArray(64) { (marker + 1).toByte() }), + work = AttoWork(ByteArray(8) { (marker + 2).toByte() }), + ) + } + + fun publicKey(marker: Byte): AttoPublicKey = AttoPublicKey(ByteArray(32) { marker }) + + fun hash(marker: Byte): AttoHash = AttoHash(ByteArray(32) { marker }) + } +} diff --git a/src/test/kotlin/cash/atto/node/bootstrap/discovery/LastDiscovererTest.kt b/src/test/kotlin/cash/atto/node/bootstrap/discovery/LastDiscovererTest.kt new file mode 100644 index 00000000..4b82c493 --- /dev/null +++ b/src/test/kotlin/cash/atto/node/bootstrap/discovery/LastDiscovererTest.kt @@ -0,0 +1,238 @@ +package cash.atto.node.bootstrap.discovery + +import cash.atto.commons.AttoAlgorithm +import cash.atto.commons.AttoAmount +import cash.atto.commons.AttoHash +import cash.atto.commons.AttoInstant +import cash.atto.commons.AttoNetwork +import cash.atto.commons.AttoPublicKey +import cash.atto.commons.AttoReceiveBlock +import cash.atto.commons.AttoSignature +import cash.atto.commons.AttoSignedVote +import cash.atto.commons.AttoTransaction +import cash.atto.commons.AttoVote +import cash.atto.commons.AttoWork +import cash.atto.commons.toAttoHeight +import cash.atto.commons.toAttoVersion +import cash.atto.node.EventPublisher +import cash.atto.node.account.AccountRepository +import cash.atto.node.bootstrap.unchecked.UncheckedTransactionRepository +import cash.atto.node.election.ElectionVoter +import cash.atto.node.network.InboundNetworkMessage +import cash.atto.node.network.MessageSource +import cash.atto.node.network.NetworkMessage +import cash.atto.node.network.NetworkMessagePublisher +import cash.atto.node.network.NodeConnectionManager +import cash.atto.node.transaction.TransactionRepository +import cash.atto.node.vote.Vote +import cash.atto.node.vote.convertion.VoteConverter +import cash.atto.node.vote.weight.VoteWeighter +import cash.atto.protocol.AttoBootstrapTransactionPush +import cash.atto.protocol.AttoNode +import cash.atto.protocol.AttoVoteStreamCancel +import cash.atto.protocol.AttoVoteStreamRequest +import cash.atto.protocol.AttoVoteStreamResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.net.InetSocketAddress +import java.net.URI +import java.util.concurrent.atomic.AtomicBoolean + +@OptIn(ExperimentalCoroutinesApi::class) +class LastDiscovererTest { + @Test + fun `capacity prevents a new head election and vote request`() = + runTest { + // Given + val fixture = fixture(initiallyAtCapacity = true) + val transaction = transaction(1) + val response = fixture.voteResponse(transaction) + + try { + // When + fixture.discoverer.processPush(fixture.push(transaction)) + fixture.discoverer.processVoteResponse(response) + + // Then + assertTrue(fixture.messages.none { it.payload is AttoVoteStreamRequest }) + coVerify(exactly = 0) { + fixture.discoveryQueue.queue(any(), DiscoverySource.HEAD) + } + } finally { + fixture.discoverer.close() + } + } + + @Test + fun `consensus at capacity retains the head election when admission is cancelled`() = + runTest { + // Given + val fixture = fixture(initiallyAtCapacity = false) + val transaction = transaction(2) + val response = fixture.voteResponse(transaction) + fixture.discoverer.processPush(fixture.push(transaction)) + fixture.atCapacity.set(true) + coEvery { + fixture.discoveryQueue.queue(any(), DiscoverySource.HEAD) + } coAnswers { + awaitCancellation() + } + + try { + // When + val admission = + launch { + fixture.discoverer.processVoteResponse(response) + } + runCurrent() + + // Then + assertFalse(admission.isCompleted) + assertTrue(fixture.atCapacity.get()) + coVerify(exactly = 1) { + fixture.discoveryQueue.queue( + match { it.transaction.hash == transaction.hash }, + DiscoverySource.HEAD, + ) + } + assertEquals(0, fixture.messages.count { it.payload is AttoVoteStreamCancel }) + + // When + admission.cancelAndJoin() + coEvery { + fixture.discoveryQueue.queue(any(), DiscoverySource.HEAD) + } returns true + fixture.discoverer.processVoteResponse(response) + + // Then + coVerify(exactly = 2) { + fixture.discoveryQueue.queue( + match { it.transaction.hash == transaction.hash }, + DiscoverySource.HEAD, + ) + } + assertEquals(1, fixture.messages.count { it.payload is AttoVoteStreamCancel }) + } finally { + fixture.discoverer.close() + } + } + + private fun fixture(initiallyAtCapacity: Boolean): Fixture { + val atCapacity = AtomicBoolean(initiallyAtCapacity) + val discoveryQueue = mockk() + every { discoveryQueue.isAtCapacity() } answers { atCapacity.get() } + coEvery { discoveryQueue.queue(any(), DiscoverySource.HEAD) } returns true + + val voteWeighter = mockk() + every { voteWeighter.getMinimalConfirmationWeight() } returns ElectionVoter.MIN_WEIGHT + every { voteWeighter.getMinimalToStaleWeight() } returns ElectionVoter.MIN_WEIGHT + + val voteConverter = mockk() + val messages = mutableListOf>() + val networkMessagePublisher = mockk() + every { networkMessagePublisher.publish(any()) } answers { + messages += firstArg>() + } + + val accountRepository = mockk() + coEvery { accountRepository.findById(any()) } returns null + + val discoverer = + LastDiscoverer( + thisNode = mockk(relaxed = true), + accountRepository = accountRepository, + transactionRepository = mockk(), + uncheckedTransactionRepository = mockk(), + nodeConnectionManager = mockk(), + networkMessagePublisher = networkMessagePublisher, + eventPublisher = mockk(relaxed = true), + discoveryQueue = discoveryQueue, + voteConverter = voteConverter, + voteWeighter = voteWeighter, + ) + + return Fixture( + discoverer = discoverer, + discoveryQueue = discoveryQueue, + voteConverter = voteConverter, + messages = messages, + atCapacity = atCapacity, + ) + } + + private data class Fixture( + val discoverer: LastDiscoverer, + val discoveryQueue: DiscoveryQueue, + val voteConverter: VoteConverter, + val messages: List>, + val atCapacity: AtomicBoolean, + ) { + private val publicUri = URI("ws://127.0.0.1:8080") + private val socketAddress = InetSocketAddress("127.0.0.1", 8080) + + fun push(transaction: AttoTransaction): InboundNetworkMessage = + InboundNetworkMessage( + source = MessageSource.WEBSOCKET, + publicUri = publicUri, + socketAddress = socketAddress, + payload = AttoBootstrapTransactionPush(transaction), + ) + + fun voteResponse(transaction: AttoTransaction): InboundNetworkMessage { + val attoVote = + AttoVote( + version = 0U.toAttoVersion(), + algorithm = AttoAlgorithm.V1, + publicKey = AttoPublicKey(ByteArray(32) { 10 }), + blockAlgorithm = AttoAlgorithm.V1, + blockHash = transaction.hash, + timestamp = AttoVote.finalTimestamp, + ) + val signedVote = + AttoSignedVote( + vote = attoVote, + signature = AttoSignature(ByteArray(64) { 11 }), + ) + every { voteConverter.convert(signedVote) } returns + Vote.from(ElectionVoter.MIN_WEIGHT, signedVote) + + return InboundNetworkMessage( + source = MessageSource.WEBSOCKET, + publicUri = publicUri, + socketAddress = socketAddress, + payload = AttoVoteStreamResponse(signedVote), + ) + } + } + + private fun transaction(marker: Byte): AttoTransaction = + AttoTransaction( + block = + AttoReceiveBlock( + version = 0U.toAttoVersion(), + network = AttoNetwork.LOCAL, + algorithm = AttoAlgorithm.V1, + publicKey = AttoPublicKey(ByteArray(32) { marker }), + height = 2U.toAttoHeight(), + balance = AttoAmount.MAX, + timestamp = AttoInstant.now(), + previous = AttoHash(ByteArray(32) { (marker + 1).toByte() }), + sendHashAlgorithm = AttoAlgorithm.V1, + sendHash = AttoHash(ByteArray(32) { (marker + 2).toByte() }), + ), + signature = AttoSignature(ByteArray(64) { (marker + 3).toByte() }), + work = AttoWork(ByteArray(8) { (marker + 4).toByte() }), + ) +} diff --git a/src/test/kotlin/cash/atto/node/bootstrap/discovery/SendDiscovererTest.kt b/src/test/kotlin/cash/atto/node/bootstrap/discovery/SendDiscovererTest.kt new file mode 100644 index 00000000..4bcc3f45 --- /dev/null +++ b/src/test/kotlin/cash/atto/node/bootstrap/discovery/SendDiscovererTest.kt @@ -0,0 +1,237 @@ +package cash.atto.node.bootstrap.discovery + +import cash.atto.commons.AttoAlgorithm +import cash.atto.commons.AttoAmount +import cash.atto.commons.AttoHash +import cash.atto.commons.AttoInstant +import cash.atto.commons.AttoNetwork +import cash.atto.commons.AttoPublicKey +import cash.atto.commons.AttoReceiveBlock +import cash.atto.commons.AttoSendBlock +import cash.atto.commons.AttoSignature +import cash.atto.commons.AttoTransaction +import cash.atto.commons.AttoWork +import cash.atto.commons.toAttoHeight +import cash.atto.commons.toAttoVersion +import cash.atto.node.bootstrap.TransactionStuck +import cash.atto.node.bootstrap.unchecked.UncheckedTransactionRepository +import cash.atto.node.network.InboundNetworkMessage +import cash.atto.node.network.MessageSource +import cash.atto.node.network.NetworkMessagePublisher +import cash.atto.node.transaction.Transaction +import cash.atto.node.transaction.TransactionRejectionReason +import cash.atto.protocol.AttoTransactionResponse +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.net.InetSocketAddress +import java.net.URI + +class SendDiscovererTest { + @Test + fun `response already in flight is admitted while discovery is paused`() = + runTest { + // given + val networkMessagePublisher = mockk(relaxed = true) + val discoveryQueue = mockk() + val uncheckedTransactionRepository = mockk() + val discoverer = + SendDiscoverer( + networkMessagePublisher, + discoveryQueue, + uncheckedTransactionRepository, + SimpleMeterRegistry(), + ) + val missingSend = sendTransaction(1) + val receive = transaction(2, missingSend.hash) + val response = + InboundNetworkMessage( + source = MessageSource.WEBSOCKET, + publicUri = URI("ws://127.0.0.1:8080"), + socketAddress = InetSocketAddress("127.0.0.1", 8080), + payload = AttoTransactionResponse(missingSend), + ) + var paused = false + every { discoveryQueue.isAtCapacity() } answers { paused } + coEvery { discoveryQueue.queue(any(), DiscoverySource.SEND) } returns true + coEvery { + uncheckedTransactionRepository.hasHigherTransaction( + missingSend.block.publicKey, + missingSend.block.height, + ) + } returns 0L + discoverer.process( + TransactionStuck( + TransactionRejectionReason.RECEIVABLE_NOT_FOUND, + receive, + ), + ) + + // when + paused = true + discoverer.process(response) + discoverer.process(response) + + // then + coVerify(exactly = 1) { + discoveryQueue.queue( + match { it.transaction.hash == missingSend.hash }, + DiscoverySource.SEND, + ) + } + } + + @Test + fun `paused discovery does not suppress a send request after recovery`() { + // given + val networkMessagePublisher = mockk(relaxed = true) + val discoveryQueue = mockk() + val uncheckedTransactionRepository = mockk() + val discoverer = + SendDiscoverer( + networkMessagePublisher, + discoveryQueue, + uncheckedTransactionRepository, + SimpleMeterRegistry(), + ) + val missingSend = sendTransaction(1) + val receive = transaction(2, missingSend.hash) + val stuck = + TransactionStuck( + TransactionRejectionReason.RECEIVABLE_NOT_FOUND, + receive, + ) + var paused = true + every { discoveryQueue.isAtCapacity() } answers { paused } + + // when + discoverer.process(stuck) + paused = false + discoverer.process(stuck) + + // then + verify(exactly = 1) { + networkMessagePublisher.publish(any()) + } + } + + @Test + fun `send response is left for gap discovery when the account has a higher unchecked transaction`() = + runTest { + // given + val networkMessagePublisher = mockk(relaxed = true) + val discoveryQueue = mockk(relaxed = true) + val uncheckedTransactionRepository = mockk() + val meterRegistry = SimpleMeterRegistry() + val discoverer = + SendDiscoverer( + networkMessagePublisher, + discoveryQueue, + uncheckedTransactionRepository, + meterRegistry, + ) + val missingSend = sendTransaction(1) + val firstReceive = transaction(2, missingSend.hash) + val secondReceive = transaction(3, missingSend.hash) + val response = + InboundNetworkMessage( + source = MessageSource.WEBSOCKET, + publicUri = URI("ws://127.0.0.1:8080"), + socketAddress = InetSocketAddress("127.0.0.1", 8080), + payload = AttoTransactionResponse(missingSend), + ) + coEvery { + uncheckedTransactionRepository.hasHigherTransaction( + missingSend.block.publicKey, + missingSend.block.height, + ) + } returns 1L + discoverer.process( + TransactionStuck( + TransactionRejectionReason.RECEIVABLE_NOT_FOUND, + firstReceive, + ), + ) + + // when + discoverer.process(response) + discoverer.process( + TransactionStuck( + TransactionRejectionReason.RECEIVABLE_NOT_FOUND, + secondReceive, + ), + ) + + // then + coVerify(exactly = 1) { + uncheckedTransactionRepository.hasHigherTransaction( + missingSend.block.publicKey, + missingSend.block.height, + ) + } + coVerify(exactly = 0) { + discoveryQueue.queue(any(), DiscoverySource.SEND) + } + verify(exactly = 1) { + networkMessagePublisher.publish(any()) + } + assertEquals( + 1.0, + meterRegistry + .get("transactions.discovery.send.skipped") + .counter() + .count(), + ) + } + + private fun sendTransaction(marker: Byte): AttoTransaction { + val publicKey = AttoPublicKey(ByteArray(32) { marker }) + val block = + AttoSendBlock( + version = 0U.toAttoVersion(), + network = AttoNetwork.LOCAL, + algorithm = AttoAlgorithm.V1, + publicKey = publicKey, + height = 2U.toAttoHeight(), + balance = AttoAmount.MAX, + timestamp = AttoInstant.now(), + previous = AttoHash(ByteArray(32) { (marker + 1).toByte() }), + receiverAlgorithm = AttoAlgorithm.V1, + receiverPublicKey = AttoPublicKey(ByteArray(32) { (marker + 2).toByte() }), + amount = AttoAmount.MAX, + ) + return AttoTransaction( + block, + AttoSignature(ByteArray(64) { (marker + 3).toByte() }), + AttoWork(ByteArray(8) { (marker + 4).toByte() }), + ) + } + + private fun transaction( + marker: Byte, + sendHash: AttoHash, + ): Transaction = + Transaction( + block = + AttoReceiveBlock( + version = 0U.toAttoVersion(), + network = AttoNetwork.LOCAL, + algorithm = AttoAlgorithm.V1, + publicKey = AttoPublicKey(ByteArray(32) { marker }), + height = 2U.toAttoHeight(), + balance = AttoAmount.MAX, + timestamp = AttoInstant.now(), + previous = AttoHash(ByteArray(32) { (marker + 1).toByte() }), + sendHashAlgorithm = AttoAlgorithm.V1, + sendHash = sendHash, + ), + signature = AttoSignature(ByteArray(64) { (marker + 2).toByte() }), + work = AttoWork(ByteArray(8) { (marker + 3).toByte() }), + ) +} diff --git a/src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionInserterTest.kt b/src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionInserterTest.kt new file mode 100644 index 00000000..13cf4857 --- /dev/null +++ b/src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionInserterTest.kt @@ -0,0 +1,133 @@ +package cash.atto.node.bootstrap.unchecked + +import cash.atto.commons.AttoAlgorithm +import cash.atto.commons.AttoAmount +import cash.atto.commons.AttoHash +import cash.atto.commons.AttoInstant +import cash.atto.commons.AttoNetwork +import cash.atto.commons.AttoOpenBlock +import cash.atto.commons.AttoPublicKey +import cash.atto.commons.AttoReceiveBlock +import cash.atto.commons.AttoSignature +import cash.atto.commons.AttoWork +import cash.atto.commons.toAttoHeight +import cash.atto.commons.toAttoVersion +import cash.atto.node.transaction.Transaction +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import io.r2dbc.spi.Connection +import io.r2dbc.spi.ConnectionFactory +import io.r2dbc.spi.ConnectionFactoryMetadata +import io.r2dbc.spi.Result +import io.r2dbc.spi.Statement +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.r2dbc.core.DatabaseClient +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono + +class UncheckedTransactionInserterTest { + @Test + fun `inserts a collection with one multi-row statement`() = + runTest { + // given + val connectionFactory = mockk() + val connectionFactoryMetadata = mockk() + val connection = mockk() + val statement = mockk() + val result = mockk() + val sql = slot() + + every { connectionFactory.metadata } returns connectionFactoryMetadata + every { connectionFactoryMetadata.name } returns "MySQL" + every { connectionFactory.create() } returns Mono.just(connection) + every { connection.createStatement(capture(sql)) } returns statement + every { connection.close() } returns Mono.empty() + every { statement.bind(any(), any()) } returns statement + every { statement.bindNull(any(), any()) } returns statement + every { statement.execute() } returns Flux.just(result) + every { result.rowsUpdated } returns Mono.just(2L) + + val inserter = UncheckedTransactionInserter(DatabaseClient.create(connectionFactory)) + val transactions = listOf(openTransaction(), receiveTransaction()) + + // when + val inserted = inserter.insert(transactions) + + // then + assertEquals(2L, inserted) + assertTrue( + sql.captured.contains( + "VALUES (?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?)", + ), + ) + assertTrue(sql.captured.endsWith("ON DUPLICATE KEY UPDATE hash = hash")) + verify(exactly = 1) { connection.createStatement(any()) } + verify(exactly = 13) { statement.bind(any(), any()) } + verify(exactly = 1) { statement.bindNull(3, ByteArray::class.java) } + verify(exactly = 0) { statement.add() } + verify(exactly = 1) { statement.execute() } + } + + @Test + fun `does not acquire a connection for an empty collection`() = + runTest { + // given + val connectionFactory = mockk() + val connectionFactoryMetadata = mockk() + every { connectionFactory.metadata } returns connectionFactoryMetadata + every { connectionFactoryMetadata.name } returns "MySQL" + val inserter = UncheckedTransactionInserter(DatabaseClient.create(connectionFactory)) + + // when + val inserted = inserter.insert(emptyList()) + + // then + assertEquals(0L, inserted) + verify(exactly = 0) { connectionFactory.create() } + } + + private fun openTransaction(): UncheckedTransaction { + val publicKey = AttoPublicKey(ByteArray(32) { 1 }) + return Transaction( + block = + AttoOpenBlock( + version = 0U.toAttoVersion(), + network = AttoNetwork.LOCAL, + algorithm = AttoAlgorithm.V1, + publicKey = publicKey, + balance = AttoAmount.MAX, + timestamp = AttoInstant.now(), + sendHashAlgorithm = AttoAlgorithm.V1, + sendHash = AttoHash(ByteArray(32) { 2 }), + representativeAlgorithm = AttoAlgorithm.V1, + representativePublicKey = publicKey, + ), + signature = AttoSignature(ByteArray(64) { 3 }), + work = AttoWork(ByteArray(8) { 4 }), + ).toUncheckedTransaction() + } + + private fun receiveTransaction(): UncheckedTransaction = + Transaction( + block = + AttoReceiveBlock( + version = 0U.toAttoVersion(), + network = AttoNetwork.LOCAL, + algorithm = AttoAlgorithm.V1, + publicKey = AttoPublicKey(ByteArray(32) { 5 }), + height = 2U.toAttoHeight(), + balance = AttoAmount.MAX, + timestamp = AttoInstant.now(), + previous = AttoHash(ByteArray(32) { 6 }), + sendHashAlgorithm = AttoAlgorithm.V1, + sendHash = AttoHash(ByteArray(32) { 7 }), + ), + signature = AttoSignature(ByteArray(64) { 8 }), + work = AttoWork(ByteArray(8) { 9 }), + ).toUncheckedTransaction() +} diff --git a/src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionMigrationTest.kt b/src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionMigrationTest.kt new file mode 100644 index 00000000..cf5be6d3 --- /dev/null +++ b/src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionMigrationTest.kt @@ -0,0 +1,694 @@ +package cash.atto.node.bootstrap.unchecked + +import cash.atto.commons.AttoAlgorithm +import cash.atto.commons.AttoAmount +import cash.atto.commons.AttoHash +import cash.atto.commons.AttoInstant +import cash.atto.commons.AttoNetwork +import cash.atto.commons.AttoOpenBlock +import cash.atto.commons.AttoPublicKey +import cash.atto.commons.AttoReceiveBlock +import cash.atto.commons.AttoSignature +import cash.atto.commons.AttoWork +import cash.atto.commons.toAttoHeight +import cash.atto.commons.toAttoVersion +import cash.atto.node.convertion.ConversionsConfiguration +import cash.atto.node.convertion.UncheckedTransactionDeserializerDBConverter +import cash.atto.node.transaction.Transaction +import io.r2dbc.spi.ConnectionFactories +import io.r2dbc.spi.ConnectionFactory +import io.r2dbc.spi.ConnectionFactoryOptions +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.reactor.awaitSingle +import kotlinx.coroutines.runBlocking +import org.flywaydb.core.Flyway +import org.flywaydb.core.api.MigrationVersion +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.data.r2dbc.convert.MappingR2dbcConverter +import org.springframework.data.r2dbc.core.DefaultReactiveDataAccessStrategy +import org.springframework.data.r2dbc.core.R2dbcEntityTemplate +import org.springframework.data.r2dbc.dialect.MySqlDialect +import org.springframework.data.r2dbc.repository.support.R2dbcRepositoryFactory +import org.springframework.data.relational.core.mapping.RelationalMappingContext +import org.springframework.r2dbc.core.DatabaseClient +import org.testcontainers.containers.MySQLContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers +import java.nio.ByteBuffer +import java.sql.Connection +import java.sql.Timestamp +import java.time.Instant + +@Testcontainers +class UncheckedTransactionMigrationTest { + @BeforeEach + fun cleanDatabase() { + flyway().clean() + } + + @Test + fun `migrates a clean database through the oldest index`() { + // when + flyway().migrate() + + // then + assertEquals( + "11", + flyway() + .info() + .current() + .version + .version, + ) + mysql.createConnection("").use { connection -> + assertEquals( + listOf("timestamp", "public_key", "height"), + indexColumns(connection), + ) + } + } + + @Test + fun `migrates a populated V10 database without changing unchecked rows`() { + // given + flyway(MigrationVersion.fromVersion("10")).migrate() + mysql.createConnection("").use { connection -> + seedUncheckedTransactions(connection, 5_000) + val before = uncheckedChecksum(connection) + + // when + flyway().migrate() + + // then + assertEquals(before, uncheckedChecksum(connection)) + assertEquals( + listOf("timestamp", "public_key", "height"), + indexColumns(connection), + ) + + val plan = resolutionPlan(connection) + assertTrue(plan.contains("Covering index scan on candidate using unchecked_transaction_oldest"), plan) + assertEquals(1_000, resolutionCount(connection)) + + assertTrue(hasHigherUncheckedTransaction(connection, bytes(7), 4_999)) + assertTrue( + higherUncheckedTransactionPlan(connection, bytes(7), 4_999) + .contains("unchecked_transaction_public_key_height"), + ) + assertTrue(!hasHigherUncheckedTransaction(connection, bytes(7), 5_000)) + + assertEquals(1_000, cleanUp(connection, 1_000)) + assertEquals(4_000L, uncheckedCount(connection)) + } + } + + @Test + fun `persists a full multi-row batch while ignoring duplicates through R2DBC`() = + runBlocking { + // given + flyway().migrate() + val databaseClient = databaseClient() + val inserter = UncheckedTransactionInserter(databaseClient) + val transactions = (0..<1_000).map { uncheckedTransaction(it) } + + // when + val affected = inserter.insert(transactions) + inserter.insert(transactions) + val newTransaction = uncheckedTransaction(2_000) + inserter.insert(listOf(newTransaction, newTransaction)) + val existingHeight = uncheckedTransaction(2_001) + val conflictingHeight = + existingHeight.copy( + block = + (existingHeight.block as AttoReceiveBlock).copy( + sendHash = AttoHash(hash(99_999)), + ), + ) + inserter.insert(listOf(existingHeight, conflictingHeight)) + + // then + assertEquals(1_000L, affected) + assertEquals(1L, hasHigherUncheckedTransaction(databaseClient, hash(1), 1)) + assertEquals(0L, hasHigherUncheckedTransaction(databaseClient, hash(1), 2)) + mysql.createConnection("").use { connection -> + connection + .createStatement() + .use { statement -> + statement + .executeQuery( + "SELECT COUNT(*), SUM(previous IS NULL) FROM unchecked_transaction", + ).use { results -> + results.next() + assertEquals(1_002L, results.getLong(1)) + assertEquals(1L, results.getLong(2)) + } + } + } + } + + @Test + fun `maps serialized resolution candidates in deterministic order`() = + runBlocking { + // given + flyway().migrate() + val databaseClient = databaseClient() + val inserter = UncheckedTransactionInserter(databaseClient) + val timestamp = AttoInstant.now() + val transactions = (0..<3).map { uncheckedTransaction(it, timestamp) } + inserter.insert(transactions.reversed()) + + // when + val candidates = + databaseClient + .sql( + """ + SELECT ut.serialized + FROM ( + SELECT candidate.hash, candidate.timestamp, candidate.public_key, candidate.height + FROM unchecked_transaction candidate + LEFT JOIN account a ON a.public_key = candidate.public_key + WHERE candidate.height > COALESCE(a.height, 0) + ORDER BY candidate.timestamp, candidate.public_key, candidate.height + LIMIT :limit + ) oldest + JOIN unchecked_transaction ut ON ut.hash = oldest.hash + ORDER BY oldest.timestamp, oldest.public_key, oldest.height + """.trimIndent(), + ).bind("limit", transactions.size.toLong()) + .map { row, _ -> UncheckedTransactionDeserializerDBConverter().convert(row) } + .all() + .collectList() + .awaitSingle() + + // then + assertEquals(transactions.map { it.hash }, candidates.map { it.hash }) + } + + @Test + fun `prioritizes account gaps by oldest boundary timestamp`() = + runBlocking { + // given + flyway().migrate() + val firstPublicKey = bytes(1) + val secondPublicKey = bytes(2) + mysql.createConnection("").use { connection -> + seedAccount(connection, firstPublicKey, 2) + seedAccount(connection, secondPublicKey, 0) + seedUncheckedTransaction(connection, firstPublicKey, 3, 1_003) + seedUncheckedTransaction(connection, firstPublicKey, 6, 1_005) + seedUncheckedTransaction(connection, secondPublicKey, 3, 2_002) + } + val repository = uncheckedTransactionRepository() + + // when + val gaps = repository.findGaps(2).toList() + + // then + assertEquals( + listOf( + GapView( + publicKey = AttoPublicKey(secondPublicKey), + startHeight = 1U.toAttoHeight(), + endHeight = 2U.toAttoHeight(), + expectedEndHash = AttoHash(hash(2_002)), + ), + GapView( + publicKey = AttoPublicKey(firstPublicKey), + startHeight = 4U.toAttoHeight(), + endHeight = 5U.toAttoHeight(), + expectedEndHash = AttoHash(hash(1_005)), + ), + ), + gaps, + ) + } + + @Test + fun `selects the lowest gap for an account before applying timestamp priority`() = + runBlocking { + // given + flyway().migrate() + val publicKey = bytes(5) + mysql.createConnection("").use { connection -> + seedAccount(connection, publicKey, 0) + seedUncheckedTransaction( + connection, + publicKey, + height = 2, + previous = 5_001, + timestamp = Instant.parse("2026-08-03T00:00:10Z"), + ) + seedUncheckedTransaction( + connection, + publicKey, + height = 5, + previous = 5_004, + timestamp = Instant.parse("2026-08-03T00:00:01Z"), + ) + } + + // when + val gap = uncheckedTransactionRepository().findGaps(1).toList().single() + + // then + assertEquals( + GapView( + publicKey = AttoPublicKey(publicKey), + startHeight = 1U.toAttoHeight(), + endHeight = 1U.toAttoHeight(), + expectedEndHash = AttoHash(hash(5_001)), + ), + gap, + ) + } + + @Test + fun `finds an initial gap for an account missing from the account table`() = + runBlocking { + // given + flyway().migrate() + val publicKey = bytes(3) + mysql.createConnection("").use { connection -> + seedUncheckedTransaction(connection, publicKey, 2, 3_001) + } + + // when + val gap = uncheckedTransactionRepository().findGaps(1).toList().single() + + // then + assertEquals( + GapView( + publicKey = AttoPublicKey(publicKey), + startHeight = 1U.toAttoHeight(), + endHeight = 1U.toAttoHeight(), + expectedEndHash = AttoHash(hash(3_001)), + ), + gap, + ) + } + + @Test + fun `returns no gap for contiguous unchecked transactions`() = + runBlocking { + // given + flyway().migrate() + val publicKey = bytes(4) + mysql.createConnection("").use { connection -> + seedAccount(connection, publicKey, 2) + seedUncheckedTransaction(connection, publicKey, 3, 4_002) + seedUncheckedTransaction(connection, publicKey, 4, 4_003) + } + + // when + val gaps = uncheckedTransactionRepository().findGaps(1).toList() + + // then + assertTrue(gaps.isEmpty()) + } + + private suspend fun hasHigherUncheckedTransaction( + databaseClient: DatabaseClient, + publicKey: ByteArray, + height: Long, + ): Long = + databaseClient + .sql( + """ + SELECT EXISTS ( + SELECT 1 + FROM unchecked_transaction + WHERE public_key = :publicKey + AND height > :height + ) + """.trimIndent(), + ).bind("publicKey", publicKey) + .bind("height", height) + .map { row -> row.get(0, Long::class.javaObjectType)!! } + .one() + .awaitSingle() + + private fun flyway(target: MigrationVersion? = null): Flyway { + val configuration = + Flyway + .configure() + .dataSource(mysql.jdbcUrl, mysql.username, mysql.password) + .cleanDisabled(false) + if (target != null) { + configuration.target(target) + } + return configuration.load() + } + + private fun seedUncheckedTransactions( + connection: Connection, + count: Int, + ) { + val publicKey = bytes(7) + val baseTimestamp = Instant.parse("2026-08-03T00:00:00Z") + + connection + .prepareStatement( + """ + INSERT INTO account ( + public_key, network, version, algorithm, height, balance, + last_transaction_timestamp, last_transaction_hash, + representative_algorithm, representative_public_key + ) VALUES (?, 'LOCAL', 0, 'V1', 4000, 0, ?, ?, 'V1', ?) + """.trimIndent(), + ).use { statement -> + statement.setBytes(1, publicKey) + statement.setTimestamp(2, Timestamp.from(baseTimestamp)) + statement.setBytes(3, bytes(8)) + statement.setBytes(4, publicKey) + statement.executeUpdate() + } + + connection.autoCommit = false + connection + .prepareStatement( + """ + INSERT INTO unchecked_transaction ( + hash, public_key, height, previous, serialized, timestamp, received_at + ) VALUES (?, ?, ?, NULL, ?, ?, ?) + """.trimIndent(), + ).use { statement -> + repeat(count) { offset -> + val height = offset + 1L + val timestamp = Timestamp.from(baseTimestamp.plusMillis(height)) + statement.setBytes(1, hash(height)) + statement.setBytes(2, publicKey) + statement.setLong(3, height) + statement.setBytes(4, byteArrayOf(1)) + statement.setTimestamp(5, timestamp) + statement.setTimestamp(6, timestamp) + statement.addBatch() + } + statement.executeBatch() + } + connection.commit() + connection.autoCommit = true + } + + private fun seedAccount( + connection: Connection, + publicKey: ByteArray, + height: Long, + ) { + val timestamp = Timestamp.from(Instant.parse("2026-08-03T00:00:00Z")) + connection + .prepareStatement( + """ + INSERT INTO account ( + public_key, network, version, algorithm, height, balance, + last_transaction_timestamp, last_transaction_hash, + representative_algorithm, representative_public_key + ) VALUES (?, 'LOCAL', 0, 'V1', ?, 0, ?, ?, 'V1', ?) + """.trimIndent(), + ).use { statement -> + statement.setBytes(1, publicKey) + statement.setLong(2, height) + statement.setTimestamp(3, timestamp) + statement.setBytes(4, hash(height)) + statement.setBytes(5, publicKey) + statement.executeUpdate() + } + } + + private fun seedUncheckedTransaction( + connection: Connection, + publicKey: ByteArray, + height: Long, + previous: Long, + timestamp: Instant = Instant.parse("2026-08-03T00:00:00Z").plusMillis(height), + ) { + val sqlTimestamp = Timestamp.from(timestamp) + connection + .prepareStatement( + """ + INSERT INTO unchecked_transaction ( + hash, public_key, height, previous, serialized, timestamp, received_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + ).use { statement -> + statement.setBytes(1, hash(previous + 1)) + statement.setBytes(2, publicKey) + statement.setLong(3, height) + statement.setBytes(4, hash(previous)) + statement.setBytes(5, byteArrayOf(1)) + statement.setTimestamp(6, sqlTimestamp) + statement.setTimestamp(7, sqlTimestamp) + statement.executeUpdate() + } + } + + private fun resolutionPlan(connection: Connection): String = + connection + .createStatement() + .use { statement -> + statement + .executeQuery( + """ + EXPLAIN FORMAT=TREE + SELECT ut.serialized + FROM ( + SELECT candidate.hash, candidate.timestamp, candidate.public_key, candidate.height + FROM unchecked_transaction candidate + LEFT JOIN account a ON a.public_key = candidate.public_key + WHERE candidate.height > COALESCE(a.height, 0) + ORDER BY candidate.timestamp, candidate.public_key, candidate.height + LIMIT 1000 + ) oldest + JOIN unchecked_transaction ut ON ut.hash = oldest.hash + ORDER BY oldest.timestamp, oldest.public_key, oldest.height + """.trimIndent(), + ).use { results -> + results.next() + results.getString(1) + } + } + + private fun resolutionCount(connection: Connection): Int = + connection + .createStatement() + .use { statement -> + statement + .executeQuery( + """ + SELECT COUNT(*) + FROM ( + SELECT candidate.hash + FROM unchecked_transaction candidate + LEFT JOIN account a ON a.public_key = candidate.public_key + WHERE candidate.height > COALESCE(a.height, 0) + ORDER BY candidate.timestamp, candidate.public_key, candidate.height + LIMIT 1000 + ) oldest + """.trimIndent(), + ).use { results -> + results.next() + results.getInt(1) + } + } + + private fun hasHigherUncheckedTransaction( + connection: Connection, + publicKey: ByteArray, + height: Long, + ): Boolean = + connection + .prepareStatement( + """ + SELECT EXISTS ( + SELECT 1 + FROM unchecked_transaction + WHERE public_key = ? + AND height > ? + ) + """.trimIndent(), + ).use { statement -> + statement.setBytes(1, publicKey) + statement.setLong(2, height) + statement.executeQuery().use { results -> + results.next() + results.getBoolean(1) + } + } + + private fun higherUncheckedTransactionPlan( + connection: Connection, + publicKey: ByteArray, + height: Long, + ): String = + connection + .prepareStatement( + """ + EXPLAIN FORMAT=TREE + SELECT EXISTS ( + SELECT 1 + FROM unchecked_transaction + WHERE public_key = ? + AND height > ? + ) + """.trimIndent(), + ).use { statement -> + statement.setBytes(1, publicKey) + statement.setLong(2, height) + statement.executeQuery().use { results -> + results.next() + results.getString(1) + } + } + + private fun cleanUp( + connection: Connection, + limit: Int, + ): Int = + connection + .prepareStatement( + """ + DELETE FROM unchecked_transaction + WHERE EXISTS ( + SELECT 1 + FROM account a + WHERE a.public_key = unchecked_transaction.public_key + AND unchecked_transaction.height <= a.height + ) + LIMIT ? + """.trimIndent(), + ).use { statement -> + statement.setInt(1, limit) + statement.executeUpdate() + } + + private fun indexColumns(connection: Connection): List = + connection + .prepareStatement( + """ + SELECT column_name + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'unchecked_transaction' + AND index_name = 'unchecked_transaction_oldest' + ORDER BY seq_in_index + """.trimIndent(), + ).use { statement -> + statement.executeQuery().use { results -> + buildList { + while (results.next()) { + add(results.getString(1)) + } + } + } + } + + private fun uncheckedChecksum(connection: Connection): Pair = + connection + .createStatement() + .use { statement -> + statement + .executeQuery( + "SELECT COUNT(*), COALESCE(SUM(CRC32(hash)), 0) FROM unchecked_transaction", + ).use { results -> + results.next() + results.getLong(1) to results.getBigDecimal(2).toPlainString() + } + } + + private fun uncheckedCount(connection: Connection): Long = + connection + .createStatement() + .use { statement -> + statement.executeQuery("SELECT COUNT(*) FROM unchecked_transaction").use { results -> + results.next() + results.getLong(1) + } + } + + private fun connectionFactory(): ConnectionFactory { + val options = + ConnectionFactoryOptions + .parse(mysql.jdbcUrl.replace("jdbc:", "r2dbc:")) + .mutate() + .option(ConnectionFactoryOptions.USER, mysql.username) + .option(ConnectionFactoryOptions.PASSWORD, mysql.password) + .build() + return ConnectionFactories.get(options) + } + + private fun databaseClient(): DatabaseClient = DatabaseClient.create(connectionFactory()) + + private fun uncheckedTransactionRepository(): UncheckedTransactionRepository { + val conversions = ConversionsConfiguration().customConversions() + val mappingContext = + RelationalMappingContext().apply { + setSimpleTypeHolder(conversions.simpleTypeHolder) + setInitialEntitySet(setOf(UncheckedTransaction::class.java)) + afterPropertiesSet() + } + val converter = MappingR2dbcConverter(mappingContext, conversions) + val strategy = DefaultReactiveDataAccessStrategy(MySqlDialect.INSTANCE, converter) + val template = R2dbcEntityTemplate(DatabaseClient.create(connectionFactory()), strategy) + return R2dbcRepositoryFactory(template).getRepository(UncheckedTransactionRepository::class.java) + } + + private fun hash(value: Long): ByteArray = + ByteArray(32).also { + ByteBuffer.wrap(it, 24, Long.SIZE_BYTES).putLong(value) + } + + private fun uncheckedTransaction( + index: Int, + timestamp: AttoInstant = AttoInstant.now(), + ): UncheckedTransaction { + val publicKey = AttoPublicKey(hash(index.toLong())) + val block = + if (index == 0) { + AttoOpenBlock( + version = 0U.toAttoVersion(), + network = AttoNetwork.LOCAL, + algorithm = AttoAlgorithm.V1, + publicKey = publicKey, + balance = AttoAmount.MAX, + timestamp = timestamp, + sendHashAlgorithm = AttoAlgorithm.V1, + sendHash = AttoHash(hash(10_000)), + representativeAlgorithm = AttoAlgorithm.V1, + representativePublicKey = publicKey, + ) + } else { + AttoReceiveBlock( + version = 0U.toAttoVersion(), + network = AttoNetwork.LOCAL, + algorithm = AttoAlgorithm.V1, + publicKey = publicKey, + height = 2U.toAttoHeight(), + balance = AttoAmount.MAX, + timestamp = timestamp, + previous = AttoHash(hash(index.toLong() + 10_000)), + sendHashAlgorithm = AttoAlgorithm.V1, + sendHash = AttoHash(hash(index.toLong() + 20_000)), + ) + } + + return Transaction( + block = block, + signature = AttoSignature(ByteArray(64) { index.toByte() }), + work = AttoWork(ByteArray(8) { (index + 1).toByte() }), + ).toUncheckedTransaction() + } + + private fun bytes(marker: Byte): ByteArray = ByteArray(32) { marker } + + companion object { + @Container + @JvmStatic + val mysql: MySQLContainer<*> = + MySQLContainer("mysql:8.4") + .withDatabaseName("node") + .withUsername("node") + .withPassword("node") + } +} diff --git a/src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessorStarterTest.kt b/src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessorStarterTest.kt new file mode 100644 index 00000000..632f4bf3 --- /dev/null +++ b/src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionProcessorStarterTest.kt @@ -0,0 +1,113 @@ +package cash.atto.node.bootstrap.unchecked + +import cash.atto.node.bootstrap.discovery.DiscoveryProperties +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset + +class UncheckedTransactionProcessorStarterTest { + @Test + fun `skips repeated scans until work changes or maintenance is due`() = + runTest { + // given + val repository = mockk() + val processor = mockk(relaxed = true) + val service = mockk() + val workTracker = UncheckedWorkTracker() + val clock = MutableClock() + val starter = newStarter(repository, processor, service, workTracker, clock) + + coEvery { repository.findTopOldest(1_000L) } returns emptyFlow() + coEvery { service.cleanUp(1_000L) } returns 0 + + // when + starter.process() + starter.process() + + // then + coVerify(exactly = 1) { repository.findTopOldest(1_000L) } + coVerify(exactly = 1) { service.cleanUp(1_000L) } + + // when + workTracker.markChanged() + starter.process() + + // then + coVerify(exactly = 2) { repository.findTopOldest(1_000L) } + + // when + starter.process() + clock.advance(Duration.ofSeconds(30)) + starter.process() + + // then + coVerify(exactly = 3) { repository.findTopOldest(1_000L) } + coVerify(exactly = 3) { service.cleanUp(1_000L) } + } + + @Test + fun `bounds cleanup work per scheduled pass`() = + runTest { + // given + val repository = mockk() + val processor = mockk(relaxed = true) + val service = mockk() + val starter = + newStarter( + repository, + processor, + service, + UncheckedWorkTracker(), + MutableClock(), + ) + + coEvery { repository.findTopOldest(1_000L) } returns emptyFlow() + coEvery { service.cleanUp(1_000L) } returns 1_000 + + // when + starter.process() + + // then + coVerify(exactly = 10) { service.cleanUp(1_000L) } + } + + private fun newStarter( + repository: UncheckedTransactionRepository, + processor: UncheckedTransactionProcessor, + service: UncheckedTransactionService, + workTracker: UncheckedWorkTracker, + clock: Clock, + ): UncheckedTransactionProcessorStarter = + UncheckedTransactionProcessorStarter( + uncheckedTransactionRepository = repository, + processor = processor, + uncheckedTransactionService = service, + workTracker = workTracker, + discoveryProperties = DiscoveryProperties(), + meterRegistry = SimpleMeterRegistry(), + clock = clock, + ) + + private class MutableClock( + private var current: Instant = Instant.parse("2026-08-03T00:00:00Z"), + ) : Clock() { + override fun getZone(): ZoneId = ZoneOffset.UTC + + override fun withZone(zone: ZoneId): Clock = this + + override fun instant(): Instant = current + + fun advance(duration: Duration) { + current = current.plus(duration) + } + } +} diff --git a/src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionServiceTest.kt b/src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionServiceTest.kt new file mode 100644 index 00000000..fc533719 --- /dev/null +++ b/src/test/kotlin/cash/atto/node/bootstrap/unchecked/UncheckedTransactionServiceTest.kt @@ -0,0 +1,29 @@ +package cash.atto.node.bootstrap.unchecked + +import cash.atto.node.EventPublisher +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class UncheckedTransactionServiceTest { + @Test + fun `cleans up at most the requested unchecked transactions`() = + runTest { + // given + val repository = mockk() + val inserter = mockk() + val eventPublisher = mockk() + val service = UncheckedTransactionService(repository, inserter, eventPublisher) + coEvery { repository.deleteExistingInTransaction(1_000L) } returns 37 + + // when + val deleted = service.cleanUp(1_000L) + + // then + assertEquals(37, deleted) + coVerify(exactly = 1) { repository.deleteExistingInTransaction(1_000L) } + } +} diff --git a/src/test/kotlin/cash/atto/node/transaction/TransactionNetworkProviderTest.kt b/src/test/kotlin/cash/atto/node/transaction/TransactionNetworkProviderTest.kt new file mode 100644 index 00000000..b4266e19 --- /dev/null +++ b/src/test/kotlin/cash/atto/node/transaction/TransactionNetworkProviderTest.kt @@ -0,0 +1,203 @@ +package cash.atto.node.transaction + +import cash.atto.commons.AttoAlgorithm +import cash.atto.commons.AttoAmount +import cash.atto.commons.AttoHash +import cash.atto.commons.AttoInstant +import cash.atto.commons.AttoNetwork +import cash.atto.commons.AttoPublicKey +import cash.atto.commons.AttoSendBlock +import cash.atto.commons.AttoSignature +import cash.atto.commons.AttoWork +import cash.atto.commons.toAttoHeight +import cash.atto.commons.toAttoVersion +import cash.atto.node.network.InboundNetworkMessage +import cash.atto.node.network.MessageSource +import cash.atto.node.network.NetworkMessagePublisher +import cash.atto.node.network.NodeConnected +import cash.atto.protocol.AttoNode +import cash.atto.protocol.AttoTransactionStreamRequest +import cash.atto.protocol.NodeFeature +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.net.InetSocketAddress +import java.net.URI +import java.util.concurrent.atomic.AtomicInteger + +class TransactionNetworkProviderTest { + @Test + fun `serves transaction streams concurrently`() = + runTest { + // given + val repository = mockk() + val active = AtomicInteger() + val bothStarted = CompletableDeferred() + val release = CompletableDeferred() + coEvery { repository.findDesc(any(), any(), any()) } answers { + flow { + if (active.incrementAndGet() == 2) { + bothStarted.complete(Unit) + } + release.await() + active.decrementAndGet() + } + } + val fixture = fixture(repository) + + // when + val first = launch { fixture.provider.stream(fixture.request(publicKey(1))) } + val second = launch { fixture.provider.stream(fixture.request(publicKey(2))) } + runCurrent() + + // then + assertTrue(bothStarted.isCompleted) + + release.complete(Unit) + first.join() + second.join() + } + + @Test + fun `limits concurrent transaction streams to the protocol maximum`() = + runTest { + // given + val repository = mockk() + val active = AtomicInteger() + val maximumActive = AtomicInteger() + val started = AtomicInteger() + val releases = Channel(Channel.UNLIMITED) + coEvery { repository.findDesc(any(), any(), any()) } answers { + flow { + val activeCount = active.incrementAndGet() + maximumActive.updateAndGet { maxOf(it, activeCount) } + started.incrementAndGet() + releases.receive() + active.decrementAndGet() + } + } + val fixture = fixture(repository) + val jobs = + (1..AttoTransactionStreamRequest.MAX_PARALLEL_STREAMS + 1).map { marker -> + launch { fixture.provider.stream(fixture.request(publicKey(marker.toByte()))) } + } + + // when + runCurrent() + + // then + assertEquals(AttoTransactionStreamRequest.MAX_PARALLEL_STREAMS, started.get()) + assertEquals(AttoTransactionStreamRequest.MAX_PARALLEL_STREAMS, maximumActive.get()) + + repeat(jobs.size) { + releases.trySend(Unit) + } + advanceUntilIdle() + assertEquals(jobs.size, started.get()) + } + + @Test + fun `materializes a transaction stream before publishing paced responses`() = + runTest { + // given + val repository = mockk() + val transaction = transaction(publicKey(1)) + val collected = CompletableDeferred() + coEvery { repository.findDesc(any(), any(), any()) } returns + flow { + try { + emit(transaction) + } finally { + collected.complete(Unit) + } + } + val publisher = mockk() + every { publisher.publish(any()) } answers { + assertTrue(collected.isCompleted) + } + val fixture = fixture(repository, publisher) + + // when + fixture.provider.stream(fixture.request(transaction.publicKey)) + + // then + assertTrue(collected.isCompleted) + } + + private fun fixture( + repository: TransactionRepository, + publisher: NetworkMessagePublisher = mockk(relaxed = true), + ): Fixture { + val thisNode = node(100) + val peer = node(101) + val provider = TransactionNetworkProvider(thisNode, repository, publisher) + provider.add(NodeConnected(socketAddress(peer), peer)) + return Fixture(provider, peer) + } + + private data class Fixture( + val provider: TransactionNetworkProvider, + val peer: AttoNode, + ) { + fun request(publicKey: AttoPublicKey): InboundNetworkMessage = + InboundNetworkMessage( + source = MessageSource.WEBSOCKET, + publicUri = peer.publicUri, + socketAddress = socketAddress(peer), + payload = + AttoTransactionStreamRequest( + publicKey, + 1U.toAttoHeight(), + 1U.toAttoHeight(), + ), + ) + } + + private companion object { + fun node(marker: Byte): AttoNode = + AttoNode( + network = AttoNetwork.LOCAL, + protocolVersion = AttoNode.CURRENT_PROTOCOL_VERSION, + algorithm = AttoAlgorithm.V1, + publicKey = publicKey(marker), + publicUri = URI("ws://127.0.0.1:${8_000 + marker}"), + features = setOf(NodeFeature.HISTORICAL), + ) + + fun socketAddress(node: AttoNode): InetSocketAddress = InetSocketAddress(node.publicUri.host, node.publicUri.port) + + fun transaction(publicKey: AttoPublicKey): Transaction { + val block = + AttoSendBlock( + version = 0U.toAttoVersion(), + network = AttoNetwork.LOCAL, + algorithm = AttoAlgorithm.V1, + publicKey = publicKey, + height = 1U.toAttoHeight(), + balance = AttoAmount.MAX, + timestamp = AttoInstant.now(), + previous = AttoHash(ByteArray(32)), + receiverAlgorithm = AttoAlgorithm.V1, + receiverPublicKey = publicKey(102), + amount = AttoAmount.MAX, + ) + return Transaction( + block = block, + signature = AttoSignature(ByteArray(64) { 1 }), + work = AttoWork(ByteArray(8) { 2 }), + ) + } + + fun publicKey(marker: Byte): AttoPublicKey = AttoPublicKey(ByteArray(32) { marker }) + } +}