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. */@Overridepublic 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) {}}}}