Context
The EvmAccountBatchService buffers EVM account sightings (from transaction senders and recipients) and flushes them in batches to the EvmAccountRepository. It uses the same dual-trigger mechanism as the existing Solana AccountBatchService but with a slower cadence (200 accounts / 2 seconds) since account tracking is less latency-sensitive than transaction ingestion. The bounded ArrayBlockingQueue(10_000) drops entries when full to prevent memory exhaustion. Deduplication by EvmAddress keeps only the account with the highest block number.
Specification
File
prism/src/main/java/com/stablebridge/prism/domain/service/EvmAccountBatchService.java
Constructor dependencies
EvmAccountRepository — the domain port for account persistence
- Batch size:
200 (constant)
- Batch timeout:
2 seconds (constant)
- Queue capacity:
10_000 (constant)
Implementation pattern
Follow AccountBatchService.java exactly:
@Singleton
@RequiredArgsConstructor
@Slf4j
public class EvmAccountBatchService implements Lifecycle {
private static final int BATCH_SIZE = 200;
private static final Duration BATCH_TIMEOUT = Duration.ofSeconds(2);
private static final int QUEUE_CAPACITY = 10_000;
private final EvmAccountRepository accountRepo;
private final ArrayBlockingQueue<EvmAccount> queue = new ArrayBlockingQueue<>(QUEUE_CAPACITY);
private volatile boolean running;
private Thread drainThread;
/**
* Enqueue an account sighting. Drops silently if queue is full.
*/
public boolean enqueue(EvmAccount account) {
return queue.offer(account);
}
@Override
public void start() {
running = true;
drainThread = Thread.ofVirtual().name("evm-acct-batch-drain").start(this::drainLoop);
}
@Override
public void stop() {
running = false;
if (drainThread != null) drainThread.interrupt();
}
private void drainLoop() {
var batch = new ArrayList<EvmAccount>(BATCH_SIZE);
while (running) {
try {
var first = queue.poll(BATCH_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
if (first != null) {
batch.add(first);
queue.drainTo(batch, BATCH_SIZE - 1);
}
if (!batch.isEmpty()) {
flush(batch);
batch.clear();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
queue.drainTo(batch);
if (!batch.isEmpty()) flush(batch);
}
private void flush(List<EvmAccount> batch) {
// Dedup by EvmAddress — keep highest blockNumber via HashMap.merge()
var deduped = new HashMap<EvmAddress, EvmAccount>();
for (var account : batch) {
deduped.merge(account.address(), account,
(existing, incoming) -> incoming.lastSeenBlock() > existing.lastSeenBlock()
? incoming : existing);
}
// Filter out OP Stack deposit addresses (type 126 transactions)
// Note: filtering logic may need context from transaction type
accountRepo.saveAll(deduped.values().stream().toList());
}
}
Deduplication logic
- Key:
EvmAddress (the account address)
- Merge: keep the entry with the highest
lastSeenBlock — if two sightings of the same address arrive in the same batch, only the most recent one is persisted
- Uses
HashMap.merge() for O(n) dedup
OP Stack deposit filtering
- Addresses derived from OP Stack deposit transactions (type 126) should be filtered from the account batch to avoid polluting the account table with system addresses
- The filtering mechanism depends on how deposit addresses are identified (may need a flag in
EvmAccount or a separate filter predicate)
Design decisions
ArrayBlockingQueue(10_000) — bounded, drops when full (accounts are less critical than transactions)
offer() returns false on full queue (no blocking, no exception)
- Dual-trigger: 200 accounts OR 2 seconds
- Slower cadence than transaction batch (2s vs 100ms) because account data is less latency-sensitive
- Virtual thread for drain loop
- Implements
Lifecycle for clean start/stop
@Singleton, @RequiredArgsConstructor, @Slf4j
Test class
prism/src/test/java/com/stablebridge/prism/domain/service/EvmAccountBatchServiceTest.java
Test cases:
- Single enqueued account is flushed after 2-second timeout
- Batch of 200 accounts is flushed immediately (size trigger)
- Duplicate addresses in batch are deduped (highest block wins)
- Full queue drops new entries (returns false)
- Stop drains remaining items
- Empty queue does not trigger repository
- Repository is called with deduped account list
Acceptance Criteria
Dependencies
References
Context
The
EvmAccountBatchServicebuffers EVM account sightings (from transaction senders and recipients) and flushes them in batches to theEvmAccountRepository. It uses the same dual-trigger mechanism as the existing SolanaAccountBatchServicebut with a slower cadence (200 accounts / 2 seconds) since account tracking is less latency-sensitive than transaction ingestion. The boundedArrayBlockingQueue(10_000)drops entries when full to prevent memory exhaustion. Deduplication byEvmAddresskeeps only the account with the highest block number.Specification
File
prism/src/main/java/com/stablebridge/prism/domain/service/EvmAccountBatchService.javaConstructor dependencies
EvmAccountRepository— the domain port for account persistence200(constant)2 seconds(constant)10_000(constant)Implementation pattern
Follow
AccountBatchService.javaexactly:Deduplication logic
EvmAddress(the account address)lastSeenBlock— if two sightings of the same address arrive in the same batch, only the most recent one is persistedHashMap.merge()for O(n) dedupOP Stack deposit filtering
EvmAccountor a separate filter predicate)Design decisions
ArrayBlockingQueue(10_000)— bounded, drops when full (accounts are less critical than transactions)offer()returnsfalseon full queue (no blocking, no exception)Lifecyclefor clean start/stop@Singleton,@RequiredArgsConstructor,@Slf4jTest class
prism/src/test/java/com/stablebridge/prism/domain/service/EvmAccountBatchServiceTest.javaTest cases:
Acceptance Criteria
EvmAccountBatchServiceexists withArrayBlockingQueue(10_000)and dual-trigger batchingEvmAddresskeeps highest block numberLifecyclewith clean start/stopAccountBatchServicepattern./gradlew buildpassesDependencies
EvmAccountRepositoryinterfaceReferences