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 }) + } +}