Subversion Repositories SmartDukaan

Rev

Rev 37472 | Blame | Compare with Previous | Last modification | View Log | RSS feed

package com.smartdukaan.cron.scheduled;

import com.spice.profitmandi.dao.model.ImeiActivationTimestampModel;
import com.spice.profitmandi.dao.repository.fofo.ActivatedImeiRepository;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import com.smartdukaan.cron.monitored.ImeiActivationGauges;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@Component
public class StandAlone {

        @Autowired
        private OppoImeiActivationService oppoImeiActivationService;

        @Autowired
        private RealmeImeiActivationService realmeImeiActivationService;

        @Autowired
        private MotorolaImeiActivationService motorolaImeiActivationService;

        @Autowired
        private ActivatedImeiRepository activatedImeiRepository;

        @Autowired
        private ImeiActivationGauges gauges;

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

        /**
         * ZERO, deliberately, and it is not an off-by-one.
         *
         * The pool query keeps rows with createTimestamp < now().atStartOfDay().minusDays(DAYS).
         * At DAYS=1 an imei answered today is measured against YESTERDAY midnight, so it is
         * not due tomorrow either -- it comes back the day after, a two-day cadence. DAYS=0
         * measures against this morning's midnight, which is what "a full run each day"
         * actually means. Oppo and realme ran at 1 before this change.
         *
         * Within a pass the snapshot already guarantees one ask per imei, so this constant
         * only governs the gap BETWEEN passes.
         */
        private static final int DAYS = 0;

        /** Imeis per browser session. Recycles the driver; does NOT re-query the pool. */
        private static final int CHUNK = 25;

        /** Safety stop so a mis-set DAYS cannot pull an unbounded list into memory. */
        private static final int POOL_CAP = 10000;

        /**
         * Oppo and Realme share ONE thread and one work queue.
         *
         * Replaces the separate oppo() and realme() jobs. Nothing else in this class runs a
         * browser, so with a single caller there is exactly one ChromeDriver alive at any
         * moment -- down from four. Peak concurrent drivers is what triggers the OOM killer
         * on this box, not average driver-seconds.
         *
         * ONE FULL PASS PER DAY, and no imei is asked twice inside a pass. Both pools are
         * snapshotted before any browser starts and the pass walks those lists to the end;
         * it never re-queries. That is the whole fix for the re-ask loop:
         *
         *   A failed lookup writes no row (dateMap.put is only reached on the success path),
         *   so createTimestamp is not bumped, so the imei was still eligible on the next tick
         *   five minutes later. Measured 29-Aug: realme issued 4,524 requests against just
         *   1,004 distinct imeis -- 4.5 asks each, 78% of the day's budget spent re-asking --
         *   while oppo, which rarely fails, sat at 1.03. More requests hardened the block,
         *   which caused more failures, which caused more requests.
         *
         * A snapshotted pass bounds that structurally: a failure costs one retry TOMORROW,
         * never one in five minutes, no matter how the far end misbehaves.
         *
         * Sizing, measured on prod 2026-08-31 for a pass starting at midnight: oppo 4,133 and
         * realme 2,118 imeis, which at 10.2s and 14.2s each is 11.7h + 8.4h = 20.1 hours of a
         * single thread -- an 84% duty cycle. It fits, but there is NO slack. If per-imei
         * seconds regress the pass will not finish and the tail rolls into the next day.
         * Watch the "Daily pass finished" line: counts short of the "starting" line are the
         * signal, and the lever is DAYS=1 (halves the work) rather than a second thread.
         *
         * Excluding stock billed today or yesterday is correctness, not capacity -- measured,
         * it trims 30 imeis from oppo and 9 from realme. A handset billed in the last 48
         * hours has essentially never been activated yet, and the cohort data agrees: the
         * sold-within-180-days cohort returns a date on 2.4% (vivo) to 8.4% (oppo) of
         * lookups, against 43-97% for stock sold over a year ago.
         *
         * ⚠ Realme's ceiling is a REQUEST-VOLUME ceiling, not a CPU one. realme.com stops
         * serving the captcha widget as the day's request count climbs: measured 920/day ->
         * 0.3% canvas timeouts, 3,467/day -> 28%, 4,524/day -> 75%, resetting at midnight --
         * while oppo on the same box, same driver count, same widget vendor, at 4,350/day had
         * ZERO timeouts across all 24 hours, on a box loaded at 0.9 of 6 cores. Realme's own
         * canvas wait is 15s against oppo's 8s, so the longer wait is the one expiring. A
         * daily pass puts realme near 2,127 requests/day, between the 920/day point where it
         * was healthy and the 3,467/day point where it was 28% blocked -- so expect SOME
         * blocking and judge the change on dates written, not on timeout count. What the
         * pass guarantees is that blocking can no longer feed itself.
         *
         * Oppo and realme alternate chunk by chunk, so whichever pool still has work keeps
         * the thread busy once the other is exhausted.
         */
        public void checkBrowserImeiActivation() {
                List<String> oppo = pendingFor("Oppo");
                List<String> realme = pendingFor("Realme");
                gauges.beginPass("Oppo", oppo.size());
                gauges.beginPass("Realme", realme.size());

                try {
                        if (oppo.isEmpty() && realme.isEmpty()) {
                                LOGGER.info("Oppo and Realme: nothing due today, not starting a browser");
                                return;
                        }

                        int oppoDone = 0;
                        int realmeDone = 0;
                        while (oppoDone < oppo.size() || realmeDone < realme.size()) {
                                oppoDone += runChunk("Oppo", oppo, oppoDone, oppoImeiActivationService::updateActivationDate);
                                realmeDone += runChunk("Realme", realme, realmeDone, realmeImeiActivationService::updateActivationDate);
                        }
                } finally {
                        // In a finally so a pass killed part-way still publishes what it managed.
                        // A truncated pass is exactly the case worth alerting on, so it must not be
                        // the case that silently reports nothing.
                        gauges.endPass("Oppo");
                        gauges.endPass("Realme");
                }
        }

