LoopDetection   F
last analyzed

Complexity

Total Complexity 191

Size/Duplication

Total Lines 949
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 387
dl 0
loc 949
rs 2
c 1
b 0
f 0
wmc 191

22 Methods

Rating   Name   Duplication   Size   Complexity  
D ProcessLoopDetectionIsHierarchySyncAdvised() 0 53 19
B SetSyncStateUsage() 0 34 7
F Detect() 0 153 43
A GetCachedData() 0 14 4
C IgnoreNextMessage() 0 83 14
C GetSyncedButBeforeIgnoredMessages() 0 61 16
C ClearData() 0 31 13
B IsSyncStateObsolete() 0 39 11
A ProcessLoopDetectionGetOutdatedSearchPID() 0 10 4
A ProcessLoopDetectionSetAsPush() 0 6 1
A getProcessStack() 0 5 1
D ProcessLoopDetectionIsHierarchyResyncRequired() 0 51 22
A ProcessLoopDetectionPreviousConnectionFailed() 0 14 5
A ProcessLoopDetectionTerminate() 0 8 1
A __construct() 0 7 1
A GetProcessIdentifier() 0 6 2
B updateProcessStack() 0 46 9
A ProcessLoopDetectionAddException() 0 13 2
A ProcessLoopDetectionAddStatus() 0 18 4
A ProcessLoopDetectionInit() 0 2 1
A GetProcessEntry() 0 10 2
B SetBrokenMessage() 0 31 9

How to fix   Complexity   

Complex Class

Complex classes like LoopDetection often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use LoopDetection, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/*
4
 * SPDX-License-Identifier: AGPL-3.0-only
5
 * SPDX-FileCopyrightText: Copyright 2007-2016 Zarafa Deutschland GmbH
6
 * SPDX-FileCopyrightText: Copyright 2020-2025 grommunio GmbH
7
 *
8
 * Detects an outgoing loop by looking if subsequent requests do try to get
9
 * changes for the same sync key. If more than once a synckey is requested,
10
 * the amount of items to be sent to the mobile is reduced to one. If then
11
 * (again) the same synckey is requested, we have most probably found the
12
 * "broken" item.
13
 */
