Interview Guide
Interview Guide
Interview Guide
(Top MNCs: Amazon, Google, Microsoft, Flipkart, PhonePe, Razorpay,
Paytm, Zepto, Swiggy)
How to use this guide: Each section has a "SAY THIS" block — that is exactly what you say in the interview. Read it out loud 5
times. Don't memorize word-for-word, understand the idea and say it naturally.
SAY THIS: "I worked on Zyro, which is a financial transaction reconciliation engine. The core problem it solves is this — in UPI
payments, three systems record every transaction: the bank, NPCI, and our internal system. Zyro automatically ingests transaction
files from all three, normalizes them to a common format, does hash-based matching to detect discrepancies, and generates reports.
It's a backend Go service with PostgreSQL, Kafka, and AWS S3, processing millions of transactions in real time."
2-Minute Deep Intro (When they say "Walk me through the project")
SAY THIS: "So the project is called Zyro — it's a reconciliation management system for UPI transactions.
The problem we're solving is: every UPI payment gets recorded by three different systems — the bank debiting the user, NPCI which
is the network in the middle, and our internal payment system. At end of day, all three need to agree on every transaction. If they
don't, money can be lost or double-counted, and settlement between banks fails.
First, banks and NPCI send CSV or Excel files to our SFTP server. We have a polling worker that picks these files up every 60
seconds. The file is then streamed through a template-based parser — we don't load the whole file into memory, we process 5000
rows at a time. Each row is converted to a canonical transaction format with a SHA256 hash.
The hash is the key idea. We compute SHA256 of the transaction ID and amount. If the bank and our internal system have the same
transaction ID with the same amount, their hashes will be identical — that's a MATCH. If amounts differ even by one paise, hashes
will be different — that's a MISMATCH.
A reconciliation worker runs every 5 seconds, does a merge join on sorted transaction lists, and updates the match status using a
bitfield. For mismatches, a rule engine evaluates whether the difference is within acceptable tolerance — like 2% for rounding
differences.
The whole thing is driven by configuration. Templates define file formats, rules define tolerance, and master config drives which
workers to spin up — so adding a new payment source requires zero code changes.
Tech stack is Go for concurrency, PostgreSQL with two separate DB connections, Kafka for event streaming with TLS, AWS S3 for
file storage, and SFTP for file ingestion."
"Banks and NPCI deliver transaction files to our SFTP server. These are CSV or Excel files with columns like transaction ID, amount,
timestamp, and status. The SFTP worker polls every 60 seconds. It skips files modified less than a minute ago — that's a safety
check so we don't process a file that's still uploading."
"Before we even start reading the file, we create a metadata record in the raw_file table. It stores the file name, which system sent
it (BANK/NPCI/INTERNAL), its status starting as INPROGRESS, and importantly — rows_inserted which starts at 0. This is our
checkpoint for resumability."
"Each file has a different format. A bank file might have columns in a different order than an NPCI file. We resolve a template from
cache — the template tells us 'column 0 is transaction_id, column 1 is amount in paise, column 2 is timestamp in this format.'
Template lookup is from in-memory cache, no DB query needed."
"Each ParsedRow becomes a CanonicalTxn. This is the normalize step — every system's data ends up in the same struct with the
same field names. The most important thing here is the hash: SHA256 of recon_key + amount. This is our transaction fingerprint.
Two records with the same recon_key and same amount will always produce the same hash."
Step 6 — Deduplication
"Before inserting, we check if this recon_key already exists for this system. If a bank resends a corrected file, we mark the old record
is_latest = false and insert the new one as is_latest = true. Reconciliation only looks at is_latest = true records."
"After insertion, we upsert a record in recon_master. This is our reconciliation state table. It has a system_mask bitfield showing
which systems have reported this transaction, and a pair_status_mask bitfield showing the comparison result between each pair.
Initially, status is PENDING."
"Separately, an Internal Sync Worker pulls transactions from our own internal system via a read-replica every 10 seconds. It tracks its
last-pulled timestamp in internal_pull_state and only fetches new records since then — like a cursor. These also go through
canonicalization and get inserted with SystemID = INTERNAL."
"Every 5 seconds, the Reconciliation Worker fetches all PENDING records from recon_master. It gets the INTERNAL transactions
and the BANK/NPCI transactions for those keys, sorts both lists by recon_key, and runs a merge join — same algorithm as merge
sort's merge step. For each matching key pair it compares hashes. Same hash = MATCH. Different hash = MISMATCH with a
HashMismatch object stored."
"Mismatches go to the rule engine. Rules are configured in DB and cached. A rule has conditions like: amount difference within 2%,
timestamp within 60 seconds, description fuzzy match. If all conditions pass, the mismatch is actually acceptable — maybe it's a
known rounding issue. The engine records its decision in rule_evaluations for audit."
"If processing fails at row 4500 of 10000, the DB transaction rolls back for that batch and rows_inserted stays at 4500. The
RetryWorker picks up the failed file, opens the file stream, skips the first 4500 rows, and resumes from row 4501. No manual
intervention needed."
SAY THIS: "The most complex part of Zyro for me was the reconciliation matching logic combined with the bitfield state management.
The challenge was: we have three systems sending data asynchronously, and we need to track the exact reconciliation status of
every transaction across every pair of systems — efficiently, in a single DB column.
The solution was a two-level bitfield. system_mask tracks which systems have arrived, and pair_status_mask tracks the 2-bit
match result for each system pair at specific bit offsets. The reconciliation worker does a merge join on sorted data — O(n log n)
instead of O(n²) nested loops.
What made it complex was the asynchronous nature — BANK data might arrive before INTERNAL data, so we needed the PENDING
state and a retry loop, rather than waiting or blocking."
SAY THIS: "The biggest challenge was designing for failures. Financial processing cannot lose data. Three specific problems we
solved:
First — partial file processing. If a server crashes at row 4999 of 10000, you can't re-process from the beginning or you'd double-
count 4999 transactions. Our solution: rows_inserted checkpoint + resume from offset.
Second — concurrent file processing. If two service instances try to process the same file, you get duplicates. Our solution: database
lease (lease_expires_at) that each worker sets and extends every batch.
Third — stale data. Banks resend corrected files. is_latest flag ensures only the newest version is used for reconciliation while old
versions remain for audit."
SAY THIS: "A few things. First, I'd add distributed tracing from the start — right now debugging a failed reconciliation means manually
joining multiple tables. OpenTelemetry traces would connect the file ingestion, canonicalization, and reconciliation steps automatically.
Second, the rule engine conditions are stored as JSON in the DB — parsing and evaluating JSON on every mismatch has overhead.
I'd consider compiling rules into an AST or using a proper expression evaluator.
Third, the polling workers use fixed intervals (5s, 10s, 60s). I'd replace these with event-driven triggers via Kafka — process
immediately when a file arrives instead of waiting up to 5 seconds."
The cache layer means adding more instances doesn't increase DB load from config lookups. The streaming file processing means
memory usage stays constant regardless of file size. The streaming pipeline processes each chunk, commits it, and moves on —
memory from the previous chunk is freed."
SAY THIS: "MergeJoin is the same as the merge step in merge sort. You have two sorted arrays. You maintain two pointers — one
for each array. At each step, you compare the current elements. If they match, process the pair and advance both pointers. If one is
smaller, it means that key only exists in one list, advance that pointer. This is O(n) for the join step itself. Total complexity is O(n log n)
for sorting both arrays first, then O(n) for the join. Compared to a naive nested loop which would be O(n²), for 1 million transactions
this is the difference between 1 million operations and 1 trillion operations."
Q6: Why use a bitfield for system_mask and pair_status_mask instead of separate columns?
SAY THIS: "Three reasons. First, efficiency — one integer column stores the presence/status of 64 different systems. Separate
columns would require a schema change every time a new system is added. Second, atomic updates — setting multiple pair statuses
in one integer update is a single atomic operation. With separate columns, updating 3 columns is 3 operations that need a
transaction. Third, query speed — checking 'has system BANK arrived?' is system_mask & BANK_BIT != 0, a single CPU
instruction. Querying a boolean column would require an index lookup."
SAY THIS: "The hash acts as a transaction fingerprint. Instead of comparing each field individually — amount, timestamp, status,
reference numbers — we compare two strings. Same hash means all fields agree. Different hash means something differs, and we
hand it to the rule engine.
SHA256 specifically because: it's deterministic (same input always produces same output), collision-resistant (two different inputs
producing the same hash is computationally infeasible), and fixed-length (always 64 hex characters regardless of input size). For
financial data, SHA256's collision resistance is important — a collision would mean two different transactions appear to match."
Q8: How does your streaming pipeline work? Explain the producer-consumer pattern.
SAY THIS: "We use Go's [Link] which creates a synchronous in-memory pipe — a writer and a reader connected. The producer
(SFTP file reader) runs in a goroutine and writes chunks to the pipe's write end. The consumer (parser) reads from the pipe's read
end in the main goroutine. They run concurrently and synchronize automatically — the producer blocks when the consumer hasn't
caught up, the consumer blocks when the producer hasn't sent data yet. Error propagation works through Go's channel pattern — if
the SFTP connection drops, the producer goroutine returns an error, the pipe closes, and the consumer gets an error on next read.
No partial data leaks."
Q9: What is the time and space complexity of processing a file with N rows?
SAY THIS: "Time: O(N) — each row is processed once through parsing, canonicalization, and insertion. The merge join is O(N log N)
per reconciliation batch but that's a separate step.
Space: O(1) relative to N — we process in chunks of 5000 rows. Memory usage is constant at any file size. The 5000-row chunk is
processed, committed, garbage collected, then the next chunk is loaded. If the file has 10 million rows, memory usage is the same as
if it had 10000 rows."
Q10: Explain how the deduplication logic works. What data structure would you use to find duplicates efficiently?
SAY THIS: "We query the DB for all existing recon_keys from the incoming batch that already exist in canonical_txn for that
system. The result is a set of duplicate keys. Then for the incoming batch, we split it into: keys already in DB (duplicates) and new
keys. For duplicates, we run UPDATE SET is_latest=false, then INSERT the new version. For new keys, just INSERT.
In code, we use a Go map (map[string]bool) to store the duplicate set — O(1) lookup. Without the map, we'd need O(n²) nested
loops or repeated DB queries. The initial DB query for duplicates is a single WHERE recon_key IN (...) query with the full
batch."
Key decisions:
The main trade-off is consistency vs. availability. We chose eventual consistency — PENDING state with polling retries — over
blocking until all three systems have data, which would create deadlocks when any system is delayed."
Q12: How do you handle the scenario where BANK data arrives but INTERNAL data hasn't synced yet?
SAY THIS: "This is exactly why we have the PENDING state. When BANK file is ingested, we create a recon_master entry with
system_mask = BANK_BIT and status = PENDING. The reconciliation worker sees this is PENDING, checks if INTERNAL data
exists for those keys, and if not, skips and tries again in 5 seconds. Meanwhile, the InternalSyncWorker is independently polling the
internal read-replica every 10 seconds and inserting INTERNAL canonical records. Once both sides are present, the next
reconciliation poll finds the pair and performs the match. No blocking, no waiting — pure eventual consistency."
SAY THIS: "Two protection layers. First, at the raw_file level — we could add a unique constraint on file_name + system_id +
file_date to reject the second insertion at DB level. Second, even if two raw_file records exist, the deduplication in ProcessorService
handles it — when the second file's rows are being inserted, each recon_key already exists, so the old record is marked
is_latest=false and the new identical data is inserted as is_latest=true. Net result: same data, same hash, reconciliation produces
same result. The second ingestion is idempotent."
Q14: How would you add a new payment system (say, Visa) without code changes?
SAY THIS: "That's a design goal we achieved. You'd need to:
Then restart the service. The cache reloads, the SFTP worker discovers the new source, the template registry resolves the new file
format, and the reconciliation worker automatically handles the new pair because pairs come from the DB. Zero code changes."
Q15: How does your caching strategy work? What happens when a cache entry is stale?
SAY THIS: "Each cache runs a background goroutine that reloads from DB every 2 hours. The loaded data is stored in an
[Link] — this is Go's lock-free atomic reference. The swap is atomic: one instruction replaces the old cache snapshot with
the new one. Any goroutine reading from cache either reads the old snapshot or the new one — never a half-updated inconsistent
state.
For stale data: if a new template is added in DB and the cache hasn't refreshed yet (within the 2-hour window), that template won't be
visible. This is an accepted trade-off for our use case — templates don't change mid-day. For rule changes that need immediate
effect, you'd need to add a manual cache invalidation endpoint."
SAY THIS: "Atomicity. If we're inserting a batch of 5000 canonical transactions and the server crashes at transaction 4999, without a
DB transaction those 4999 rows are in the DB permanently. But rows_inserted would also have been updated... or maybe not,
depending on when the crash happened. This creates an inconsistent state that's very hard to recover from.
With a DB transaction: the batch insert, the rows_inserted update, and any recon_master updates all happen atomically. Either all
5000 succeed and rows_inserted jumps from 0 to 5000, or ALL roll back and rows_inserted stays at 0. The retry can safely restart
from 0."
SAY THIS: "The external DB is the bank/switch system's own database. We have read-only access to it via a read replica. Keeping it
as a separate connection pool has several benefits: it enforces that we only read from it (we have no write credentials), connection
pool exhaustion on one doesn't affect the other, and we can configure different pool sizes — internal might need 20 connections for
writes, external read-replica might need 5 since it's only used by InternalSyncWorker."
Q18: What is the N+1 query problem and does your system have it?
SAY THIS: "N+1 problem is when you run 1 query to get N records and then N separate queries to get related data for each record —
N+1 queries total instead of 1 join query. We avoided it by bulk operations. For example, the reconciliation worker doesn't fetch one
transaction at a time — it collects all pending recon_keys, then does a single WHERE recon_key IN (...) query to fetch all
transactions at once. Similarly, batch inserts use PostgreSQL's bulk insert with unnest — one query for 5000 rows instead of 5000
single-row inserts."
Q19: What is an index and would you add any to this system?
SAY THIS: "An index is a sorted data structure (usually B-tree) on a column that speeds up lookups. Without it, the DB scans every
row — O(n). With it — O(log n).
SAY THIS: "Database-level lease. When a worker picks up a file, it does a SELECT...FOR UPDATE on the raw_file row, sets
processing_by = worker_id and lease_expires_at = now + 10 minutes. Other workers filter out files where
lease_expires_at > now. Every batch the active worker extends the lease. If the worker dies, the lease expires after 10 minutes
and another worker can pick it up. This is a distributed lock implemented in PostgreSQL — no need for Redis or Zookeeper."
SAY THIS: "Primarily through design. The cache uses [Link] for lock-free reads — no mutex needed. The DB is the source
of truth for coordination between workers — PostgreSQL's ACID guarantees handle the critical sections. Within a single worker,
processing is sequential within a file — no shared mutable state between goroutines. Where goroutines need to communicate, we
use channels. The streaming pipeline producer-consumer communicates through [Link] which handles synchronization internally."
SAY THIS: "The most important use is the Ready() channel on each cache. Each cache's Ready() returns a channel. When the
cache finishes its first data load, it closes the channel. [Link] does <-[Link]() which blocks until the channel closes. This
is Go's idiomatic way to signal 'I'm done' — a closed channel is always readable and returns immediately. We use this for all four
caches before starting workers, ensuring no worker starts with stale/empty cache."
SAY THIS (STAR format): "Situation: The reconciliation worker was initially doing field-by-field comparison for every transaction pair
— comparing amount, timestamp, status, reference numbers separately. For 100,000 mismatched pairs it was slow.
Task: We needed to speed up the detection step while keeping the detailed analysis for genuine mismatches.
Action: I proposed using SHA256 hashing. Compute a hash of the critical fields once per transaction at ingestion time. Reconciliation
just compares two strings — O(1) per pair. The detailed rule engine evaluation only runs on the subset that actually mismatches,
which is typically less than 1% of transactions.
Result: Reconciliation time dropped significantly. The trade-off was we can't tell WHY records differ from the hash alone — but that's
exactly what the rule engine is for. We got both: fast detection and detailed analysis, on different subsets of data."
Q25: Tell me about a time you had to ensure data was never lost.
SAY THIS (STAR format): "Situation: We had a scenario where file processing could fail midway — network drops, server restarts,
OOM kills. If we lost progress, we'd either miss transactions or double-count them.
Action: I designed the rows_inserted checkpoint system. Every batch of 5000 rows is wrapped in a DB transaction.
rows_inserted only advances on successful commit. If the server crashes, on restart the RetryWorker reads rows_inserted,
opens the file, skips exactly that many rows using the file reader's offset, and resumes. The file reader supports seeking to a byte
offset, so we skip already-processed rows without re-reading them.
Result: Any failure is recoverable. We've processed files of 2 million rows that failed at row 1.5 million and resumed perfectly."
SAY THIS (STAR format): "Situation: The initial implementation of canonical transaction insertion was doing one INSERT per row
— for a 100,000 row file that's 100,000 round trips to the database.
Action: I switched to bulk inserts using PostgreSQL's unnest pattern — pass arrays of values and unnest them into rows in a single
query. One query for 5000 rows instead of 5000 queries. We also moved the deduplication check to a single WHERE recon_key =
ANY($1) query for the whole batch instead of per-row lookups.
Result: File ingestion time for a 100,000 row file dropped from minutes to seconds. DB connection usage dropped dramatically
because each batch uses the connection for seconds instead of minutes."
Q27: How did you handle a bug that only appeared in production?
SAY THIS (STAR format): "Situation: In production, some transactions were showing as MISMATCH even though the bank and our
internal system had the same amount. This wasn't reproducible locally.
Action: I added detailed logging to the hash computation, logging the exact input string to SHA256. I discovered that our internal
system was sending amounts in rupees (₹ 500) while the bank was sending in paise (50000). Locally our test data was all in paise so
we never caught it. The hash inputs were '500' vs '50000' — completely different.
Result: Fixed by adding a MULTIPLY_100 transform rule to the internal system's template. Also added a validation step that checks
all amounts are in paise range (> 100) before hashing to catch this class of bug earlier."
Q28: Tell me about a time you had to work with an ambiguous requirement.
SAY THIS (STAR format): "Situation: The requirement said 'reconcile transactions across systems' but didn't specify what to do
when a BANK transaction exists but INTERNAL doesn't have it yet.
Action: I analyzed the possible states: INTERNAL could arrive first, BANK could arrive first, or they could arrive simultaneously. I
proposed three states — PENDING (waiting for data), MATCH (all systems agree), NO_MATCH (definitive mismatch). The PENDING
state allows asynchronous arrival without blocking. I documented this as the 'eventual consistency model' for the team.
Result: The PENDING + polling design handled all timing scenarios. It also naturally handles the retry case — if INTERNAL data is
late by hours, the reconciliation worker just keeps polling until it arrives."
Q29: Tell me about a time you had to explain a technical decision to a non-technical stakeholder.
SAY THIS (STAR format): "Situation: A product manager asked why we couldn't show reconciliation results instantly after a file is
uploaded.
Task: Explain eventual consistency vs. immediate consistency in a way that made sense.
Action: I used an analogy — it's like comparing two bank statements. You can't compare them until both arrive. Our internal
statement (InternalSyncWorker) takes up to 10 seconds to arrive after a BANK file is uploaded. The reconciliation itself takes up to 5
more seconds. So the minimum latency is 15 seconds. I showed a simple timeline diagram showing when each piece of data arrives.
Result: The PM understood and we agreed on a 'status: processing' state in the UI with a webhook notification when reconciliation
completes. We also added a max-latency SLA of 30 seconds for 99th percentile."
Q30: Describe a situation where you had to balance adding new features vs. fixing technical debt.
SAY THIS (STAR format): "Situation: We needed to add support for a new file format (pipe-delimited) while also having a known
issue — the template column mapping used 0-based index which caused off-by-one errors when files had a header row.
Task: Decide whether to fix the debt first or add the feature.
Action: I fixed the header handling bug first. The reason: if we added a new file format with the same bug, we'd have two buggy
implementations to fix later. The fix was small — add a SkipRows(1) call when [Link] = true. Once the parser
was correct, adding pipe-delimited support was trivial — just a new case in the reader factory.
Result: Fixed both in one PR. The key insight was that the debt was in the foundation — the parser — not in a peripheral feature.
Fixing the foundation first made the feature cleaner."
It matters for three reasons: settlement — banks and NPCI settle net positions at end of day, and disputed transactions can't be
settled; fraud detection — if INTERNAL shows ₹ 500 but bank shows ₹ 5000, someone manipulated a transaction; and regulatory
compliance — RBI mandates that all payment systems maintain reconciliation records."
SAY THIS: "Floating-point arithmetic is dangerous for money. Try 0.1 + 0.2 in Python — you get 0.30000000000000004. If we
stored ₹ 500.01 as a float and compared it to another float representation of ₹ 500.01, they might not be equal due to IEEE 754
precision. By storing in paise as integers (₹ 500.01 = 50001 paise), all arithmetic is exact integer math. This is standard practice in
fintech globally — Stripe, Razorpay, and most payment systems store amounts in the smallest currency unit."
SAY THIS: "DLQ stands for Dead Letter Queue. In Kafka, if a consumer fails to process a message after configured retries, instead of
dropping it, the message is routed to a special DLQ topic. In financial systems, dropping a message could mean a transaction is
never reconciled — meaning money is unaccounted for. The DLQ gives us a safety net: a team member can inspect failed
messages, understand why they failed (maybe malformed data), fix the issue, and reprocess the DLQ messages. Silent drops in
financial systems are unacceptable."
SAY THIS: "Reconciliation is the verification step — confirming that all parties agree on what transactions happened. Settlement is
the actual money movement that happens after verification.
Example: At end of business day, Bank A has 1000 transactions where users of Bank A paid merchants on Bank B via NPCI.
Reconciliation verifies all 1000 are correctly recorded across NPCI and both banks. Settlement then calculates the net amount Bank
A owes Bank B (or vice versa) and transfers it in bulk — instead of 1000 individual transfers.
Our system handles reconciliation. Settlement uses our reconciliation output to know which transactions are valid and their confirmed
amounts."
Key design decisions: amount as int64 not float64 — financial amounts need exact arithmetic. payload_json as raw string not
a struct — the raw format varies by system and we don't want to lose original data. is_latest as boolean not a version number —
we only ever care about current vs. historical, not which version number something is."
Q36: How would you write a test for the reconciliation merge join logic?
I'd use testify/assert for clean error messages. No DB needed — the merge join function only takes sorted slices as input,
making it a pure unit test."
Q37: How would you add rate limiting to the SFTP polling?
SAY THIS: "Go has [Link] for fixed-interval polling — already used for the 60-second SFTP poll. For rate limiting the number
of concurrent file processings (e.g., max 5 files at once), I'd use a buffered channel as a semaphore:
SAY THIS: "An interface in Go defines a set of method signatures. Any type that implements all those methods automatically satisfies
the interface — no explicit declaration needed. This is called implicit interface satisfaction.
In this project we use interfaces everywhere. The Cache interface has Start(ctx) and Ready() methods — all four caches
implement it. The Storage interface has DownloadStream and UploadStream — both local filesystem and S3 implement it. The
Reader interface abstracts CSV and Excel readers. Because of interfaces, the ingestion service doesn't know whether it's reading
from S3 or a local disk — it just calls [Link](). Switching from local to S3 in production requires zero code
changes, just config."
SAY THIS: "defer schedules a function to run when the surrounding function returns — regardless of whether it returns normally or
via panic. It runs in LIFO (last in, first out) order if there are multiple defers.
In this project defer is critical for resource cleanup. When we open an SFTP file stream, we defer [Link]() immediately
after opening — this guarantees the file handle is released even if parsing fails halfway. For DB transactions, we defer
[Link]() right after starting the transaction — if the function returns without calling [Link](), the transaction rolls back
automatically. This prevents connection leaks that would starve the connection pool."
SAY THIS: "Context carries deadlines, cancellation signals, and request-scoped values across API boundaries. Every worker in this
project accepts a [Link] parameter.
Two main uses here: First, cancellation propagation — when the main process receives a SIGTERM (shutdown signal), it cancels the
root context. Every worker's select loop checks [Link]() and gracefully stops. Without context, workers would keep running after
the process is supposed to shut down. Second, timeout control — a database query that hangs forever can be cancelled by setting a
context deadline. For a financial system, a hanging DB query should timeout and retry, not block forever."
Q41: What is a goroutine leak and how would you detect it in this system?
SAY THIS: "A goroutine leak is when a goroutine is started but never terminates — it keeps consuming memory and CPU forever.
Common cause: a goroutine waiting on a channel that nobody will ever send to, or waiting on a network call with no timeout.
In this system a potential leak is in the streaming pipeline — the producer goroutine writes to the pipe, the consumer reads. If the
consumer panics and stops reading without closing the pipe, the producer goroutine blocks forever trying to write. We prevent this by
propagating errors through the context and ensuring both sides properly close the pipe on any error.
Detection: [Link]() tells how many goroutines are running. If this count grows over time without bound, you
have a leak. Tools like pprof can show which goroutines are stuck and why."
Q42: What is select in Go? How is it used in the workers?
SAY THIS: "select is like a switch statement for channels — it waits on multiple channel operations and executes whichever one is
ready first. If multiple are ready simultaneously, it picks one randomly.
for {
select {
case <-[Link]():
return // Shutdown signal received
case <-ticker.C:
doWork() // Poll interval fired
}
}
This lets the worker simultaneously wait for its next poll interval AND a shutdown signal. Without select, you'd have to choose
between checking cancellation and doing work — you can't wait for both at the same time."
Q43: What is the difference between a mutex and a channel? When would you use each?
SAY THIS: "A mutex ([Link]) protects shared state — you lock before reading/writing shared data, unlock after. A channel
transfers data between goroutines — one goroutine sends, another receives.
Rule of thumb: use a channel when you want to communicate data between goroutines. Use a mutex when multiple goroutines need
to read/write the same shared variable.
In this project: the cache uses [Link] (not even a mutex) for reads because reads vastly outnumber writes —
[Link] is faster. For the DB connection pool, pgx uses internal mutexes for pool management. The streaming pipeline uses
[Link] which is channel-like — no shared state, data flows one direction."
SAY THIS: "Panic is Go's mechanism for unrecoverable errors — it stops normal execution, runs deferred functions, and if not
recovered, crashes the program with a stack trace. recover() inside a deferred function can catch a panic and return execution to
normal.
In this project's workers, we'd wrap the main polling loop body in a recover to prevent one bad transaction from crashing the entire
service. For example, if a specific recon_key causes a nil pointer panic in the merge join, we recover, log the error with the recon_key
details, and continue processing the rest. In financial systems, crashing the whole reconciliation process because of one malformed
record is worse than skipping that record and alerting."
In this project, key dependencies in [Link]: jackc/pgx/v5 for PostgreSQL, segmentio/kafka-go for Kafka,
xuri/excelize/v2 for Excel parsing, spf13/viper for config, pkg/sftp for SFTP. When you run go mod tidy, it removes
unused imports and adds missing ones. When you go build, it downloads exactly the versions in [Link] — no surprises."
SAY THIS: "Kafka is a distributed event streaming platform. You publish events to topics and consumers subscribe to those topics.
The key difference from REST: REST is synchronous — the caller waits for a response. Kafka is asynchronous — the publisher
sends an event and continues; consumers process it independently.
For this system, when a file arrives on SFTP, we need to trigger ingestion. With REST, if the ingestion service is down, the event is
lost. With Kafka, the event sits in the topic until the consumer is ready — durability is built-in. Also, multiple consumers can read the
same event independently — if we want to both ingest and audit log a file arrival, both consumers get the same event without
coupling."
SAY THIS: "A consumer group is a group of consumers that share the work of consuming a topic. Kafka partitions the topic, and each
partition is assigned to exactly one consumer in the group at a time. This means: if you have 10 partitions and 5 consumers in a
group, each consumer handles 2 partitions — parallel processing. If a consumer dies, its partitions are rebalanced to surviving
consumers automatically.
In this project, all instances of the Zyro service belong to the same consumer group ID (recon-consumer-group). This ensures
each file upload event is processed by exactly one service instance — no duplicates."
Q48: What is at-least-once delivery in Kafka? How does your system handle duplicates?
SAY THIS: "Kafka guarantees at-least-once delivery by default — a message might be delivered more than once if a consumer
processes a message but crashes before committing the offset. On restart, Kafka re-delivers from the last committed offset.
Our system handles this with idempotency. If a file is re-processed, the deduplication logic (is_latest flag and recon_key
uniqueness check) ensures we don't double-count transactions. The reconciliation result is the same whether a file is processed once
or ten times. This is the correct approach — we accept at-least-once delivery from Kafka but make our processing idempotent."
Q49: What is a Kafka offset?
SAY THIS: "An offset is a unique sequential number assigned to each message in a Kafka partition — like a line number in a file.
Consumers track which offset they've consumed. When a consumer restarts, it resumes from its last committed offset.
This is similar to our rows_inserted checkpoint in file processing — both are cursor-based resumption patterns. The difference:
Kafka manages offsets for you, while our rows_inserted is a manual cursor we maintain in PostgreSQL."
SAY THIS: "TLS (Transport Layer Security) encrypts data in transit between Kafka clients and brokers. Without TLS, transaction data
flowing through Kafka would be in plaintext — anyone with network access between our service and the Kafka broker could read
sensitive financial data.
Our configuration uses mutual TLS — both client and server authenticate each other. The client presents a certificate ([Link]),
the server presents its certificate verified against our CA ([Link]). This prevents man-in-the-middle attacks. For financial data, RBI
guidelines require encryption in transit, so TLS isn't optional."
Factory Pattern — worker/factory_worker.go creates InternalSyncWorker instances based on master_config records from the
DB. The caller doesn't know which concrete worker is created, just that it gets a Worker interface. Same with storage/[Link]
— creates either LocalStorage or S3Storage based on config.
Strategy Pattern — The rule engine uses different evaluation strategies (EQUAL, AMOUNT_TOLERANCE, TIME_RANGE, FUZZY)
that can be swapped based on the condition type. Each condition type is a strategy for comparing two field values.
Template Method Pattern — The generic parser defines the skeleton (read → map → validate → transform → return ParsedRow)
but the concrete reader (CSV vs Excel) implements the actual reading step. The algorithm structure is fixed; only one step varies.
Module Pattern / DI Container — Every major subsystem has a [Link] that acts as a factory composing internal
dependencies. [Link] only talks to modules, never to internal service implementations directly."
Q52: What is Dependency Injection and why does it matter for testing?
SAY THIS: "Dependency Injection means a component receives its dependencies from outside rather than creating them internally.
Instead of [Link] = [Link](...) inside the service, you pass the db connection: NewService(db *[Link]).
Why it matters for testing: in tests you can pass a mock or stub instead of a real DB connection. For example, to test the
reconciliation merge join logic, you don't need a real database — you inject a fake repository that returns hardcoded transactions.
The merge join logic is tested in complete isolation. Without DI, your service would create a real DB connection in its constructor and
every test would need a real database."
SAY THIS: "Each major subsystem has a [Link] file. The module is a struct that holds all the services and repositories for that
subsystem. It has a NewModule(dependencies...) constructor that creates and wires everything internally. The module exposes
only the public services that other modules need.
For example, [Link](ctx, repo) creates all four caches, wires them with the repository, and returns a CacheModule
struct. [Link] calls [Link]() and gets back the module. Then [Link](db, repo, ingestion,
cache) receives the cache module and uses it. [Link] never directly instantiates individual cache objects — it always goes
through the module.
This pattern makes [Link] the only place with wiring knowledge. Each module is independently testable and replaceable."
Q54: How does the Template Method pattern work in the file parser?
SAY THIS: "The generic parser defines the overall algorithm: loop through rows, for each row call the row mapper, the row mapper
applies column index lookup, validation, transformation, and field name mapping. This sequence is fixed.
What varies is the reading part — CSV files and Excel files have completely different binary formats. We have a Reader interface
with ReadChunk(n) method. CSVReader implements it using Go's encoding/csv. ExcelReader implements it using excelize. The
parser just calls [Link](5000) and gets back [][]string regardless of file format. The template method (the
parsing algorithm) is in the generic parser; the step that varies (how to read the file) is in the concrete reader."
SAY THIS: "Observer pattern: when state changes, all registered observers are notified. Pub-Sub is similar — publishers emit events
to topics, subscribers receive them without knowing who published.
In this project, Kafka implements Pub-Sub. When a file arrives, an event is published to a Kafka topic. Any number of consumers
(subscribers) can react — the ingestion consumer processes the file, an audit consumer logs the event, a monitoring consumer
records metrics. Publishers and subscribers are completely decoupled.
The cache's Ready() channel is a simpler observer — [Link] 'subscribes' by waiting on the channel, the cache 'publishes' by
closing the channel when ready."
Q56: How would you design a REST API for this system?
File operations:
Reconciliation:
Reports:
Admin:
Authentication would use JWT tokens for service-to-service and OAuth for user-facing endpoints."
Unit tests — the merge join algorithm, the rule engine operators (EQUAL, AMOUNT_TOLERANCE, FUZZY), hash computation,
bitfield operations. These are pure functions with no external dependencies — just Go test files with table-driven test cases.
Integration tests — testing the full ingestion pipeline against a real PostgreSQL instance (or a Docker test database). We test: parse a
real CSV file → insert to canonical_txn → verify recon_master is created correctly.
End-to-end scenario tests — upload a BANK file and an INTERNAL file with matching transactions → verify recon_master.status =
MATCH. Upload files with a ₹ 1 difference → verify rule engine marks it as PARTIAL_MATCH with AMOUNT_TOLERANCE.
We used testify/assert for assertions. Table-driven tests in Go let us test 20 scenarios with one function — just a slice of
input/expected pairs."
Q58: How would you write a unit test for the rule engine's AMOUNT_TOLERANCE operator?
SAY THIS: "I'd write a table-driven test like this:
tests := []struct {
name string
src int64
target int64
tolerance string
wantMatch bool
}{
{name: 'exact match', src: 50000, target: 50000, tolerance: 'FIXED:100', wantMatch: true},
{name: 'within fixed', src: 50000, target: 50050, tolerance: 'FIXED:100', wantMatch: true},
{name: 'exceeds fixed', src: 50000, target: 50200, tolerance: 'FIXED:100', wantMatch: false},
{name: 'within percent', src: 50000, target: 50900, tolerance: 'PERCENT:2', wantMatch: true},
{name: 'exceeds percent', src: 50000, target: 52000, tolerance: 'PERCENT:2', wantMatch: false},
{name: 'zero difference', src: 0, target: 0, tolerance: 'FIXED:0', wantMatch: true},
}
for _, tt := range tests {
[Link]([Link], func(t *testing.T) {
result := evaluateAmountTolerance([Link], [Link], [Link])
[Link](t, [Link], [Link])
})
}
Each test case is a struct. The loop runs every case. On failure, you see exactly which case failed."
SAY THIS: "Mocking replaces a real dependency with a fake one that you control. For example, to test FileIngestionService
without a real database, you create a mock Repository interface — one that returns hardcoded data when called and records
which methods were called.
Use mocks when: the real dependency is slow (database, network), has side effects you don't want in tests (writing to S3, sending
Kafka messages), or is non-deterministic (current time, random).
Don't over-mock. In this project, the merge join is a pure function — no mocking needed, just test inputs and outputs directly. Only
mock when the real thing makes tests slow, fragile, or impossible to control."
Q60: How do you test that the retry mechanism works correctly?
SAY THIS: "I'd test it with a controlled failure scenario:
1. Create a test file with 3 batches (15000 rows at 5000 per batch)
2. Mock the repository to succeed on batch 1, fail on batch 2 with a specific error
3. Verify: rows_inserted = 5000 after the failure (first batch committed)
4. Now call the retry method with this raw_file
5. The retry should skip the first 5000 rows and start from row 5001
6. Verify: rows_inserted = 15000 after retry succeeds
7. Verify: the first 5000 rows are NOT duplicated in canonical_txn
The key assertion is no duplication — that's the whole point of the offset-based retry."
SAY THIS (STAR): "Situation: I was assigned to build the SFTP polling worker — just the file detection part. During testing I noticed
the retry worker was re-processing failed files from row 0 every time, even if half the rows had already been inserted successfully.
This wasn't my task, but it was a serious bug — double inserts.
Task: Fix the retry offset issue even though it wasn't my assigned work.
Action: I traced through the ingestion code and found that rows_inserted was being updated only at file completion, not per batch. I
changed it to update after every successful batch commit. Then in the retry worker, I added the offset-based skip using rows_inserted
as the start position.
Result: The retry worker now resumes exactly where it left off. I flagged this to my team lead before making the change, explained
the impact, got approval, then implemented and tested it. Taking ownership of adjacent problems is faster than waiting for someone
else to discover the same issue."
Q62: Tell me about a time you had a disagreement with a teammate. (Earn Trust / Have Backbone)
SAY THIS (STAR): "Situation: A teammate proposed storing amounts as float64 to simplify the code — no need to convert from
rupees to paise everywhere.
Action: I respectfully pushed back with a concrete example: 0.1 + 0.2 in IEEE 754 floating point gives 0.30000000000000004.
In a reconciliation system, two records of ₹ 0.30 stored as separate floats might not be equal when compared. I wrote a small Go
program demonstrating this and shared it in our Slack. I proposed storing amounts as int64 in paise as the industry standard —
Stripe, Razorpay, and NPCI all do this.
Result: The team agreed to use paise. My teammate appreciated having a concrete demonstration rather than just 'floats are bad.'
The codebase now has a comment explaining why paise is used — for future team members."
Q63: Tell me about a time you had to learn something new quickly. (Learn and Be Curious)
SAY THIS (STAR): "Situation: I was assigned to build the SFTP integration, but had never worked with SFTP or the pkg/sftp
library in Go before.
Action: I started by reading the pkg/sftp documentation and the SSH/SFTP RFC to understand the protocol. I set up a local SFTP
server using OpenSSH to test against. I built a minimal prototype first — just connect, list files, read one file. Then added the safety
filter (skip files < 1 minute old), error handling, and retry logic. I pair-programmed with a senior engineer for 30 minutes to review my
approach before building the full implementation.
Result: The SFTP worker was delivered in the sprint. I also documented the SFTP setup process for the team since no one had done
it before — turned my learning into a team resource."
Q64: Tell me about a time you delivered something with incomplete information. (Bias for Action)
SAY THIS (STAR): "Situation: We needed to process NPCI files but NPCI hadn't finalized their file format specification. We had a
draft spec but not the final one.
Task: Build the NPCI parser without a final spec, while still hitting the sprint deadline.
Action: I built the template-based parser system to be data-driven — the column mappings are in the database, not hardcoded. I
implemented the parser against the draft spec and loaded the draft template into the DB. I documented the assumption: 'template ID
3 is based on NPCI draft v0.3, update when final spec arrives.'
Result: The parser was done and working with the draft spec. When NPCI sent the final spec with 3 column changes, we just
updated the template in the DB — zero code changes. The upfront investment in a data-driven template system paid off exactly when
we needed it."
Q65: Tell me about a time you failed. What did you learn? (Learn from Mistakes)
SAY THIS (STAR): "Situation: I implemented the cache refresh without considering what happens during the refresh window.
Between the old cache being cleared and the new cache being populated, there was a ~100ms window where cache reads returned
nil.
Task: Fix production errors where nil cache was causing panics in the reconciliation worker.
Action: First fix was immediate — added nil checks before using cache data. Proper fix was to change the cache architecture:
instead of clear-then-reload, use atomic swap — load the new snapshot in a temporary variable, then atomically replace the
reference. The old snapshot is always valid until the moment of swap. No nil window.
Result: Zero-downtime cache refreshes. The learning: always think about the intermediate states during state transitions, not just the
before and after states."
Q66: Tell me about a time you simplified a complex problem. (Invent and Simplify)
SAY THIS (STAR): "Situation: The initial design for tracking which systems had reported a transaction was a junction table —
transaction_system_presence(recon_key, system_id, arrived_at). Querying 'which systems are present for this
recon_key' required a JOIN.
Task: Make the reconciliation worker faster — it was doing many joins per polling cycle.
Action: I proposed replacing the junction table with a bitfield column on recon_master. Each system gets a fixed bit position —
INTERNAL=bit0, BANK=bit1, NPCI=bit2. Checking if BANK arrived is system_mask & (1<<1) != 0 — one bitwise AND, no join
needed. Setting all three systems present is system_mask = 0b111 — one integer update.
Result: Removed an entire table from the schema. Reconciliation queries got faster. The code is simpler — bitwise operations are
easier to reason about than JOIN conditions for this use case."
Q67: Tell me about a time you worked on a tight deadline. (Deliver Results)
SAY THIS (STAR): "Situation: A regulatory audit required that all reconciliation discrepancies from the last 90 days be traceable —
each mismatch needed a decision log explaining why it was MATCH or NO_MATCH. We had 2 weeks to implement this.
Task: Add a rule evaluation audit trail without breaking existing reconciliation logic.
Action: I prioritized: the core change was adding the rule_evaluations table and writing RuleEvaluation records after rule engine
runs. I did that first. Then I added the query API to retrieve evaluations by recon_key. I skipped the 'nice-to-have' features like
evaluation analytics dashboard — those weren't required for the audit. I shipped in 10 days, spent the last 4 days writing 90-day
backfill scripts for historical data.
Result: Passed the audit. The key was ruthless prioritization — delivered the exact requirement, nothing more."
File processing fails — the ingestion worker gets a DB connection error. Since each batch is wrapped in a transaction, the in-progress
batch rolls back. rows_inserted is not advanced. The worker logs the error and marks the file as FAILED. RetryWorker will pick it
up when DB is back.
Reconciliation stops — the reconciliation worker's next poll will fail to query recon_master. The worker should have retry logic with
exponential backoff — it waits 5s, 10s, 20s between retries without hammering the dead DB.
SFTP polling continues reading files — but can't store them. This is where Kafka helps — instead of directly writing to DB, the SFTP
worker publishes a 'file arrived' event to Kafka. Kafka retains it durably. When DB recovers, the consumer picks up and processes all
queued events.
Critical: cache data is still in memory. Workers can still read config without hitting DB. The system degrades gracefully — no new
processing, but no crash."
SAY THIS: "The SFTP worker polls every 60 seconds. If it can't connect to an SFTP source, it logs the error and tries the next source
in its list. It doesn't crash — one bad source doesn't affect processing of other sources.
For retry: the worker retries the connection on the next polling cycle (60 seconds later). If the SFTP server is down for hours, the files
just sit on the SFTP server. When the connection is restored, they're all picked up in the next poll. Banks typically retain files for 24-48
hours on their SFTP server.
The safety filter (skip files < 1 minute old) still works correctly when the server comes back — files that were there before the outage
are old enough to process immediately."
SAY THIS: "The reconciliation worker doesn't maintain any in-memory state across cycles — it reads from DB, processes, writes to
DB, and that's it. On restart, the new instance reads recon_master and finds records still in PENDING. It picks them up and
processes normally.
The key design principle: no critical state exists only in memory. Everything is checkpointed in PostgreSQL. A crash means at most
one polling cycle (5 seconds) of delay, not data loss.
The one concern is partial batch writes during a reconciliation update — if the service crashes after updating pair_status_mask for 50
records but before updating the remaining 50. On restart, the 50 already-updated records are fine — they'll just be skipped. The 50
not yet updated will be picked up in the next cycle. Idempotency means running the reconciliation check twice produces the same
final result."
Q71: What if two workers try to reconcile the same transaction simultaneously?
SAY THIS: "This is prevented by using SELECT FOR UPDATE or row-level locks when claiming batches from recon_master. The first
worker to execute the SELECT locks those rows. The second worker either waits (blocking) or skips them (skip locked).
We use SKIP LOCKED — the second worker gets a different batch of rows and processes those instead. No waiting, no blocking.
Transactions that are already being reconciled are simply skipped by other workers. This is PostgreSQL's advisory lock pattern for
worker pools.
The result: multiple worker instances can reconcile different transactions truly in parallel, but no two workers ever process the same
transaction."
Q72: What if a file has 10 million rows and the server runs out of memory?
SAY THIS: "This is prevented by design — streaming in 5000-row chunks. The memory usage is bounded by the chunk size, not the
file size. Each 5000-row chunk is processed, committed to DB, and then garbage collected before the next chunk is loaded.
What does 5000 rows cost in memory? Each CanonicalTxn is roughly 200 bytes. 5000 × 200 = 1MB. Plus overhead from parsing,
maybe 3-5x = 3-5MB per batch. A server with 512MB of RAM can easily handle this.
If somehow the server does OOM-kill: the in-progress batch rolls back (DB transaction), rows_inserted stays at the last committed
value, and the RetryWorker picks it up from that offset. The file is processed in the next attempt."
First, goroutines vs. Java threads. We have ~10 concurrent workers (polling at different intervals). In Java, each would be an OS
thread — ~1MB stack each, expensive context switches. In Go, goroutines start at 8KB. Running 10 or 1000 goroutines has virtually
no overhead difference.
Second, deployment. Go compiles to a single statically-linked binary. No JVM, no classpath, no dependency jars. Docker image is 10-
20MB. Java Spring Boot images are typically 200-500MB. For a service that might run on many small VMs, this matters.
Third, built-in tooling. go vet, go race (race condition detector), pprof profiling — all built-in, no external setup. For a financial
service where correctness is critical, the race detector finding potential data races at test time is invaluable."
First, bitwise operations. pair_status_mask & 0x3 for checking pair status — PostgreSQL supports bitwise operators on integer
types natively and efficiently. Both work, but PostgreSQL's support is more mature.
Second, SELECT FOR UPDATE SKIP LOCKED — this is how the worker pool avoids processing the same file twice. MySQL added
SKIP LOCKED only in version 8.0. PostgreSQL has had it longer and with more mature semantics.
General reasons: PostgreSQL has better standards compliance, richer data types (native JSON, arrays, ranges), and MVCC is
implemented more cleanly. For financial data with complex queries and constraints, PostgreSQL is generally the safer choice."
Kafka retains messages on disk for a configurable period (days/weeks). If our consumer is down for 2 hours, all messages are waiting
when it comes back — nothing lost. RabbitMQ removes messages once consumed; if no consumer is running, messages can be lost.
Kafka's consumer groups allow multiple instances of our service to share load AND allows different services to each read the full
stream independently. RabbitMQ distributes messages to consumers in round-robin — once consumed by one, it's gone for others.
For financial file events where durability and replay are critical — if we need to reprocess a file event from 3 days ago — Kafka is the
right choice. RabbitMQ is simpler and faster for task queues where durability is less critical."
First, multi-instance deployment. If you run 3 instances of the service, and a file is stored on instance 1's local disk, instances 2 and 3
can't access it. S3 is a shared, durable store accessible by all instances.
Second, durability. S3 stores data across multiple availability zones — 99.999999999% durability (eleven 9s). Local disk fails with the
server. For financial transaction files that may need to be re-processed or audited months later, S3 is the right store.
Third, the abstraction is already there. We have a Storage interface. Local disk works in development for speed. S3 is swapped in
for production with a config change. The code doesn't change."
1. Encryption in transit — Kafka uses TLS with mutual certificate authentication. External DB connection uses
sslmode=require. SFTP itself encrypts the channel with SSH.
2. PGP file decryption — banks send files PGP-encrypted. We decrypt in memory using ProtonMail's go-crypto library before
parsing. The plaintext is never written to disk.
3. Least privilege DB access — the external DB connection only has SELECT permission. Even if our code had a bug that
tried to DELETE from the external DB, it would be rejected at the DB level.
4. No plaintext sensitive data in logs — we log recon_key (transaction ID) for debugging, but never log amounts or account
numbers. Logs should be safe to share with developers.
5. Audit trail — every rule evaluation decision is stored in rule_evaluations with timestamps. Every file processing step is
tracked in raw_file. This supports forensic investigation if there's ever a dispute."
Q78: What is SQL injection and does your code prevent it?
SAY THIS: "SQL injection is when user-supplied input is concatenated into a SQL query string, letting attackers run arbitrary SQL.
Example of vulnerable code: 'SELECT * FROM users WHERE name = ' + userInput. If userInput is '; DROP TABLE
users; --, the table gets dropped.
We use parameterized queries everywhere through pgx — never string concatenation. For example, [Link]('SELECT * FROM
canonical_txn WHERE recon_key = $1', reconKey). The $1 is a placeholder; pgx sends the query and the parameters
separately. The database treats the parameter as data, never as SQL syntax. This completely prevents SQL injection regardless of
what's in reconKey."
Q79: What is TLS and why is mutual TLS used for Kafka?
SAY THIS: "TLS (Transport Layer Security) encrypts communication between two parties. In standard TLS (like HTTPS), only the
server proves its identity — you verify the server's certificate. You, as client, are anonymous.
Mutual TLS means BOTH parties prove identity. The Kafka broker verifies our client certificate ([Link]), and we verify the
broker's certificate against our CA. This prevents two attacks:
For financial systems, mutual TLS is required by most regulatory frameworks — it proves who sent which message."
Reconciliation health:
System health:
Kafka lag:
Consumer group lag — how many messages are unprocessed. If lag is growing, our consumers are slower than producers.
Tools: Prometheus for metrics collection, Grafana for dashboards, PagerDuty for alerts on critical conditions."
Structured JSON logging means every log line is parseable — tools like Elasticsearch can query 'show me all files that failed with this
specific error' without regex parsing."
Q82: How would you debug a situation where reconciliation is stuck in PENDING for hours?
SAY THIS: "Step-by-step diagnosis:
1. Check which systems are present: SELECT recon_key, system_mask, pair_status_mask, status FROM
recon_master WHERE status = 'PENDING' AND updated_at < now() - interval '1 hour'
2. If system_mask shows only BANK (no INTERNAL): InternalSyncWorker isn't pulling this transaction. Check
internal_pull_state table — is the cursor advancing? Check InternalSyncWorker logs for errors.
3. If both systems present but still PENDING: reconciliation worker isn't processing it. Check reconciliation worker logs. Check if
the worker is alive. Check DB connections.
4. If pair_status_mask shows OPEN for a pair even with both systems: the merge join isn't matching. Maybe recon_key formats
differ between systems — BANK sends UPI123 but INTERNAL stores upi123 (case sensitivity). Check canonical_txn for
both systems with that recon_key.
5. Nuclear option: add temporary debug logging to the reconciliation worker to print what keys it's processing and the result of
each comparison."
Atomicity — either all operations in a transaction succeed, or none do. We rely on this heavily: inserting 5000 canonical transactions
+ updating rows_inserted + upserting recon_master records all happen in one transaction. If any step fails, all roll back.
Consistency — the DB moves from one valid state to another. Our constraints enforce this: recon_key must have a valid system_id,
foreign keys between tables are enforced.
Isolation — concurrent transactions don't see each other's uncommitted changes. When two workers run simultaneously, each sees
a consistent snapshot of the DB. One worker updating pair_status_mask doesn't affect what another worker reads until committed.
Durability — once committed, data survives crashes. PostgreSQL writes to WAL (Write-Ahead Log) before confirming commit. Even
if the server crashes a millisecond after commit, the data is safe.
We rely on Atomicity and Durability most — they're the foundation of the checkpoint-and-retry pattern."
Concurrency doesn't require parallelism: a single-core CPU can be concurrent (switching between tasks), but not parallel.
In this project: our Go service is concurrent — it has multiple goroutines for reconciliation worker, SFTP worker, retry worker, cache
refresh. On a single-core machine, they'd time-slice. On a multi-core machine (typical production server), Go's runtime schedules
goroutines across all cores — true parallelism.
The DB batch insert is serial within a batch (one query for 5000 rows), but different files can be processed in parallel by different
service instances."
SAY THIS: "A deadlock is when two processes each hold a resource the other needs, and both wait forever. Example: Process A
holds Lock 1 and waits for Lock 2. Process B holds Lock 2 and waits for Lock 1. Neither can proceed.
In a database: Transaction A updates recon_master row X then tries to lock canonical_txn row Y. Transaction B updates
canonical_txn row Y then tries to lock recon_master row X. Deadlock.
Prevention in this system: we always acquire locks in a consistent order — canonical_txn is always updated before recon_master
within a transaction, never the reverse. PostgreSQL also has deadlock detection — if it detects a deadlock, it picks one transaction,
rolls it back, and lets the other proceed. Our code handles this by catching the deadlock error and retrying the transaction."
SAY THIS: "Creating a new database connection is expensive — it involves TCP handshake, authentication, SSL negotiation —
typically 20-50ms. If every DB query opened and closed a new connection, processing 5000 rows would cost 5000 × 50ms = 250
seconds just in connection overhead.
Connection pooling maintains a set of pre-opened connections. When your code needs to query the DB, it borrows a connection from
the pool, uses it, and returns it. Borrowing takes microseconds, not milliseconds.
pgx's pool manages min/max pool size, idle timeout, health checks. When our service starts, pgx opens a minimum number of
connections. Under load, it opens more (up to max). Connections idle for too long are closed. If all connections are in use, new
requests wait for one to free up — this prevents overwhelming the DB with too many simultaneous connections."
Q87: What is the difference between a process and a thread? Where do goroutines fit?
SAY THIS: "A process is an isolated program with its own memory space. Processes communicate via IPC (pipes, sockets, shared
memory). Creating a process is expensive — hundreds of milliseconds.
A thread is a unit of execution within a process — multiple threads share the same memory space. Threads communicate by
reading/writing shared variables (which is why you need mutexes). Creating a thread costs ~1MB of stack and OS overhead.
A goroutine is Go's userspace thread — managed by Go's runtime, not the OS. Goroutines start with 8KB stack (grows dynamically).
Creating a goroutine costs microseconds. The Go runtime multiplexes many goroutines onto fewer OS threads using M:N scheduling.
In this project: everything runs in one process. Multiple goroutines handle different workers. They share memory (the cache's
[Link] is accessed by all goroutines) but coordinate safely through atomic operations and channels."
Q88: What is an HTTP status code? What would the API return in different scenarios?
SAY THIS: "HTTP status codes indicate the result of a request. Key ones for this system's API:
For async operations like report generation: return 202 Accepted immediately with a job_id. The client polls GET
/reports/{job_id} which returns 200 with status='processing' until done, then 200 with status='completed' and download URL."
SAY THIS: "Good question. SHA256 has a collision resistance of 2^128 — practically impossible to find two different inputs with the
same hash. But the recon_key collision is a more realistic concern — what if two different transactions have the same UPI reference
number?
In theory, UPI reference numbers (RRN) are unique per transaction. But bugs happen. If two different transactions share a recon_key,
our system would compare their hashes. If the amounts also coincidentally match, we'd falsely call it a MATCH.
Mitigation: validate recon_key uniqueness within a system during ingestion. If the same recon_key appears twice in a BANK file, flag
it as a data quality issue rather than silently deduplicating. The is_latest flag handles intentional corrections, not accidental
duplicates."
Q90: What if the rule engine has a bug and marks a genuine mismatch as MATCH?
First, rules are in the DB with an active flag — a rule can be disabled without code deployment. If a bug is found, disable the rule
immediately.
Second, rule_evaluations table stores the full evaluation — src field values, target field values, each condition's result. A
compliance team can query: 'show me all transactions where rule X was applied and the decision was MATCH, with the actual field
values.' This is the audit trail.
Third, there should be a manual review queue for any transaction where rules were applied (rule_applied = true). A human
periodically spot-checks these to catch systematic bugs.
Fourth, the system should have canary rules — rules with known correct answers on test data — that run in parallel to detect rule
engine regressions."
SAY THIS: "Timezone differences are a common source of TIME_RANGE false mismatches. A transaction that happens at 11:30 PM
IST might be recorded as 6:00 PM UTC by one system and 11:30 PM IST by another — they're the same moment in time but look 5.5
hours different.
The fix has two parts: First, all timestamps are normalized to UTC during canonicalization. The template column transformer includes
timezone conversion — if a bank sends IST timestamps, the transformer converts to UTC before storing in canonical_txn.
Second, the TIME_RANGE operator compares timestamps after normalization — both are UTC, so the difference is the actual wall-
clock difference between when the systems recorded the event.
The tolerance in TIME_RANGE (e.g., 60 seconds) accounts for legitimate clock drift between systems, not timezone differences —
those are eliminated at ingestion time."
SAY THIS: "Idempotency means running the same operation multiple times produces the same result as running it once. A function
f(x) is idempotent if f(f(x)) = f(x).
It's critical here because failures happen, and recovery means re-running operations. If re-running causes duplicate data, you've
made things worse.
Our ingestion is idempotent via the is_latest flag — re-ingesting the same file marks old records as not-latest and inserts fresh
copies. The reconciliation worker is idempotent — running it on already-MATCH records finds them MATCH again and updates the
same values. The retry worker is idempotent — resuming from rows_inserted offset means rows already inserted won't be re-
inserted.
Without idempotency: a Kafka consumer that processes a message and crashes before committing the offset would receive the
message again on restart. If the handler isn't idempotent, you get double records."
PART 5 — THINGS TO NEVER SAY IN
INTERVIEWS
Don't Say Say Instead
"I just used what was already set
"I chose/contributed to this decision because..."
up"
"I don't know how that part works" "That part was handled by X, but what I understand is..."
"It was easy" "The straightforward part was X, the challenging part was Y"
"I copy-pasted from StackOverflow" "I referenced documentation and adapted it for our use case"
"It's basically like [simple analogy]
Use the analogy, then say "specifically in our case..."
but more complex"
"Testing that specific edge case is a good point — we covered
"We didn't test that"
X,Y,Z. For that scenario, we relied on Z"
Good luck. You built this system — you know it better than any interviewer ever will.