| 30308 |
amit.gupta |
1 |
package com.smartdukaan.cron.scheduled;
|
|
|
2 |
|
|
|
3 |
import com.spice.profitmandi.dao.model.ImeiActivationTimestampModel;
|
|
|
4 |
import com.spice.profitmandi.dao.repository.fofo.ActivatedImeiRepository;
|
| 30353 |
amit.gupta |
5 |
import org.apache.logging.log4j.LogManager;
|
|
|
6 |
import org.apache.logging.log4j.Logger;
|
| 30308 |
amit.gupta |
7 |
import org.springframework.beans.factory.annotation.Autowired;
|
| 37472 |
amit |
8 |
import com.smartdukaan.cron.monitored.ImeiActivationGauges;
|
| 30308 |
amit.gupta |
9 |
import org.springframework.stereotype.Component;
|
|
|
10 |
|
| 37447 |
amit |
11 |
import java.util.ArrayList;
|
| 37565 |
amit |
12 |
import java.util.Arrays;
|
| 30308 |
amit.gupta |
13 |
import java.util.List;
|
|
|
14 |
import java.util.stream.Collectors;
|
|
|
15 |
|
|
|
16 |
@Component
|
|
|
17 |
public class StandAlone {
|
|
|
18 |
|
|
|
19 |
@Autowired
|
| 30352 |
amit.gupta |
20 |
private OppoImeiActivationService oppoImeiActivationService;
|
| 30308 |
amit.gupta |
21 |
|
|
|
22 |
@Autowired
|
| 34418 |
amit.gupta |
23 |
private RealmeImeiActivationService realmeImeiActivationService;
|
|
|
24 |
|
|
|
25 |
@Autowired
|
| 37459 |
amit |
26 |
private MotorolaImeiActivationService motorolaImeiActivationService;
|
|
|
27 |
|
|
|
28 |
@Autowired
|
| 30352 |
amit.gupta |
29 |
private ActivatedImeiRepository activatedImeiRepository;
|
| 30308 |
amit.gupta |
30 |
|
| 37472 |
amit |
31 |
@Autowired
|
|
|
32 |
private ImeiActivationGauges gauges;
|
|
|
33 |
|
| 30353 |
amit.gupta |
34 |
private static final Logger LOGGER = LogManager.getLogger(StandAlone.class);
|
|
|
35 |
|
| 37472 |
amit |
36 |
/**
|
|
|
37 |
* ZERO, deliberately, and it is not an off-by-one.
|
|
|
38 |
*
|
|
|
39 |
* The pool query keeps rows with createTimestamp < now().atStartOfDay().minusDays(DAYS).
|
|
|
40 |
* At DAYS=1 an imei answered today is measured against YESTERDAY midnight, so it is
|
|
|
41 |
* not due tomorrow either -- it comes back the day after, a two-day cadence. DAYS=0
|
|
|
42 |
* measures against this morning's midnight, which is what "a full run each day"
|
|
|
43 |
* actually means. Oppo and realme ran at 1 before this change.
|
|
|
44 |
*
|
|
|
45 |
* Within a pass the snapshot already guarantees one ask per imei, so this constant
|
|
|
46 |
* only governs the gap BETWEEN passes.
|
|
|
47 |
*/
|
|
|
48 |
private static final int DAYS = 0;
|
| 34418 |
amit.gupta |
49 |
|
| 37472 |
amit |
50 |
/** Imeis per browser session. Recycles the driver; does NOT re-query the pool. */
|
|
|
51 |
private static final int CHUNK = 25;
|
| 36253 |
amit |
52 |
|
| 37472 |
amit |
53 |
/** Safety stop so a mis-set DAYS cannot pull an unbounded list into memory. */
|
|
|
54 |
private static final int POOL_CAP = 10000;
|
| 36253 |
amit |
55 |
|
| 37565 |
amit |
56 |
/** The browser lane's rotation, in turn order. */
|
|
|
57 |
private static final List<String> BROWSER_BRANDS = Arrays.asList("Oppo", "Realme");
|
|
|
58 |
|
| 37447 |
amit |
59 |
/**
|
| 37565 |
amit |
60 |
* Round-robin cursor across BROWSER_BRANDS.
|
| 37447 |
amit |
61 |
*
|
| 37565 |
amit |
62 |
* Not an AtomicInteger: this lane is a single @Scheduled(fixedDelay) job, so Spring
|
|
|
63 |
* never runs two of its ticks at once and the increment has nothing to race with.
|
|
|
64 |
* volatile only for visibility -- consecutive ticks are serialised but land on
|
|
|
65 |
* whichever scheduled-task-pool thread is free, and a stale read here would break the
|
|
|
66 |
* alternation. floorMod keeps it correct when it eventually overflows.
|
|
|
67 |
*/
|
|
|
68 |
private volatile int turn = 0;
|
|
|
69 |
|
|
|
70 |
/**
|
|
|
71 |
* Oppo and Realme share ONE thread and one work queue, ONE CHUNK PER TICK.
|
| 37447 |
amit |
72 |
*
|
| 37565 |
amit |
73 |
* Called every 20 seconds. Each call takes the next brand in the rotation, asks the
|
|
|
74 |
* pool query for its next CHUNK, runs exactly that chunk, and returns. When a brand's
|
|
|
75 |
* pool comes back empty its turn is skipped and the other brand keeps the lane; when
|
|
|
76 |
* both are empty the tick does nothing at all and stays silent until midnight.
|
| 37447 |
amit |
77 |
*
|
| 37565 |
amit |
78 |
* Nothing else in this class runs a browser, so with a single caller there is exactly
|
|
|
79 |
* one ChromeDriver alive at any moment. Peak concurrent drivers is what triggers the
|
|
|
80 |
* OOM killer on this box, not average driver-seconds, and a tick model does not change
|
|
|
81 |
* that number -- it only changes how the same driver-seconds are laid out in the day.
|
| 37447 |
amit |
82 |
*
|
| 37565 |
amit |
83 |
* ⚠ THE POOL QUERY IS THE CURSOR. There is no snapshot and no in-memory position. An
|
|
|
84 |
* imei leaves the pool because a row was stamped for it, which is why every asked imei
|
|
|
85 |
* must be stamped whatever the outcome -- see restUnanswered on the two services. An
|
|
|
86 |
* imei that is asked and not stamped is handed straight back on the next tick, twenty
|
|
|
87 |
* seconds later, and the lane stops advancing.
|
| 37447 |
amit |
88 |
*
|
| 37565 |
amit |
89 |
* That is also what replaces the snapshot the previous version relied on. The snapshot
|
|
|
90 |
* existed to stop the re-ask loop measured on 29-Aug -- realme issued 4,524 requests
|
|
|
91 |
* against 1,004 distinct imeis, 4.5 asks each, 78% of the day's budget spent re-asking,
|
|
|
92 |
* because a failed lookup wrote no row and was eligible again five minutes later.
|
|
|
93 |
* Stamping closes that at the source instead, and costs nothing the snapshot was buying:
|
|
|
94 |
* a failure still waits until tomorrow, DAYS=0 still means one ask per imei per day.
|
| 37447 |
amit |
95 |
*
|
| 37565 |
amit |
96 |
* What the snapshot cost, and this does not: a restart threw the day away. The pass
|
|
|
97 |
* held one thread for 20+ hours and kept its position only in that thread's stack, so
|
|
|
98 |
* the 12:03 restart on 09-Sep forfeited roughly 4,000 lookups and the whole afternoon,
|
|
|
99 |
* and the funnel had reported last_finish = -1 for three days running. A tick loses at
|
|
|
100 |
* most the chunk in flight.
|
| 37472 |
amit |
101 |
*
|
| 37565 |
amit |
102 |
* Sizing, measured on prod 2026-08-31: oppo 4,133 and realme 2,118 imeis at 10.2s and
|
|
|
103 |
* 14.2s each is 20.1 hours of a single thread. Add 20s per chunk and it is 21.4 hours --
|
|
|
104 |
* it fits, but only at those per-imei costs. Measured again on 09-Sep the lane was at
|
|
|
105 |
* ~21s per imei, twice the sizing, which needs ~35 hours and does not fit. So expect the
|
|
|
106 |
* pool NOT to clear on a bad day: the lane will still be working at midnight, beginDay
|
|
|
107 |
* will log the truncation, and the tail rolls over. The tick model makes that visible
|
|
|
108 |
* and survivable; it does not create capacity. The lever for capacity is DAYS=1, which
|
|
|
109 |
* halves the daily load by asking each brand every other day.
|
|
|
110 |
*
|
| 37472 |
amit |
111 |
* ⚠ Realme's ceiling is a REQUEST-VOLUME ceiling, not a CPU one. realme.com stops
|
|
|
112 |
* serving the captcha widget as the day's request count climbs: measured 920/day ->
|
|
|
113 |
* 0.3% canvas timeouts, 3,467/day -> 28%, 4,524/day -> 75%, resetting at midnight --
|
|
|
114 |
* while oppo on the same box, same driver count, same widget vendor, at 4,350/day had
|
|
|
115 |
* ZERO timeouts across all 24 hours, on a box loaded at 0.9 of 6 cores. Realme's own
|
| 37565 |
amit |
116 |
* canvas wait is 15s against oppo's 8s, so the longer wait is the one expiring. Spacing
|
|
|
117 |
* the same volume across the day does not move that number -- total daily requests is
|
|
|
118 |
* what the far end counts -- so judge this change on dates written and on whether the
|
|
|
119 |
* day clears, not on timeout count.
|
| 37447 |
amit |
120 |
*/
|
| 37472 |
amit |
121 |
public void checkBrowserImeiActivation() {
|
| 37565 |
amit |
122 |
for (int attempt = 0; attempt < BROWSER_BRANDS.size(); attempt++) {
|
|
|
123 |
String brand = BROWSER_BRANDS.get(Math.floorMod(turn++, BROWSER_BRANDS.size()));
|
|
|
124 |
List<String> chunk = dueNow(brand);
|
|
|
125 |
if (chunk.isEmpty()) {
|
|
|
126 |
// Cleared for today. Say so once, then let every later tick pass in silence:
|
|
|
127 |
// at 20 seconds a line per idle tick is 4,320 a day, per brand.
|
|
|
128 |
gauges.endDay(brand);
|
|
|
129 |
continue;
|
| 37472 |
amit |
130 |
}
|
| 37565 |
amit |
131 |
runBrand(brand, chunk, batchFor(brand));
|
|
|
132 |
gauges.churned(brand, chunk.size());
|
|
|
133 |
return;
|
| 37447 |
amit |
134 |
}
|
|
|
135 |
}
|
|
|
136 |
|
| 37472 |
amit |
137 |
/**
|
| 37565 |
amit |
138 |
* This brand's next chunk, and the day's denominator on the first tick after midnight.
|
| 37472 |
amit |
139 |
*
|
| 37565 |
amit |
140 |
* The full pool is fetched once a day purely to have something to measure progress
|
|
|
141 |
* against -- POOL_CAP rows instead of CHUNK, one extra query per brand per day, the
|
|
|
142 |
* same query the old midnight pass ran. Its head doubles as that tick's chunk, so the
|
|
|
143 |
* sizing costs no extra work. Every later tick asks for CHUNK and nothing more.
|
| 37472 |
amit |
144 |
*/
|
| 37565 |
amit |
145 |
private List<String> dueNow(String brand) {
|
|
|
146 |
if (gauges.needsDayStart(brand)) {
|
|
|
147 |
List<String> pool = pendingFor(brand, POOL_CAP);
|
|
|
148 |
gauges.beginDay(brand, pool.size());
|
|
|
149 |
return pool.size() <= CHUNK ? pool : new ArrayList<>(pool.subList(0, CHUNK));
|
| 37472 |
amit |
150 |
}
|
| 37565 |
amit |
151 |
return pendingFor(brand, CHUNK);
|
| 37472 |
amit |
152 |
}
|
|
|
153 |
|
| 37565 |
amit |
154 |
private ImeiBatch batchFor(String brand) {
|
|
|
155 |
return "Oppo".equals(brand)
|
|
|
156 |
? oppoImeiActivationService::updateActivationDate
|
|
|
157 |
: realmeImeiActivationService::updateActivationDate;
|
|
|
158 |
}
|
|
|
159 |
|
| 37472 |
amit |
160 |
/**
|
|
|
161 |
* One brand's turn. Wrapped so a failure in the first brand still lets the second
|
|
|
162 |
* one run -- these are separate sites and separate driver sessions, and a realme
|
|
|
163 |
* outage must not cost oppo its whole tick.
|
|
|
164 |
*/
|
|
|
165 |
private void runBrand(String brand, List<String> imeis, ImeiBatch batch) {
|
|
|
166 |
if (imeis.isEmpty()) {
|
| 37447 |
amit |
167 |
return;
|
|
|
168 |
}
|
| 37472 |
amit |
169 |
LOGGER.info("{} imeis {}", brand, imeis);
|
|
|
170 |
try {
|
|
|
171 |
batch.run(imeis);
|
|
|
172 |
} catch (Exception e) {
|
|
|
173 |
gauges.error(brand);
|
|
|
174 |
LOGGER.error("{} activation batch failed, continuing with the next brand", brand, e);
|
|
|
175 |
}
|
| 37447 |
amit |
176 |
}
|
|
|
177 |
|
| 37472 |
amit |
178 |
@FunctionalInterface
|
|
|
179 |
private interface ImeiBatch {
|
|
|
180 |
void run(List<String> imeis) throws Exception;
|
|
|
181 |
}
|
|
|
182 |
|
| 37459 |
amit |
183 |
/**
|
| 37565 |
amit |
184 |
* The next `maxResults` imeis due for one brand, newest query wins -- there is no
|
|
|
185 |
* cached list anywhere, so this is the lane's only notion of position.
|
| 37472 |
amit |
186 |
*
|
|
|
187 |
* The secondary/tertiary split is an artefact of there being two join paths to a
|
|
|
188 |
* serial (transaction.lineitem vs fofo.fofo_line_item), not two kinds of work: both
|
|
|
189 |
* funnel into the same updateActivationDate -> checkWarranty -> saveActivation path.
|
|
|
190 |
* They were separate jobs with separate batch sizes, which is what made the split
|
| 37565 |
amit |
191 |
* visible at all. See interleave for why they are merged by turns rather than joined
|
|
|
192 |
* end to end.
|
| 37472 |
amit |
193 |
*
|
| 37565 |
amit |
194 |
* ⚠ Neither named query has an ORDER BY, so rows arrive in whatever order MySQL
|
|
|
195 |
* returns and each tick's chunk is an arbitrary slice of what is still due. That is
|
|
|
196 |
* survivable but wasteful: hit rate per lookup is 8.4% for oppo stock sold within
|
|
|
197 |
* 180 days against 97.5% for stock sold over a year ago, so an `order by
|
|
|
198 |
* o.billingTimestamp asc` in the two named queries would drain the productive cohort
|
|
|
199 |
* first. It is a dao change and deliberately not made here.
|
|
|
200 |
*
|
|
|
201 |
* The brand-generic call is used for every brand; the realme-named copy of it has
|
|
|
202 |
* been deleted, it was a verbatim duplicate down to the named query.
|
| 37472 |
amit |
203 |
*/
|
| 37565 |
amit |
204 |
private List<String> pendingFor(String brand, int maxResults) {
|
|
|
205 |
List<String> secondary = serials(
|
|
|
206 |
activatedImeiRepository.selectImeiActivationPendingByBrand(brand, DAYS, maxResults));
|
|
|
207 |
List<String> tertiary = serials(
|
|
|
208 |
activatedImeiRepository.selectImeiActivationPendingByBrandTertiary(brand, DAYS, maxResults));
|
|
|
209 |
|
|
|
210 |
List<String> pool = interleave(secondary, tertiary).stream()
|
|
|
211 |
.distinct()
|
|
|
212 |
.limit(maxResults)
|
|
|
213 |
.collect(Collectors.toList());
|
|
|
214 |
if (maxResults == POOL_CAP && pool.size() >= POOL_CAP) {
|
|
|
215 |
LOGGER.warn("{} pool hit the {} cap -- today's due count is a floor, not the true total",
|
|
|
216 |
brand, POOL_CAP);
|
| 37472 |
amit |
217 |
}
|
| 37565 |
amit |
218 |
return pool;
|
| 37472 |
amit |
219 |
}
|
|
|
220 |
|
| 37565 |
amit |
221 |
private static List<String> serials(List<ImeiActivationTimestampModel> rows) {
|
|
|
222 |
return rows.stream().map(ImeiActivationTimestampModel::getSerialNumber).collect(Collectors.toList());
|
|
|
223 |
}
|
|
|
224 |
|
| 37472 |
amit |
225 |
/**
|
| 37565 |
amit |
226 |
* Take from both queues by turns rather than concatenating them.
|
|
|
227 |
*
|
|
|
228 |
* Plain concatenation was correct while a pass snapshotted everything and walked it to
|
|
|
229 |
* the end -- ordering could not starve anything that was going to be reached anyway.
|
|
|
230 |
* A chunk-at-a-time lane has no such guarantee: it stops wherever midnight finds it,
|
|
|
231 |
* and on the measured per-imei cost it often will not reach the end. Concatenated,
|
|
|
232 |
* oppo's 163 tertiary serials sit behind 3,819 secondary ones and would be asked only
|
|
|
233 |
* on a day that fully cleared -- so the smaller queue would go months untouched.
|
|
|
234 |
*
|
|
|
235 |
* The two are disjoint by construction (the secondary query excludes anything carrying
|
|
|
236 |
* a FofoLineItem), so distinct() downstream is insurance rather than a fix.
|
|
|
237 |
*/
|
|
|
238 |
private static List<String> interleave(List<String> first, List<String> second) {
|
|
|
239 |
List<String> merged = new ArrayList<>(first.size() + second.size());
|
|
|
240 |
for (int i = 0; i < Math.max(first.size(), second.size()); i++) {
|
|
|
241 |
if (i < first.size()) {
|
|
|
242 |
merged.add(first.get(i));
|
|
|
243 |
}
|
|
|
244 |
if (i < second.size()) {
|
|
|
245 |
merged.add(second.get(i));
|
|
|
246 |
}
|
|
|
247 |
}
|
|
|
248 |
return merged;
|
|
|
249 |
}
|
|
|
250 |
|
|
|
251 |
/**
|
| 37459 |
amit |
252 |
* Motorola: secondary + tertiary in ONE browser session, same shape as the
|
|
|
253 |
* oppo/realme combined jobs.
|
|
|
254 |
*
|
|
|
255 |
* Cadence: the pool query defers an imei for `days` after each attempt
|
|
|
256 |
* (saveActivation bumps createTimestamp even when no date came back), so
|
|
|
257 |
* days=2 gives the requested "retry everything every two days".
|
|
|
258 |
*
|
|
|
259 |
* Sizing: the pending pool measured 1,534 (1,182 secondary + 352 tertiary).
|
|
|
260 |
* At the ~10-14s/imei the oppo and realme jobs measure, 60 per invocation is
|
|
|
261 |
* roughly 12 minutes of driver time, and clearing 1,534 inside 48h needs
|
|
|
262 |
* about 26 invocations -- i.e. an OS cron entry every 90 minutes, with
|
|
|
263 |
* headroom. Do NOT schedule it inside the oppo/realme window: each driver
|
|
|
264 |
* tree costs ~850MB and this box has been OOM-killed twice with tomcat the
|
|
|
265 |
* victim, so peak concurrent drivers is the number that matters.
|
|
|
266 |
*/
|
|
|
267 |
public void checkMotorolaImeiStatusCombined() throws Exception {
|
|
|
268 |
List<String> secondary = activatedImeiRepository.selectImeiActivationPendingByBrand("Motorola", 2, 30)
|
|
|
269 |
.stream().map(ImeiActivationTimestampModel::getSerialNumber).collect(Collectors.toList());
|
|
|
270 |
List<String> tertiary = activatedImeiRepository.selectImeiActivationPendingByBrandTertiary("Motorola", 2, 30)
|
|
|
271 |
.stream().map(ImeiActivationTimestampModel::getSerialNumber).collect(Collectors.toList());
|
|
|
272 |
LOGGER.info("Motorola secondary imeis {}", secondary);
|
|
|
273 |
LOGGER.info("Motorola tertiary imeis {}", tertiary);
|
|
|
274 |
List<String> all = new ArrayList<>(secondary);
|
|
|
275 |
all.addAll(tertiary);
|
|
|
276 |
all = all.stream().distinct().collect(Collectors.toList());
|
|
|
277 |
if (all.isEmpty()) {
|
|
|
278 |
LOGGER.info("Motorola: nothing pending, not starting a browser");
|
|
|
279 |
return;
|
|
|
280 |
}
|
|
|
281 |
motorolaImeiActivationService.updateActivationDate(all);
|
|
|
282 |
}
|
|
|
283 |
|
| 36253 |
amit |
284 |
}
|