Company questionsGroundworkMeesho

Machine coding · Booking and reservations

Inventory service with stock blocking

  • Reported once
  • SDE-3
  • State
  • Reservation
  • Idempotency key
  • 1 source

One candidate write-up. Treat it as a single data point.

The prompt
Design an inventory service that can add a product, add stock, block stock for an order and handle the outcome of the payment. Blocked stock cannot be sold to anyone else. When the payment succeeds the blocked stock is consumed, and when it fails the stock goes back to being available.

Restated in our own words, not a quote from Meesho or any page. The answer below is written for this site.

Try it first.The answer is covered until you pick. Talk it through against a 60 minute clock, or just read it.

Clarify first

Pin the scope down before you name a single class.

Questions to ask first: can one order block several products at once and must that be all or nothing, how long may stock stay blocked if the payment never answers, may the same order id call block twice because of a retry, what happens if a payment succeeds after the hold has run out, are quantities whole units, and is there one warehouse or several.

The report lists adding a product, adding inventory, blocking inventory and a payment outcome that either uses or frees the block; the hold timeout, refunds and multi-line orders are assumptions. Assumed scope: one warehouse, each product with an on-hand count and a blocked count, and available stock defined as on-hand minus blocked. Blocking several lines is atomic. A hold lasts a fixed time and unpaid holds are released by a sweep. Blocking the same order twice does not block twice. A payment that succeeds after the hold expired is refunded. Out of scope: several warehouses, prices, shipping, reordering and persistence.

Entities and relationships

Who owns what, and who talks to whom.

  • Product has an id, a name, an on-hand count and a blocked count. Available stock is on-hand minus blocked.
  • Reservation belongs to one order. It holds the lines (product and quantity), a status of BLOCKED, CONFIRMED, RELEASED or EXPIRED, and an expiry time.
  • Inventory is the service with addProduct, addStock, block, confirm, release and sweep. It owns the products and the reservations.
  • OrderService runs the payment flow. It blocks stock, charges, then confirms or releases, and issues a refund when confirmation fails.
  • PaymentGateway is the outside collaborator with charge and refund. The fake in the code returns scripted outcomes and can advance the clock to model a slow payment.
  • Clock is injected, so expiry is deterministic in tests.

The design

The class map, the main flow and the patterns that earn their place.

Inventory class mapThe order service blocks stock first, then charges, then confirms or releases through the same service.
OrderServiceplaceInventoryblock, confirm, releaseProductonHand, blockedReservationstatus, expiresAtClocknow() for expiryPaymentGatewaycharge, refundblock stockblocked upcreateschargeexpiry time12345
  1. The order service asks the inventory to block every line of the order, all or nothing.
  2. Each product's blocked count goes up, so its available stock goes down.
  3. A reservation records the lines, the BLOCKED status and the expiry time.
  4. The order service charges the gateway. Success calls confirm, and failure calls release.
  5. Expiry uses the injected clock, and a sweep releases holds that outlive their time.

Open on the whiteboardAll 3 diagrams on one tidy board, with a step-by-step walkthrough.

Two counters. Each product keeps on-hand stock (physically in the warehouse) and blocked stock (promised to orders that have not paid yet). Available is the difference. Blocking raises only the blocked counter, so stock cannot be sold twice and nothing is lost if the buyer walks away. Confirming lowers both counters because the goods leave. Releasing lowers only the blocked counter.

Atomic block. block first sweeps expired holds, then rejects an empty order and any line whose quantity is not a positive whole number, checked per line so a negative line cannot cancel another. It merges duplicate lines by product in sorted id order, makes one pass that checks every line against available stock and only if all pass a second pass that adds to the blocked counters. A failing second line therefore leaves the first one untouched.

Reservation states. A reservation starts BLOCKED, and that is the only state that can change. Confirm and release both go through one settle method, which first sweeps expired holds, then requires BLOCKED, applies the counter changes and sets the final status. A second confirm, or a release after a confirm, returns an error such as already CONFIRMED. That guard makes retries and races between a late payment callback and the sweep harmless. A full State pattern with a class per state would be heavier than four statuses and two transitions need, so a status field with a guard is the chosen trade-off.

Payment flow. OrderService.place blocks first and returns REJECTED with the reason, without touching the gateway, if the block fails for example because stock is short. A repeat of an order id that is still BLOCKED returns IN_PROGRESS and one with a final status returns DUPLICATE, and neither charges again. It then charges. If the charge fails it releases the stock at once. If it succeeds it confirms. When confirmation fails because the hold expired during a slow payment, it refunds and reports REFUNDED. The order matters: never charge before stock is held, and never consume stock before the charge result is known. If the gateway call throws, the outcome is unknown, so the hold is left to expire instead of being released at once, and a reconciliation job asks the gateway what happened.

Idempotency. Calling block again with the same order id while it is BLOCKED returns the same reservation and blocks nothing more, while the same id with different lines is refused. Expired holds are swept at the start of every block, so a stale BLOCKED status is never trusted. Once the reservation has a final status, the id cannot be reused.

Concurrency. This version is single threaded, so the check and the update cannot interleave. In a database each product update is conditional, for example setting blocked to blocked plus the quantity only where on-hand minus blocked is at least the quantity, and the affected row count says whether it worked. Several products are updated in one transaction in product id order to avoid deadlocks, and a zero count rolls everything back. The sweep is a periodic job that moves BLOCKED rows past their expiry using the same guarded update, so it can race with a confirm safely: whichever changes the row from BLOCKED first wins.

The code

Runnable JavaScript, with the checks it passes.

meesho-inventory.js
class Clock {
  constructor() {
    this.t = 0;
  }
  now() {
    return this.t;
  }
  advance(by) {
    this.t += by;
  }
}

class Inventory {
  constructor(clock, holdSeconds) {
    this.clock = clock;
    this.holdSeconds = holdSeconds;
    this.products = new Map();
    this.reservations = new Map();
  }
  addProduct(id, name) {
    if (this.products.has(id)) throw new Error("product exists " + id);
    this.products.set(id, { id, name, onHand: 0, blocked: 0 });
  }
  addStock(id, qty) {
    if (!Number.isInteger(qty) || qty <= 0) throw new Error("quantity must be positive");
    this.product(id).onHand += qty;
  }
  product(id) {
    const product = this.products.get(id);
    if (!product) throw new Error("unknown product " + id);
    return product;
  }
  available(id) {
    const product = this.product(id);
    return product.onHand - product.blocked;
  }
  block(orderId, lines) {
    this.sweep();
    const wanted = new Map();
    for (const line of lines) {
      if (!Number.isInteger(line.qty) || line.qty <= 0) return { ok: false, reason: "bad quantity for " + line.id };
      wanted.set(line.id, (wanted.get(line.id) || 0) + line.qty);
    }
    if (wanted.size === 0) return { ok: false, reason: "no lines" };
    const sorted = [...wanted].sort((a, b) => (a[0] < b[0] ? -1 : 1));
    const existing = this.reservations.get(orderId);
    if (existing && existing.status !== "BLOCKED") {
      return { ok: false, duplicate: true, reason: "order already " + existing.status };
    }
    if (existing) {
      if (JSON.stringify(existing.lines) !== JSON.stringify(sorted)) {
        return { ok: false, reason: "order " + orderId + " is blocked with different lines" };
      }
      return { ok: true, repeat: true, reservation: existing };
    }
    for (const [id, qty] of sorted) {
      if (!this.products.has(id)) return { ok: false, reason: "unknown product " + id };
      if (this.available(id) < qty) return { ok: false, reason: "not enough stock for " + id };
    }
    for (const [id, qty] of sorted) this.product(id).blocked += qty;
    const reservation = {
      orderId,
      lines: sorted,
      status: "BLOCKED",
      expiresAt: this.clock.now() + this.holdSeconds,
    };
    this.reservations.set(orderId, reservation);
    return { ok: true, reservation };
  }
  settle(orderId, next) {
    this.sweep();
    const reservation = this.reservations.get(orderId);
    if (!reservation) return { ok: false, reason: "no reservation" };
    if (reservation.status !== "BLOCKED") return { ok: false, reason: "already " + reservation.status };
    for (const [id, qty] of reservation.lines) {
      const product = this.product(id);
      product.blocked -= qty;
      if (next === "CONFIRMED") product.onHand -= qty;
    }
    reservation.status = next;
    return { ok: true };
  }
  confirm(orderId) {
    return this.settle(orderId, "CONFIRMED");
  }
  release(orderId) {
    return this.settle(orderId, "RELEASED");
  }
  sweep() {
    for (const reservation of this.reservations.values()) {
      if (reservation.status === "BLOCKED" && reservation.expiresAt <= this.clock.now()) {
        reservation.lines.forEach(([id, qty]) => (this.product(id).blocked -= qty));
        reservation.status = "EXPIRED";
      }
    }
  }
}

class FakeGateway {
  constructor(clock, outcomes) {
    this.clock = clock;
    this.outcomes = outcomes;
    this.refunds = [];
  }
  charge() {
    const outcome = this.outcomes.shift() || { ok: true, delay: 0 };
    this.clock.advance(outcome.delay);
    return { ok: outcome.ok };
  }
  refund(orderId) {
    this.refunds.push(orderId);
  }
}

class OrderService {
  constructor(inventory, gateway) {
    this.inventory = inventory;
    this.gateway = gateway;
  }
  place(orderId, lines) {
    const held = this.inventory.block(orderId, lines);
    if (!held.ok) return { status: held.duplicate ? "DUPLICATE" : "REJECTED", reason: held.reason };
    if (held.repeat) return { status: "IN_PROGRESS" };
    if (!this.gateway.charge(orderId).ok) {
      this.inventory.release(orderId);
      return { status: "PAYMENT_FAILED" };
    }
    const done = this.inventory.confirm(orderId);
    if (!done.ok) {
      this.gateway.refund(orderId);
      return { status: "REFUNDED", reason: done.reason };
    }
    return { status: "CONFIRMED" };
  }
}

Runs here in your browser, in a sandboxed worker. Nothing is sent anywhere.

Try your own input

Write JavaScript that calls the code above and prints with console.log. It runs after the solution.

Extend it

The change requests an interviewer throws next.

  1. How would you extend blocking to several warehouses and decide which one ships each order?
  2. How do you make the sweep safe when many workers run it at the same time?
  3. What changes if a shopper can extend a hold while sitting on the payment page?
  4. How would you show available stock on product pages without reading the hot row on every view?
  5. How would you handle a payment callback that arrives twice, or after the sweep has already released the stock?

Where candidates slip

The mistakes this problem punishes.

Reducing on-hand stock at block time and then trying to add it back on failure. Applying each line as it is checked, so a short second line leaves the first one blocked. Having no expiry, so abandoned checkouts hide stock forever. Confirming silently after the hold expired. Letting a second confirm consume stock twice. Calling the gateway before the stock is held. Releasing the hold the moment a gateway call times out, when the money may still have been taken. Reading available stock and writing it back in two separate steps.

Have a go before you read.

Interviews reward the sketch you make cold. Clarify out loud, draw it, then compare with the answer.

Now make it stick

Where this was reported

Public pages only. Companies do not confirm these, and they rotate their questions, so treat each link as one person's account or one list's tag.

Read these chapters first