Subversion Repositories SmartDukaan

Rev

Rev 37482 | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
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
}