14
15
class LoopDetection extends InterProcessData {
16
	public const INTERPROCESSLD = "processstack";
17
	public const BROKENMSGS = "brokenmsgs";
18
	private static $processident;
19
	private static $processentry;
20
	private $ignore_messageid;
21
	private $broken_message_uuid;
22
	private $broken_message_counter;
23
24
	/**
25
	 * Constructor.
26
	 */
27
	public function __construct() {
28
		// initialize super parameters
29
		$this->allocate = 1024000; // 1 MB
30
		$this->type = "grommunio-sync:loopdetection";
31
		parent::__construct();
32
33
		$this->ignore_messageid = false;
34
	}
35
36
	/**
37
	 * PROCESS LOOP DETECTION.
38
	 */
39
40
	/**
41
	 * Adds the process entry to the process stack.
42
	 *
43
	 * @return bool
44
	 */
45
	public function ProcessLoopDetectionInit() {
46
		return $this->updateProcessStack();
47
	}
48
49
	/**
50
	 * Marks the process entry as termineted successfully on the process stack.
51
	 *
52
	 * @return bool
53
	 */
54
	public function ProcessLoopDetectionTerminate() {
55
		// just to be sure that the entry is there
56
		self::GetProcessEntry();
57
58
		self::$processentry['end'] = time();
59
		SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->ProcessLoopDetectionTerminate()");
60
61
		return $this->updateProcessStack();
62
	}
63
64
	/**
65
	 * Returns a unique identifier for the internal process tracking.
66
	 *
67
	 * @return string
68
	 */
69
	public static function GetProcessIdentifier() {
70
		if (!isset(self::$processident)) {
71
			self::$processident = sprintf('%04x%04x', mt_rand(0, 0xFFFF), mt_rand(0, 0xFFFF));
72
		}
73
74
		return self::$processident;
75
	}
76
77
	/**
78
	 * Returns a unique entry with information about the current process.
79
	 *
80
	 * @return array
81
	 */
82
	public static function GetProcessEntry() {
83
		if (!isset(self::$processentry)) {
84
			self::$processentry = [];
85
			self::$processentry['id'] = self::GetProcessIdentifier();
86
			self::$processentry['pid'] = self::$pid;
87
			self::$processentry['time'] = self::$start;
88
			self::$processentry['cc'] = Request::GetCommandCode();
89
		}
90
91
		return self::$processentry;
92
	}
93
94
	/**
95
	 * Adds an Exceptions to the process tracking.
96
	 *
97
	 * @param Exception $exception
98
	 *
99
	 * @return bool
100
	 */
101
	public function ProcessLoopDetectionAddException($exception) {
102
		// generate entry if not already there
103
		self::GetProcessEntry();
104
105
		if (!isset(self::$processentry['stat'])) {
106
			self::$processentry['stat'] = [];
107
		}
108
109
		self::$processentry['stat'][get_class($exception)] = $exception->getCode();
110
111
		$this->updateProcessStack();
112
113
		return true;
114
	}
115
116
	/**
117
	 * Adds a folderid and connected status code to the process tracking.
118
	 *
119
	 * @param string $folderid
120
	 * @param int    $status
121
	 *
122
	 * @return bool
123
	 */
124
	public function ProcessLoopDetectionAddStatus($folderid, $status) {
125
		SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->ProcessLoopDetectionAddStatus: '%s' with status %d", $folderid ? $folderid : 'hierarchy', $status));
126
		// generate entry if not already there
127
		self::GetProcessEntry();
128
129
		if ($folderid === false) {
0 ignored issues
show
introduced by
The condition $folderid === false is always false.
Loading history...
130
			$folderid = "hierarchy";
131
		}
132
133
		if (!isset(self::$processentry['stat'])) {
134
			self::$processentry['stat'] = [];
135
		}
136
137
		self::$processentry['stat'][$folderid] = $status;
138
139
		$this->updateProcessStack();
140
141
		return true;
142
	}
143
144
	/**
145
	 * Marks the current process as a PUSH connection.
146
	 *
147
	 * @return bool
148
	 */
149
	public function ProcessLoopDetectionSetAsPush() {
150
		// generate entry if not already there
151
		self::GetProcessEntry();
152
		self::$processentry['push'] = true;
153
154
		return $this->updateProcessStack();
155
	}
156
157
	/**
158
	 * Indicates if a simple Hierarchy sync should be done after Ping.
159
	 *
160
	 * When trying to sync a non existing folder, Sync will return Status 12.
161
	 * This should trigger a hierarchy sync by the client, but this is not always done.
162
	 * Clients continue trying to Ping, which fails as well and triggers a Sync again.
163
	 *
164
	 * Ping could indicate to perform a FolderSync as well after a few Sync/Ping cycles.
165
	 *
166
	 * @return bool
167
	 */
168
	public function ProcessLoopDetectionIsHierarchySyncAdvised() {
169
		$me = self::GetProcessEntry();
170
		if ($me['cc'] !== GSync::COMMAND_PING) {
171
			return false;
172
		}
173
174
		$loopingFolders = [];
175
176
		$lookback = self::$start - 600; // look at the last 5 min
177
		foreach ($this->getProcessStack() as $se) {
178
			if ($se['time'] > $lookback && $se['time'] < (self::$start - 1)) {
179
				// look for sync command
180
				if (isset($se['stat']) && ($se['cc'] == GSync::COMMAND_SYNC || $se['cc'] == GSync::COMMAND_PING)) {
181
					foreach ($se['stat'] as $key => $value) {
182
						// we only care about hierarchy errors of this folder
183
						if ($se['cc'] == GSync::COMMAND_SYNC) {
184
							if ($value == SYNC_STATUS_FOLDERHIERARCHYCHANGED) {
185
								SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->ProcessLoopDetectionIsHierarchySyncAdvised(): seen Sync command with Exception or folderid '%s' and code '%s'", $key, $value));
186
							}
187
						}
188
						if (!isset($loopingFolders[$key])) {
189
							$loopingFolders[$key] = [GSync::COMMAND_SYNC => [], GSync::COMMAND_PING => []];
190
						}
191
						if (!isset($loopingFolders[$key][$se['cc']][$value])) {
192
							$loopingFolders[$key][$se['cc']][$value] = 0;
193
						}
194
						++$loopingFolders[$key][$se['cc']][$value];
195
					}
196
				}
197
			}
198
		}
199
200
		$filtered = [];
0 ignored issues
show
Unused Code introduced by
The assignment to $filtered is dead and can be removed.
Loading history...
201
		foreach ($loopingFolders as $fid => $data) {
202
			// Ping is constantly generating changes for this folder
203
			if (isset($data[GSync::COMMAND_PING][SYNC_PINGSTATUS_CHANGES]) && $data[GSync::COMMAND_PING][SYNC_PINGSTATUS_CHANGES] >= 3) {
204
				// but the Sync request is not treating it (not being requested)
205
				if (count($data[GSync::COMMAND_SYNC]) == 0) {
206
					SLog::Write(LOGLEVEL_INFO, sprintf("LoopDetection->ProcessLoopDetectionIsHierarchySyncAdvised(): Ping loop of folderid '%s' detected that is not being synchronized.", $fid));
207
208
					return true;
209
				}
210
				// Sync is executed, but a foldersync should be executed (hierarchy errors)
211
				if (isset($data[GSync::COMMAND_SYNC][SYNC_STATUS_FOLDERHIERARCHYCHANGED]) &&
212
						$data[GSync::COMMAND_SYNC][SYNC_STATUS_FOLDERHIERARCHYCHANGED] > 3) {
213
					SLog::Write(LOGLEVEL_INFO, sprintf("LoopDetection->ProcessLoopDetectionIsHierarchySyncAdvised(): Sync(with error)/Ping loop of folderid '%s' detected.", $fid));
214
215
					return true;
216
				}
217
			}
218
		}
219
220
		return false;
221
	}
222
223
	/**
224
	 * Indicates if a full Hierarchy Resync is necessary.
225
	 *
226
	 * In some occasions the mobile tries to sync a folder with an invalid/not-existing ID.
227
	 * In these cases a status exception like SYNC_STATUS_FOLDERHIERARCHYCHANGED is returned
228
	 * so the mobile executes a FolderSync expecting that some action is taken on that folder (e.g. remove).
229
	 *
230
	 * If the FolderSync is not doing anything relevant, then the Sync is attempted again
231
	 * resulting in the same error and looping between these two processes.
232
	 *
233
	 * This method checks if in the last process stack a Sync and FolderSync were triggered to
234
	 * catch the loop at the 2nd interaction (Sync->FolderSync->Sync->FolderSync => ReSync)
235
	 *
236
	 * @return bool
237
	 */
238
	public function ProcessLoopDetectionIsHierarchyResyncRequired() {
239
		$seenFailed = [];
240
		$seenFolderSync = false;
241
242
		$lookback = self::$start - 600; // look at the last 5 min
243
		foreach ($this->getProcessStack() as $se) {
244
			if ($se['time'] > $lookback && $se['time'] < (self::$start - 1)) {
245
				// look for sync command
246
				if (isset($se['stat']) && ($se['cc'] == GSync::COMMAND_SYNC || $se['cc'] == GSync::COMMAND_PING)) {
247
					foreach ($se['stat'] as $key => $value) {
248
						// don't count PING with changes on a folder or sync with success
249
						if (($se['cc'] == GSync::COMMAND_PING && $value == SYNC_PINGSTATUS_CHANGES) ||
250
								($se['cc'] == GSync::COMMAND_SYNC && $value == SYNC_STATUS_SUCCESS)) {
251
							continue;
252
						}
253
						if (!isset($seenFailed[$key])) {
254
							$seenFailed[$key] = 0;
255
						}
256
						++$seenFailed[$key];
257
						SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->ProcessLoopDetectionIsHierarchyResyncRequired(): seen command with Exception or folderid '%s' and code '%s'", $key, $value));
258
					}
259
				}
260
				// look for FolderSync command with previous failed commands
261
				if ($se['cc'] == GSync::COMMAND_FOLDERSYNC && !empty($seenFailed) && $se['id'] != self::GetProcessIdentifier()) {
262
					// a full folderresync was already triggered
263
					if (isset($se['stat'], $se['stat']['hierarchy']) && $se['stat']['hierarchy'] == SYNC_FSSTATUS_SYNCKEYERROR) {
264
						SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->ProcessLoopDetectionIsHierarchyResyncRequired(): a full FolderReSync was already requested. Resetting fail counter.");
265
						$seenFailed = [];
266
					}
267
					else {
268
						$seenFolderSync = true;
269
						SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->ProcessLoopDetectionIsHierarchyResyncRequired(): seen FolderSync after other failing command");
270
					}
271
				}
272
			}
273
		}
274
275
		$filtered = [];
276
		foreach ($seenFailed as $k => $count) {
277
			if ($count > 1) {
278
				$filtered[] = $k;
279
			}
280
		}
281
282
		if ($seenFolderSync && !empty($filtered)) {
283
			SLog::Write(LOGLEVEL_INFO, "LoopDetection->ProcessLoopDetectionIsHierarchyResyncRequired(): Potential loop detected. Full hierarchysync indicated.");
284
285
			return true;
286
		}
287
288
		return false;
289
	}
290
291
	/**
292
	 * Indicates if a previous process could not be terminated.
293
	 *
294
	 * Checks if there is an end time for the last entry on the stack
295
	 *
296
	 * @return bool
297
	 */
298
	public function ProcessLoopDetectionPreviousConnectionFailed() {
299
		$stack = $this->getProcessStack();
300
		$errors = false;
301
		if (count($stack) > 1) {
302
			$se = $stack[0];
303
			if (!isset($se['end']) && $se['cc'] != GSync::COMMAND_PING && !isset($se['push'])) {
304
				// there is no end time
305
				SLog::Write(LOGLEVEL_ERROR, sprintf("LoopDetection->ProcessLoopDetectionPreviousConnectionFailed(): Command '%s' at %s with pid '%d' terminated unexpectedly or is still running.", Utils::GetCommandFromCode($se['cc']), Utils::GetFormattedTime($se['time']), $se['pid']));
306
				SLog::Write(LOGLEVEL_ERROR, "Please check your logs for this PID and errors like PHP-Fatals or Apache segmentation faults and report your results to the grommunio dev team.");
307
				$errors = true;
308
			}
309
		}
310
311
		return $errors;
312
	}
313
314
	/**
315
	 * Gets the PID of an outdated search process.
316
	 *
317
	 * Returns false if there isn't any process
318
	 *
319
	 * @return bool
320
	 */
321
	public function ProcessLoopDetectionGetOutdatedSearchPID() {
322
		$stack = $this->getProcessStack();
323
		if (count($stack) > 1) {
324
			$se = $stack[0];
325
			if ($se['cc'] == GSync::COMMAND_SEARCH || $se['cc'] == GSync::COMMAND_FIND) {
326
				return $se['pid'];
327
			}
328
		}
329
330
		return false;
331
	}
332
333
	/**
334
	 * Inserts or updates the current process entry on the stack.
335
	 *
336
	 * @return bool
337
	 */
338
	private function updateProcessStack() {
339
		// initialize params
340
		$this->initializeParams();
341
342
		$ok = false;
343
		$tryCount = 1;
344
		while (!$ok && $tryCount < 5) {
345
			list($stack, $stackRaw) = $this->getDeviceUserData($this->type, parent::$devid, parent::$user, self::INTERPROCESSLD, true);
346
347
			// insert/update current process entry
348
			$nstack = [];
349
			$updateentry = self::GetProcessEntry();
350
			$found = false;
351
352
			foreach ($stack as $entry) {
353
				if ($entry['id'] != $updateentry['id']) {
354
					$nstack[] = $entry;
355
				}
356
				else {
357
					$nstack[] = $updateentry;
358
					$found = true;
359
				}
360
			}
361
362
			if (!$found) {
363
				$nstack[] = $updateentry;
364
			}
365
366
			if (count($nstack) > 10) {
367
				$nstack = array_slice($nstack, -10, 10);
368
			}
369
370
			// update loop data
371
			$ok = $this->setDeviceUserData($this->type, $nstack, parent::$devid, parent::$user, self::INTERPROCESSLD, $doCas = "replace", $stackRaw);
372
			if (!$ok) {
373
				$l = LOGLEVEL_INFO;
374
				if ($tryCount > 1) {
375
					$l = LOGLEVEL_WARN;
376
				}
377
				SLog::Write($l, sprintf("LoopDetection->updateProcessStack(): CAS failed on try %d!", $tryCount));
378
				usleep(100000);
379
				++$tryCount;
380
			}
381
		}
382
383
		return $ok;
384
	}
385
386
	/**
387
	 * Returns the current process stack.
388
	 *
389
	 * @return array
390
	 */
391
	private function getProcessStack() {
392
		// initialize params
393
		$this->initializeParams();
394
395
		return $this->getDeviceUserData($this->type, parent::$devid, parent::$user, self::INTERPROCESSLD);
396
	}
397
398
	/*
399
	 * TRACKING OF BROKEN MESSAGES
400
	 * if a previousily ignored message is streamed again to the device it's tracked here.
401
	 *
402
	 * There are two outcomes:
403
	 * - next uuid counter is higher than current -> message is fixed and successfully synchronized
404
	 * - next uuid counter is the same or uuid changed -> message is still broken
405
	 */
406
407
	/**
408
	 * Adds a message to the tracking of broken messages
409
	 * Being tracked means that a broken message was streamed to the device.
410
	 * We save the latest uuid and counter so if on the next sync the counter is higher
411
	 * the message was accepted by the device.
412
	 *
413
	 * @param string $folderid the parent folder of the message
414
	 * @param string $id       the id of the message
415
	 *
416
	 * @return bool
417
	 */
418
	public function SetBrokenMessage($folderid, $id) {
419
		if ($folderid == false ||
0 ignored issues
show
Bug Best Practice introduced by
It seems like you are loosely comparing $folderid of type string to the boolean false. If you are specifically checking for an empty string, consider using the more explicit === '' instead.
Loading history...
420
				!isset($this->broken_message_uuid) ||
421
				!isset($this->broken_message_counter) ||
422
				$this->broken_message_uuid == false ||
423
				$this->broken_message_counter == false) {
424
			return false;
425
		}
426
427
		$ok = false;
428
		$brokenkey = self::BROKENMSGS . "-" . $folderid;
429
430
		// initialize params
431
		$this->initializeParams();
432
		$tryCount = 1;
433
		while (!$ok && $tryCount < 5) {
434
			list($brokenmsgs, $brokenmsgsRaw) = $this->getDeviceUserData($this->type, parent::$devid, parent::$user, $brokenkey, true);
435
436
			$brokenmsgs[$id] = ['uuid' => $this->broken_message_uuid, 'counter' => $this->broken_message_counter];
437
			SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->SetBrokenMessage('%s', '%s'): tracking broken message", $folderid, $id));
438
439
			// update data
440
			$ok = $this->setDeviceUserData($this->type, $brokenmsgs, parent::$devid, parent::$user, $brokenkey, $doCas = "replace", $brokenmsgsRaw);
441
			if (!$ok) {
442
				SLog::Write(LOGLEVEL_WARN, sprintf("LoopDetection->SetBrokenMessage(): CAS failed on try %d!", $tryCount));
443
				usleep(100000);
444
				++$tryCount;
445
			}
446
		}
447
448
		return $ok;
449
	}
450
451
	/**
452
	 * Gets a list of all ids of a folder which were tracked and which were
453
	 * accepted by the device from the last sync.
454
	 *
455
	 * @param string $folderid the parent folder of the message
456
	 *
457
	 * @return array
458
	 */
459
	public function GetSyncedButBeforeIgnoredMessages($folderid) {
460
		if ($folderid == false ||
0 ignored issues
show
Bug Best Practice introduced by
It seems like you are loosely comparing $folderid of type string to the boolean false. If you are specifically checking for an empty string, consider using the more explicit === '' instead.
Loading history...
461
				!isset($this->broken_message_uuid) ||
462
				!isset($this->broken_message_counter) ||
463
				$this->broken_message_uuid == false ||
464
				$this->broken_message_counter == false) {
465
			return [];
466
		}
467
468
		$brokenkey = self::BROKENMSGS . "-" . $folderid;
469
		$removeIds = [];
470
		$okIds = [];
471
472
		// initialize params
473
		$this->initializeParams();
474
475
		$ok = false;
476
		$tryCount = 1;
477
		while (!$ok && $tryCount < 5) {
478
			list($brokenmsgs, $brokenmsgsRaw) = $this->getDeviceUserData($this->type, parent::$devid, parent::$user, $brokenkey, true);
479
480
			if (empty($brokenmsgs)) {
481
				break;
482
			}
483
484
			foreach ($brokenmsgs as $id => $data) {
485
				// previously broken message was successfully synced!
486
				if ($data['uuid'] == $this->broken_message_uuid && $data['counter'] < $this->broken_message_counter) {
487
					SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->GetSyncedButBeforeIgnoredMessages('%s'): message '%s' was successfully synchronized", $folderid, $id));
488
					$okIds[] = $id;
489
				}
490
491
				// if the uuid has changed this is old data which should also be removed
492
				if ($data['uuid'] != $this->broken_message_uuid) {
493
					SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->GetSyncedButBeforeIgnoredMessages('%s'): stored message id '%s' for uuid '%s' is obsolete", $folderid, $id, $data['uuid']));
494
					$removeIds[] = $id;
495
				}
496
			}
497
498
			// remove data
499
			foreach (array_merge($okIds, $removeIds) as $id) {
500
				unset($brokenmsgs[$id]);
501
			}
502
503
			if (empty($brokenmsgs)) {
504
				$this->delDeviceUserData($this->type, parent::$devid, parent::$user, $brokenkey);
505
				SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->GetSyncedButBeforeIgnoredMessages('%s'): removed folder from tracking of ignored messages", $folderid));
506
507
				break;
508
			}
509
510
			// update data
511
			$ok = $this->setDeviceUserData($this->type, $brokenmsgs, parent::$devid, parent::$user, $brokenkey, $doCas = "replace", $brokenmsgsRaw);
512
			if (!$ok) {
513
				SLog::Write(LOGLEVEL_WARN, sprintf("LoopDetection->GetSyncedButBeforeIgnoredMessages(): CAS failed on try %d!", $tryCount));
514
				usleep(100000);
515
				++$tryCount;
516
			}
517
		}
518
519
		return $okIds;
520
	}
521
522
	/**
523
	 * Marks a SyncState as "already used", e.g. when an import process started.
524
	 * This is most critical for DiffBackends, as an imported message would be exported again
525
	 * in the heartbeat if the notification is triggered before the import is complete.
526
	 *
527
	 * @param string $folderid folder id
528
	 * @param string $uuid     synkkey
529
	 * @param string $counter  synckey counter
530
	 *
531
	 * @return bool
532
	 */
533
	public function SetSyncStateUsage($folderid, $uuid, $counter) {
534
		// initialize params
535
		$this->initializeParams();
536
537
		SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->SetSyncStateUsage(): uuid: %s  counter: %d", $uuid, $counter));
538
539
		$ok = false;
540
		$tryCount = 1;
541
		while (!$ok && $tryCount < 5) {
542
			list($current, $currentRaw) = $this->getDeviceUserData($this->type, parent::$devid, parent::$user, $folderid, true);
543
544
			if (!isset($current["uuid"])) {
545
				$current["uuid"] = $uuid;
546
			}
547
			if (!isset($current["count"])) {
548
				$current["count"] = $counter;
549
			}
550
			if (!isset($current["queued"])) {
551
				$current["queued"] = 0;
552
			}
553
554
			// update the usage flag
555
			$current["usage"] = $counter;
556
557
			// update loop data
558
			$ok = $this->setDeviceUserData($this->type, $current, parent::$devid, parent::$user, $folderid, $doCas = "replace", $currentRaw);
559
			if (!$ok) {
560
				SLog::Write(LOGLEVEL_WARN, sprintf("LoopDetection->SetSyncStateUsage(): CAS failed on try %d!", $tryCount));
561
				usleep(100000);
562
				++$tryCount;
563
			}
564
		}
565
566
		return $ok;
567
	}
568
569
	/**
570
	 * Checks if the given counter for a certain uuid+folderid was exported before.
571
	 * Returns also true if the counter are the same but previously there were
572
	 * changes to be exported.
573
	 *
574
	 * @param string $folderid folder id
575
	 * @param string $uuid     synkkey
576
	 * @param string $counter  synckey counter
577
	 *
578
	 * @return bool indicating if an uuid+counter were exported (with changes) before
579
	 */
580
	public function IsSyncStateObsolete($folderid, $uuid, $counter) {
581
		// initialize params
582
		$this->initializeParams();
583
584
		$obsolete = false;
585
586
		$current = $this->getDeviceUserData($this->type, parent::$devid, parent::$user, $folderid);
587
588
		if (!empty($current)) {
589
			if (!isset($current["uuid"]) || $current["uuid"] != $uuid) {
590
				SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->IsSyncStateObsolete(): yes, uuid changed or not set");
591
				$obsolete = true;
592
			}
593
			else {
594
				SLog::Write(LOGLEVEL_DEBUG, sprintf(
595
					"LoopDetection->IsSyncStateObsolete(): check folderid: '%s' uuid '%s' counter: %d - last counter: %d with %d queued",
596
					$folderid,
597
					$uuid,
598
					$counter,
599
					$current["count"],
600
					$current["queued"]
601
				));
602
603
				if ($current["uuid"] == $uuid && (
604
					$current["count"] > $counter ||
605
						($current["count"] == $counter && $current["queued"] > 0) ||
606
						(isset($current["usage"]) && $current["usage"] >= $counter)
607
				)) {
608
					$usage = isset($current["usage"]) ? sprintf(" - counter %d already expired", $current["usage"]) : "";
609
					SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->IsSyncStateObsolete(): yes, counter already processed" . $usage);
610
					$obsolete = true;
611
				}
612
			}
613
		}
614
		else {
615
			SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->IsSyncStateObsolete(): check folderid: '%s' uuid '%s' counter: %d - no data found: not obsolete", $folderid, $uuid, $counter));
616
		}
617
618
		return $obsolete;
619
	}
620
621
	/*
622
	 * MESSAGE LOOP DETECTION.
623
	 */
624
625
	/**
626
	 * Loop detection mechanism.
627
	 *
628
	 *    1. request counter is higher than the previous counter (somehow default)
629
	 *      1.1)   standard situation                                   -> do nothing
630
	 *      1.2)   loop information exists
631
	 *      1.2.1) request counter < maxCounter AND no ignored data     -> continue in loop mode
632
	 *      1.2.2) request counter < maxCounter AND ignored data        -> we have already encountered issue, return to normal
633
	 *
634
	 *    2. request counter is the same as the previous, but no data was sent on the last request (standard situation)
635
	 *
636
	 *    3. request counter is the same as the previous and last time objects were sent (loop!)
637
	 *      3.0)   no loop was detected before, but with big window size -> lower window size first - NO LOOP mode yet
638
	 *      3.1)   no loop was detected before, entering loop mode      -> save loop data, loopcount = 1
639
	 *      3.2)   loop was detected before, but are gone               -> loop resolved
640
	 *      3.3)   loop was detected before, continuing in loop mode    -> this is probably the broken element,loopcount++,
641
	 *      3.3.1) item identified, loopcount >= 3                      -> ignore item, set ignoredata flag
642
	 *
643
	 * @param string $folderid       the current folder id to be worked on
644
	 * @param string $uuid           the synkkey
645
	 * @param string $counter        the synckey counter
646
	 * @param string $maxItems       the current amount of items to be sent to the mobile
647
	 * @param string $queuedMessages the amount of messages which were found by the exporter
648
	 *
649
	 * @return bool|int when returning true if a loop has been identified - returns new suggested window size if window might have been too big
650
	 */
651
	public function Detect($folderid, $uuid, $counter, $maxItems, $queuedMessages) {
652
		SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->Detect(): folderid:'%s' uuid:'%s' counter:'%s' max:'%s' queued:'%s'", $folderid, $uuid, $counter, $maxItems, $queuedMessages));
653
		$this->broken_message_uuid = $uuid;
654
		$this->broken_message_counter = $counter;
655
656
		// if an incoming loop is already detected, do nothing
657
		if ($maxItems === 0 && $queuedMessages > 0) {
0 ignored issues
show
introduced by
The condition $maxItems === 0 is always false.
Loading history...
658
			GSync::GetTopCollector()->AnnounceInformation("Incoming loop!", true);
659
660
			return true;
661
		}
662
663
		// initialize params
664
		$this->initializeParams();
665
666
		$loop = false;
667
668
		$ok = false;
669
		$tryCount = 1;
670
		while (!$ok && $tryCount < 5) {
671
			list($current, $currentRaw) = $this->getDeviceUserData($this->type, parent::$devid, parent::$user, $folderid, true);
672
673
			// completely new/unknown UUID
674
			if (empty($current)) {
675
				$current = ["uuid" => $uuid, "count" => $counter - 1, "queued" => $queuedMessages];
676
			}
677
678
			// old UUID in cache - the device requested a new state!!
679
			if (isset($current['uuid']) && $current['uuid'] != $uuid) {
680
				SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->Detect(): UUID changed for folder");
681
682
				// some devices (iPhones) may request new UUIDs after broken items were sent several times
683
				if (isset($current['queued']) && $current['queued'] > 0 &&
684
					(isset($current['maxCount']) && $current['maxCount'] > $current['count'] + 1 || $counter == 1)) {
685
					SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->Detect(): UUID changed and while items where sent to device - forcing loop mode");
686
					$loop = true; // force loop mode
687
					$current['queued'] = $queuedMessages;
688
				}
689
				else {
690
					$current['queued'] = 0;
691
				}
692
693
				// set new data, unset old loop information
694
				$current["uuid"] = $uuid;
695
				$current['count'] = $counter;
696
				unset($current['loopcount'], $current['ignored'], $current['maxCount'], $current['potential'], $current['windowLimit']);
697
			}
698
699
			// see if there are values
700
			if (isset($current['uuid']) && $current['uuid'] == $uuid &&
701
				isset($current['count'])) {
702
				// case 1 - standard, during loop-resolving & resolving
703
				if ($current['count'] < $counter) {
704
					// case 1.1
705
					$current['count'] = $counter;
706
					$current['queued'] = $queuedMessages;
707
					if (isset($current["usage"]) && $current["usage"] < $current['count']) {
708
						unset($current["usage"]);
709
					}
710
711
					// case 1.2
712
					if (isset($current['maxCount'])) {
713
						SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->Detect(): case 1.2 detected");
714
715
						// case 1.2.1
716
						// broken item not identified yet
717
						if (!isset($current['ignored']) && $counter < $current['maxCount']) {
718
							$current['loopcount'] = 1;
719
							$loop = true; // continue in loop-resolving
720
							SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->Detect(): case 1.2.1 detected");
721
						}
722
						// case 1.2.2 - if there were any broken items they should be gone, return to normal
723
						else {
724
							SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->Detect(): case 1.2.2 detected");
725
							unset($current['loopcount'], $current['ignored'], $current['maxCount'], $current['potential'], $current['windowLimit']);
726
						}
727
					}
728
				}
729
730
				// case 2 - same counter, but there were no changes before and are there now
731
				elseif ($current['count'] == $counter && $current['queued'] == 0 && $queuedMessages > 0) {
732
					$current['queued'] = $queuedMessages;
733
					if (isset($current["usage"]) && $current["usage"] < $current['count']) {
734
						unset($current["usage"]);
735
					}
736
				}
737
738
				// case 3 - same counter, changes sent before, hanging loop and ignoring
739
				elseif ($current['count'] == $counter && $current['queued'] > 0) {
740
					if (!isset($current['loopcount'])) {
741
						// We are potentially syncing a lot of data, e.g. OL with 512 WindowSize
742
						// In case there are more then 40 items in the last request, we limit to 25 items
743
						// before entering 1-by-1 loop detection if counter is re-requested
744
						if ($maxItems > 40 && !isset($current['windowLimit'])) {
745
							// case 3.0) we have just encountered a loop, but with a big window size, lower window first
746
							SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->Detect(): case 3.0 detected - big windowsize of %d, lowering before entering loop mode", $maxItems));
747
							// return suggested new window size
748
							$current['windowLimit'] = 25;
749
							$loop = $current['windowLimit'];
750
						}
751
						else {
752
							// case 3.1) we have just encountered a loop!
753
							SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->Detect(): case 3.1 detected - loop detected, init loop mode");
754
							if (isset($current['windowLimit'])) {
755
								$maxItems = $current['windowLimit'];
756
								unset($current['windowLimit']);
757
							}
758
							$current['loopcount'] = 1;
759
							// the MaxCount is the max number of messages exported before
760
							$current['maxCount'] = $counter + (($maxItems < $queuedMessages) ? $maxItems : $queuedMessages);
761
							$loop = true;   // loop mode!!
762
						}
763
					}
764
					elseif ($queuedMessages == 0) {
765
						// case 3.2) there was a loop before but now the changes are GONE
766
						SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->Detect(): case 3.2 detected - changes gone - clearing loop data");
767
						$current['queued'] = 0;
768
						unset($current['loopcount'], $current['ignored'], $current['maxCount'], $current['potential'], $current['windowLimit']);
769
					}
770
					else {
771
						// case 3.3) still looping the same message! Increase counter
772
						SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->Detect(): case 3.3 detected - in loop mode, increase loop counter");
773
						++$current['loopcount'];
774
775
						// case 3.3.1 - we got our broken item!
776
						if ($current['loopcount'] >= 3 && isset($current['potential'])) {
777
							SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->Detect(): case 3.3.1 detected - broken item should be next, attempt to ignore it - id '%s'", $current['potential']));
778
							$this->ignore_messageid = $current['potential'];
779
						}
780
						$current['maxCount'] = $counter + (($maxItems < $queuedMessages) ? $maxItems : $queuedMessages);
781
						$loop = true;   // loop mode!!
782
					}
783
				}
784
			}
785
			if (isset($current['loopcount'])) {
786
				SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->Detect(): loop data: loopcount(%d), maxCount(%d), queued(%d), ignored(%s)", $current['loopcount'], $current['maxCount'], $current['queued'], isset($current['ignored']) ? $current['ignored'] : 'false'));
787
			}
788
789
			// update loop data
790
			$ok = $this->setDeviceUserData($this->type, $current, parent::$devid, parent::$user, $folderid, $doCas = "replace", $currentRaw);
791
			if (!$ok) {
792
				SLog::Write(LOGLEVEL_WARN, sprintf("LoopDetection->Detect(): CAS failed on try %d!", $tryCount));
793
				usleep(100000);
794
				++$tryCount;
795
			}
796
		}
797
		// end exclusive block
798
799
		if ($loop === true && $this->ignore_messageid === false) {
800
			GSync::GetTopCollector()->AnnounceInformation("Loop detection", true);
801
		}
802
803
		return $loop;
804
	}
805
806
	/**
807
	 * Indicates if the next messages should be ignored (not be sent to the mobile!).
808
	 *
809
	 * @param string $messageid     (opt) id of the message which is to be exported next
810
	 * @param string $folderid      (opt) parent id of the message
811
	 * @param bool   $markAsIgnored (opt) to peek without setting the next message to be
812
	 *                              ignored, set this value to false
813
	 *
814
	 * @return bool
815
	 */
816
	public function IgnoreNextMessage($markAsIgnored = true, $messageid = false, $folderid = false) {
817
		// as the next message id is not available at all point this method is called, we use different indicators.
818
		// potentialbroken indicates that we know that the broken message should be exported next,
819
		// alltho we do not know for sure as it's export message orders can change
820
		// if the $messageid is available and matches then we are sure and only then really ignore it
821
822
		$potentialBroken = false;
823
		$realBroken = false;
824
		if (Request::GetCommandCode() == GSync::COMMAND_SYNC && $this->ignore_messageid !== false) {
825
			$potentialBroken = true;
826
		}
827
828
		if ($messageid !== false && $this->ignore_messageid == $messageid) {
829
			$realBroken = true;
830
		}
831
832
		// this call is just to know what should be happening
833
		// no further actions necessary
834
		if ($markAsIgnored === false) {
835
			return $potentialBroken;
836
		}
837
838
		// we should really do something here
839
840
		// first we check if we are in the loop mode, if so,
841
		// we update the potential broken id message so we loop count the same message
842
843
		$changedData = false;
844
		$ok = false;
845
		$tryCount = 1;
846
		while (!$ok && $tryCount < 5) {
847
			list($current, $currentRaw) = $this->getDeviceUserData($this->type, parent::$devid, parent::$user, $folderid, true);
848
849
			// we found our broken message!
850
			if ($realBroken) {
851
				$this->ignore_messageid = false;
852
				$current['ignored'] = $messageid;
853
				$changedData = true;
854
855
				// check if this message was broken before - here we know that it still is and remove it from the tracking
856
				$brokenkey = self::BROKENMSGS . "-" . $folderid;
0 ignored issues
show
Bug introduced by
Are you sure $folderid of type false|string can be used in concatenation? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

856
				$brokenkey = self::BROKENMSGS . "-" . /** @scrutinizer ignore-type */ $folderid;
Loading history...
Unused Code introduced by
The assignment to $brokenkey is dead and can be removed.
Loading history...
857
			// TODO: this is currently not supported here! It's in a different structure now!
858
			//                if (isset($loopdata[self::$devid][self::$user][$brokenkey]) && isset($loopdata[self::$devid][self::$user][$brokenkey][$messageid])) {
859
			//                    SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->IgnoreNextMessage(): previously broken message '%s' is still broken and will not be tracked anymore", $messageid));
860
			//                    unset($loopdata[self::$devid][self::$user][$brokenkey][$messageid]);
861
			//                }
862
			}
863
			// not the broken message yet
864
			else {
865
				// update potential id if looping on an item
866
				if (isset($current['loopcount'])) {
867
					$current['potential'] = $messageid;
868
869
					// this message should be the broken one, but is not!!
870
					// we should reset the loop count because this is certainly not the broken one
871
					if ($potentialBroken) {
872
						$current['loopcount'] = 1;
873
						SLog::Write(LOGLEVEL_DEBUG, "LoopDetection->IgnoreNextMessage(): this should be the broken one, but is not! Resetting loop count.");
874
					}
875
876
					SLog::Write(LOGLEVEL_DEBUG, sprintf("LoopDetection->IgnoreNextMessage(): Loop mode, potential broken message id '%s'", $current['potential']));
877
878
					$changedData = true;
879
				}
880
			}
881
882
			if ($changedData !== true) {
883
				break;
884
			}
885
			// update loop data
886
			$ok = $this->setDeviceUserData($this->type, $current, parent::$devid, parent::$user, $folderid, $doCas = "replace", $currentRaw);
887
			if (!$ok) {
888
				SLog::Write(LOGLEVEL_WARN, sprintf("LoopDetection->Detect(): CAS failed on try %d!", $tryCount));
889
				usleep(100000);
890
				++$tryCount;
891
			}
892
		}
893
894
		if ($realBroken) {
895
			GSync::GetTopCollector()->AnnounceInformation("Broken message ignored", true);
896
		}
897
898
		return $realBroken;
899
	}
900
901
	/**
902
	 * Clears loop detection data.
903
	 *
904
	 * @param string $user  (opt) user which data should be removed - user can not be specified without
905
	 * @param string $devid (opt) device id which data to be removed
906
	 *
907
	 * @return bool
908
	 */
909
	public function ClearData($user = false, $devid = false) {
910
		$stat = true;
911
		$ok = false;
912
		// TODO: implement this
913
		// exclusive block
914
		if ($this->blockMutex()) {
915
			$loopdata = ($this->hasData()) ? $this->getData() : [];
916
917
			if ($user == false && $devid == false) {
0 ignored issues
show
Bug introduced by
It seems like you are loosely comparing $user of type false|string against false; this is ambiguous if the string can be empty. Consider using a strict comparison === instead.
Loading history...
Bug introduced by
It seems like you are loosely comparing $devid of type false|string against false; this is ambiguous if the string can be empty. Consider using a strict comparison === instead.
Loading history...
918
				$loopdata = [];
919
			}
920
			elseif ($user == false && $devid != false) {
0 ignored issues
show
Bug introduced by
It seems like you are loosely comparing $user of type false|string against false; this is ambiguous if the string can be empty. Consider using a strict comparison === instead.
Loading history...
Bug introduced by
It seems like you are loosely comparing $devid of type false|string against false; this is ambiguous if the string can be empty. Consider using a strict comparison !== instead.
Loading history...
921
				$loopdata[$devid] = [];
922
			}
923
			elseif ($user != false && $devid != false) {
0 ignored issues
show
Bug introduced by
It seems like you are loosely comparing $user of type false|string against false; this is ambiguous if the string can be empty. Consider using a strict comparison !== instead.
Loading history...
Bug introduced by
It seems like you are loosely comparing $devid of type false|string against false; this is ambiguous if the string can be empty. Consider using a strict comparison !== instead.
Loading history...
924
				$loopdata[$devid][$user] = [];
925
			}
926
			elseif ($user != false && $devid == false) {
0 ignored issues
show
Bug introduced by
It seems like you are loosely comparing $devid of type false|string against false; this is ambiguous if the string can be empty. Consider using a strict comparison === instead.
Loading history...
Bug introduced by
It seems like you are loosely comparing $user of type false|string against false; this is ambiguous if the string can be empty. Consider using a strict comparison !== instead.
Loading history...
927
				SLog::Write(LOGLEVEL_WARN, sprintf("Not possible to reset loop detection data for user '%s' without a specifying a device id", $user));
928
				$stat = false;
929
			}
930
931
			if ($stat) {
932
				$ok = $this->setData($loopdata);
933
			}
934
935
			$this->releaseMutex();
936
		}
937
		// end exclusive block
938
939
		return $stat && $ok;
940
	}
941
942
	/**
943
	 * Returns loop detection data for a user and device.
944
	 *
945
	 * @param string $user
946
	 * @param string $devid
947
	 *
948
	 * @return array|bool returns false if data not available
949
	 */
950
	public function GetCachedData($user, $devid) {
0 ignored issues
show
Unused Code introduced by
The parameter $devid is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

950
	public function GetCachedData($user, /** @scrutinizer ignore-unused */ $devid) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $user is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

950
	public function GetCachedData(/** @scrutinizer ignore-unused */ $user, $devid) {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
951
		// not implemented (also nowhere used apparently)
952
		return false;
953
		// exclusive block
954
		if ($this->blockMutex()) {
0 ignored issues
show
Unused Code introduced by
IfNode is not reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

Loading history...
955
			$loopdata = ($this->hasData()) ? $this->getData() : [];
956
			$this->releaseMutex();
957
		}
958
		// end exclusive block
959
		if (isset($loopdata, $loopdata[$devid], $loopdata[$devid][$user])) {
960
			return $loopdata[$devid][$user];
961
		}
962
963
		return false;
964
	}
965
}
966