Skip to content

EVM-19: EvmAccountBatchService #159

@Puneethkumarck

Description

@Puneethkumarck

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

  • EvmAccountBatchService exists with ArrayBlockingQueue(10_000) and dual-trigger batching
  • Batch size threshold is 200 accounts
  • Batch timeout threshold is 2 seconds
  • Queue drops entries when full (no blocking)
  • Deduplication by EvmAddress keeps highest block number
  • OP Stack deposit addresses are filtered
  • Uses virtual thread for drain loop
  • Implements Lifecycle with clean start/stop
  • Follows existing AccountBatchService pattern
  • All test cases pass
  • ./gradlew build passes

Dependencies

References

Metadata

Metadata

Labels

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions