Subversion Repositories SmartDukaan

Rev

Blame | Last modification | View Log | RSS feed

package com.spice.profitmandi.common.util;

import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.attribute.PosixFilePermission;
import java.util.EnumSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

/**
 * A machine-wide mutex around "a ChromeDriver tree is running right now".
 *
 * <p>Every headless chrome we start costs roughly 850MB RSS across its process tree. The
 * app box holds a -Xmx8g tomcat and a -Xmx2g cron jar on 16GB and has been kernel-OOM-killed
 * twice, tomcat the victim both times, so the number that matters is PEAK CONCURRENT DRIVERS,
 * not drivers per day.
 *
 * <p>Measured 2026-09-17 on the oppo/realme lane: a 25-imei chunk takes 505s (12s of driver
 * startup, then ~19.7s an imei) and the lane idles for the 20s fixedDelay before the next
 * chunk. That is a <b>96% duty cycle</b> -- a driver is alive almost always. Meanwhile tomcat
 * starts its own driver 8 times a day for the knowlarity scrape. On 2026-09-17 that scrape ran
 * 11:40:00-11:41:07 and the lane's next driver came up at 11:42:21: it cleared by 74 seconds,
 * by luck. At a 96% duty cycle you cannot dodge the collision by choosing better times, so the
 * only fix is to serialise.
 *
 * <p>The two JVMs are separate processes owned by different users (cron as root, tomcat as
 * tomcat), so an in-process flag like KnowlarityInsightsService's {@code isScraping} cannot see
 * across the boundary. This uses an OS file lock, which the kernel releases when the holder
 * dies -- a jar that gets OOM-killed mid-chunk does not strand the lane, which a marker file
 * would.
 *
 * <p>Callers WAIT rather than skip. Waiting is cheap and skipping is not: the knowlarity scrape
 * needs 67s against a lane that is busy 96% of the time, so a skip-on-contention policy would
 * mean it essentially never refreshed again. Eight scrapes a day costs the imei lane about nine
 * minutes of waiting in total, which is inside the noise of a lane that already loses whole
 * chunks to captcha refusals.
 *
 * <pre>
 * try (BrowserLane lane = BrowserLane.acquire("oppo", 5, TimeUnit.MINUTES)) {
 *     if (lane == null) {
 *         return;              // lane stayed busy; caller leaves its work pending
 *     }
 *     driver = new ChromeDriver(options);
 *     ...
 * } finally {
 *     driver.quit();
 * }
 * </pre>
 *
 * <p>⚠ Acquire BEFORE {@code new ChromeDriver(...)} and hold until after {@code quit()} has
 * returned. Holding only around the constructor protects nothing -- the 850MB is resident for
 * the whole chunk, not just at startup.
 */
public final class BrowserLane implements Closeable {

        private static final Logger LOGGER = LogManager.getLogger(BrowserLane.class);

        /**
         * Overridable so a dev box (macOS has no /var/lock) or a test can point somewhere writable.
         */
        private static final String LOCK_PATH = System.getProperty("sd.browser.lock", "/var/lock/sd-browser.lock");

        /**
         * The file lock is per-JVM, not per-thread: a second thread in the SAME jvm asking for a
         * lock this jvm already holds gets an OverlappingFileLockException rather than blocking.
         * This gate makes threads queue first so only one of them ever reaches the file lock.
         * Fair, so a waiter cannot be starved by the lane's own 20s-cadence ticks.
         */
        private static final ReentrantLock IN_JVM = new ReentrantLock(true);

        private static final long POLL_MILLIS = 2000L;

        private final String owner;
        private final RandomAccessFile handle;
        private final FileLock fileLock;
        private boolean released;

        private BrowserLane(String owner, RandomAccessFile handle, FileLock fileLock) {
                this.owner = owner;
                this.handle = handle;
                this.fileLock = fileLock;
        }

        /**
         * Waits for the browser lane, up to the given timeout.
         *
         * @param owner what to name in the logs -- the brand or job asking
         * @return the held lane, or {@code null} if it stayed busy for the whole timeout, in which
         *         case the caller must NOT start a driver
         */
        public static BrowserLane acquire(String owner, long timeout, TimeUnit unit) {
                long deadline = System.currentTimeMillis() + unit.toMillis(timeout);

                boolean gated;
                try {
                        gated = IN_JVM.tryLock(unit.toMillis(timeout), TimeUnit.MILLISECONDS);
                } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        LOGGER.warn("[{}] interrupted waiting for the in-jvm browser gate", owner);
                        return null;
                }
                if (!gated) {
                        LOGGER.warn("[{}] browser lane still held by this jvm after {} {} - not starting a driver",
                                        owner, timeout, unit);
                        return null;
                }

                RandomAccessFile handle = null;
                try {
                        handle = open();
                } catch (Exception e) {
                        // A missing /var/lock, a read-only mount, a dev laptop. Serialising within this jvm
                        // is still better than nothing, and refusing to run would stop imei activation over
                        // what is only a safety interlock.
                        LOGGER.warn("[{}] browser lane file {} unusable ({}) - proceeding with in-jvm serialisation only",
                                        owner, LOCK_PATH, e.getMessage());
                        return new BrowserLane(owner, null, null);
                }

                long waitedFrom = System.currentTimeMillis();
                while (true) {
                        FileLock lock = null;
                        try {
                                lock = handle.getChannel().tryLock();
                        } catch (OverlappingFileLockException e) {
                                // Another thread in this jvm holds it. IN_JVM should have prevented this, so it
                                // means someone acquired the channel outside this class. Treat as contended.
                                lock = null;
                        } catch (IOException e) {
                                LOGGER.warn("[{}] browser lane lock failed ({}) - proceeding with in-jvm serialisation only",
                                                owner, e.getMessage());
                                closeQuietly(handle);
                                return new BrowserLane(owner, null, null);
                        }

                        if (lock != null) {
                                long waited = System.currentTimeMillis() - waitedFrom;
                                if (waited > POLL_MILLIS) {
                                        LOGGER.info("[{}] browser lane acquired after waiting {}s", owner, waited / 1000);
                                }
                                return new BrowserLane(owner, handle, lock);
                        }

                        if (System.currentTimeMillis() >= deadline) {
                                LOGGER.warn("[{}] browser lane held by another process for the whole {} {} wait"
                                                + " - not starting a driver", owner, timeout, unit);
                                closeQuietly(handle);
                                IN_JVM.unlock();
                                return null;
                        }

                        try {
                                Thread.sleep(POLL_MILLIS);
                        } catch (InterruptedException e) {
                                Thread.currentThread().interrupt();
                                closeQuietly(handle);
                                IN_JVM.unlock();
                                return null;
                        }
                }
        }

        /**
         * Opens the lock file, creating it world-writable on first use.
         *
         * <p>The permissions are the whole point: cron runs as root and tomcat as tomcat, and
         * whichever starts first creates the file. A root-created 0644 file would leave tomcat
         * unable to take a write lock, which fails open and silently gives up the interlock.
         */
        private static RandomAccessFile open() throws IOException {
                File file = new File(LOCK_PATH);
                boolean fresh = !file.exists();
                RandomAccessFile handle = new RandomAccessFile(file, "rw");
                if (fresh) {
                        try {
                                Set<PosixFilePermission> perms = EnumSet.of(
                                                PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE,
                                                PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE,
                                                PosixFilePermission.OTHERS_READ, PosixFilePermission.OTHERS_WRITE);
                                Files.setPosixFilePermissions(Paths.get(LOCK_PATH), perms);
                        } catch (Exception e) {
                                LOGGER.warn("Could not widen permissions on {} - the other jvm may not be able to"
                                                + " take this lock: {}", LOCK_PATH, e.getMessage());
                        }
                }
                return handle;
        }

        /** Releases the lane. Idempotent, so it is safe in a finally that also runs on the happy path. */
        @Override
        public void close() {
                if (released) {
                        return;
                }
                released = true;
                if (fileLock != null) {
                        try {
                                fileLock.release();
                        } catch (Exception e) {
                                LOGGER.warn("[{}] releasing the browser lane lock failed: {}", owner, e.getMessage());
                        }
                }
                closeQuietly(handle);
                IN_JVM.unlock();
        }

        private static void closeQuietly(RandomAccessFile handle) {
                if (handle != null) {
                        try {
                                handle.close();
                        } catch (Exception ignored) {
                        }
                }
        }
}