        /**
         * One driver session's worth of one brand, taken from a list that was snapshotted
         * before the pass began. Returns how many were consumed so the caller can advance.
         *
         * Chunking exists only to recycle the browser -- a driver held open for the whole
         * pass would leak memory on a box that has been OOM-killed twice, and a crash would
         * cost the entire pool rather than CHUNK imeis. It deliberately does NOT re-query:
         * re-querying between chunks is what produced the 4.5 lookups per imei per day that
         * this rewrite removes.
         */
        private int runChunk(String brand, List<String> pool, int from, ImeiBatch batch) {
                if (from >= pool.size()) {
                        return 0;
                }
                List<String> chunk = pool.subList(from, Math.min(from + CHUNK, pool.size()));
                runBrand(brand, chunk, batch);
                gauges.churned(brand, chunk.size());
                return chunk.size();
        }

        /**
         * One brand's turn. Wrapped so a failure in the first brand still lets the second
         * one run -- these are separate sites and separate driver sessions, and a realme
         * outage must not cost oppo its whole tick.
         */
        private void runBrand(String brand, List<String> imeis, ImeiBatch batch) {
                if (imeis.isEmpty()) {
                        return;
                }
                LOGGER.info("{} imeis {}", brand, imeis);
                try {
                        batch.run(imeis);
                } catch (Exception e) {
                        gauges.error(brand);
                        LOGGER.error("{} activation batch failed, continuing with the next brand", brand, e);
                }
        }

        @FunctionalInterface
        private interface ImeiBatch {
                void run(List<String> imeis) throws Exception;
        }

        /**
         * Everything due for one brand today, as a single list.
         *
         * The secondary/tertiary split is an artefact of there being two join paths to a
         * serial (transaction.lineitem vs fofo.fofo_line_item), not two kinds of work: both
         * funnel into the same updateActivationDate -> checkWarranty -> saveActivation path.
         * They were separate jobs with separate batch sizes, which is what made the split
         * visible at all. A pass walks the whole list to the end, so nothing can be starved
         * by ordering and a plain concatenation is enough.
         *
         * The pools are disjoint by construction -- the secondary query excludes anything
         * with a FofoLineItem -- so distinct() is cheap insurance, not a fix for a known
         * overlap. The brand-generic call is used for every brand; the realme-named copy of
         * it has been deleted, it was a verbatim duplicate down to the named query.
         */
        private List<String> pendingFor(String brand) {
                List<String> pool = new ArrayList<>();
                pool.addAll(activatedImeiRepository.selectImeiActivationPendingByBrand(brand, DAYS, POOL_CAP)
                                .stream().map(ImeiActivationTimestampModel::getSerialNumber).collect(Collectors.toList()));
                pool.addAll(activatedImeiRepository.selectImeiActivationPendingByBrandTertiary(brand, DAYS, POOL_CAP)
                                .stream().map(ImeiActivationTimestampModel::getSerialNumber).collect(Collectors.toList()));
                if (pool.size() >= POOL_CAP) {
                        LOGGER.warn("{} pool hit the {} cap -- the pass will not cover everything due today", brand, POOL_CAP);
                }
                return pool.stream().distinct().collect(Collectors.toList());
        }

        /**
         * Motorola: secondary + tertiary in ONE browser session, same shape as the
         * oppo/realme combined jobs.
         *
         * Cadence: the pool query defers an imei for `days` after each attempt
         * (saveActivation bumps createTimestamp even when no date came back), so
         * days=2 gives the requested "retry everything every two days".
         *
         * Sizing: the pending pool measured 1,534 (1,182 secondary + 352 tertiary).
         * At the ~10-14s/imei the oppo and realme jobs measure, 60 per invocation is
         * roughly 12 minutes of driver time, and clearing 1,534 inside 48h needs
         * about 26 invocations -- i.e. an OS cron entry every 90 minutes, with
         * headroom. Do NOT schedule it inside the oppo/realme window: each driver
         * tree costs ~850MB and this box has been OOM-killed twice with tomcat the
         * victim, so peak concurrent drivers is the number that matters.
         */
        public void checkMotorolaImeiStatusCombined() throws Exception {
                List<String> secondary = activatedImeiRepository.selectImeiActivationPendingByBrand("Motorola", 2, 30)
                                .stream().map(ImeiActivationTimestampModel::getSerialNumber).collect(Collectors.toList());
                List<String> tertiary = activatedImeiRepository.selectImeiActivationPendingByBrandTertiary("Motorola", 2, 30)
                                .stream().map(ImeiActivationTimestampModel::getSerialNumber).collect(Collectors.toList());
                LOGGER.info("Motorola secondary imeis {}", secondary);
                LOGGER.info("Motorola tertiary imeis {}", tertiary);
                List<String> all = new ArrayList<>(secondary);
                all.addAll(tertiary);
                all = all.stream().distinct().collect(Collectors.toList());
                if (all.isEmpty()) {
                        LOGGER.info("Motorola: nothing pending, not starting a browser");
                        return;
                }
                motorolaImeiActivationService.updateActivationDate(all);
        }

}