PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
← All changes | app/Hooks/Handlers/CleanupHandler.php +93 -62 2.3.2 → 2.4.5 View file →
@@ -33,9 +33,8 @@
33 33 }
34 34
35 35 protected function cleanLiveActivities()
36 36 {
37 - // Delete All Live Activity older than 24 hours
38 37 $oldDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - 86400);
39 38
40 39 Meta::where('key', '_live_activity')
41 40 ->where('object_type', 'ticket_meta')
@@ -44,17 +43,13 @@
44 43 }
45 44
46 45 protected function cleanExpiredAuthChallenges()
47 46 {
48 - // Signup/2FA verification codes are only ever valid for a few minutes (see
49 - // EmailVerificationHandler::CODE_TTL_SECONDS and TwoFaHandler::CODE_TTL_SECONDS);
50 - // an hour is a generous margin so this never races a still-valid code.
47 + // Codes live for minutes (see the CODE_TTL_SECONDS constants), so an hour
48 + // never races a still-valid code.
51 49 $oldDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - HOUR_IN_SECONDS);
52 50
53 - // Challenges issued by earlier versions were written with the query builder,
54 - // which does not stamp created_at, so those rows are NULL and can never match
55 - // the range check. They pre-date this upgrade and are long past their TTL, so
56 - // they are purged alongside the expired ones.
51 + // Older versions wrote these rows without created_at. They are long expired.
57 52 Meta::whereIn('object_type', ['fs_login_hashes', 'fs_2fa'])
58 53 ->where(function ($query) use ($oldDateTime) {
59 54 $query->where('created_at', '<', $oldDateTime)
60 55 ->orWhereNull('created_at');
@@ -65,17 +60,46 @@
65 60 protected function cleanActivityLogs()
66 61 {
67 62 $settings = Helper::getOption('_activity_settings', []);
68 63
69 - if (!$settings && empty($settings['delete_days'])) {
64 + if (empty($settings['delete_days'])) {
70 65 $settings['delete_days'] = 14;
71 66 }
72 67
73 68 $oldDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - ($settings['delete_days'] * 86400));
74 69
75 - Activity::where('created_at', '<', $oldDateTime)->delete();
70 + $this->deleteInBatches(function () use ($oldDateTime) {
71 + return Activity::where('created_at', '<', $oldDateTime);
72 + });
76 73 }
77 74
75 + /**
76 + * Delete the rows $query() matches a batch at a time, so a large backlog
77 + * (say, after shortening the retention) never locks the table in one
78 + * statement. Stops when the time budget is spent; the next run continues.
79 + *
80 + * @param callable $query Returns a fresh model query for the rows to delete.
81 + * @param int $batchSize
82 + * @param int $timeBudget Seconds.
83 + * @return int Rows deleted.
84 + */
85 + public function deleteInBatches(callable $query, $batchSize = 1000, $timeBudget = 20)
86 + {
87 + $started = time();
88 + $deleted = 0;
89 +
90 + do {
91 + $ids = $query()->orderBy('id', 'ASC')->limit($batchSize)->pluck('id')->all();
92 + if (!$ids) {
93 + break;
94 + }
95 +
96 + $deleted += $query()->whereIn('id', $ids)->delete();
97 + } while (count($ids) === $batchSize && (time() - $started) < $timeBudget);
98 +
99 + return $deleted;
100 + }
101 +
78 102 protected function cleanAIActivityLogs()
79 103 {
80 104 if ((!defined('FLUENT_SUPPORT_PRO_DIR_FILE') || !Helper::openAIIntegrationStatus())
81 105 && !Helper::fluentBotIntegrationStatus()) {
@@ -91,9 +115,11 @@
91 115 }
92 116
93 117 $oldDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - ($defaultDays * 86400));
94 118
95 - AIActivityLogs::where('created_at', '<', $oldDateTime)->delete();
119 + $this->deleteInBatches(function () use ($oldDateTime) {
120 + return AIActivityLogs::where('created_at', '<', $oldDateTime);
121 + });
96 122 }
97 123
98 124 protected function cleanInternalNotifications()
99 125 {
@@ -108,21 +134,23 @@
108 134 }
109 135
110 136 $oldDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - (15 * 86400));
111 137
112 - $wpdb->query($wpdb->prepare(
113 - "DELETE notification_users
114 - FROM {$notificationUsersTable} AS notification_users
115 - INNER JOIN {$notificationsTable} AS notifications
116 - ON notification_users.notification_id = notifications.id
117 - WHERE notifications.created_at < %s",
118 - $oldDateTime
119 - ));
138 + // A batch of old notifications at a time, recipients first.
139 + $started = time();
140 + do {
141 + $ids = array_map('intval', $wpdb->get_col($wpdb->prepare(
142 + "SELECT id FROM {$notificationsTable} WHERE created_at < %s ORDER BY id ASC LIMIT 1000",
143 + $oldDateTime
144 + )));
145 + if (!$ids) {
146 + break;
147 + }
120 148
121 - $wpdb->query($wpdb->prepare(
122 - "DELETE FROM {$notificationsTable} WHERE created_at < %s",
123 - $oldDateTime
124 - ));
149 + $idList = implode(',', $ids);
150 + $wpdb->query("DELETE FROM {$notificationUsersTable} WHERE notification_id IN ({$idList})"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integer ids
151 + $wpdb->query("DELETE FROM {$notificationsTable} WHERE id IN ({$idList})"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integer ids
152 + } while (count($ids) === 1000 && (time() - $started) < 20);
125 153 }
126 154
127 155 public function maybeDeleteAttachmentsOnClose($ticket)
128 156 {
@@ -179,41 +207,28 @@
179 207 private function maybeDeleteOldTempFiles()
180 208 {
181 209 $dir = FileSystem::getDir();
182 210
183 - // loop through files in directory
184 211 foreach (glob($dir . '/temp_files/*') as $filename) {
185 - // check if file was created before last 2 hours
186 212 if (time() - filectime($filename) >= 7200) { // 2 hours
187 - wp_delete_file($filename); // delete file
213 + wp_delete_file($filename);
188 214 }
189 215 }
190 216 }
191 217
192 218 /**
193 - * An upload stays 'in-active' until its hash is submitted with a ticket or reply,
194 - * at which point TicketService/ResponseService flip it to 'active' (or 'inline' for
195 - * pasted images). A hash that is never submitted keeps its row forever, and
196 - * UploaderController::checkAttachmentQuota() counts those rows against the
197 - * per-ticket upload limit — so abandoned uploads permanently consume the quota.
219 + * Uploads stay 'in-active' until submitted with a ticket or reply. Abandoned ones
220 + * count against the upload quota, so drop them after the same 2 hours that
221 + * maybeDeleteOldTempFiles() uses for the files.
198 222 *
199 - * maybeDeleteOldTempFiles() already deletes the underlying file at 2 hours, so
200 - * these rows point at files that no longer exist. The same window is used here to
201 - * keep the row lifetime aligned with the file lifetime.
202 - *
203 - * Rows with a NULL created_at are skipped rather than swept in: unlike the auth
204 - * challenges above, they are not necessarily expired. TicketController's bulk reply
205 - * writes clones with the query builder, which does not stamp created_at, and those
206 - * rows are flipped to 'active' moments later in the same request. They also carry a
207 - * ticket_id and no person_id, so they can never match the quota check anyway.
223 + * NULL created_at rows are left alone: bulk reply writes them without a timestamp
224 + * and activates them within the same request.
208 225 */
209 226 protected function cleanAbandonedTempAttachments()
210 227 {
211 228 $oldDateTime = gmdate('Y-m-d H:i:s', current_time('timestamp') - 7200); // 2 hours
212 229
213 - // Bounded per run: purging touches the filesystem (and remote storage) per row,
214 - // so a large first-run backlog drains over several hourly passes instead of
215 - // stalling one cron tick.
230 + // Each row hits the filesystem or remote storage, so drain a backlog over several runs.
216 231 $attachments = Attachment::where('status', 'in-active')
217 232 ->where('created_at', '<', $oldDateTime)
218 233 ->limit(500)
219 234 ->get();
@@ -221,10 +236,8 @@
221 236 if ($attachments->isEmpty()) {
222 237 return;
223 238 }
224 239
225 - // Removes the local temp file, or fires the remote-driver hook when the
226 - // abandoned upload was already pushed to Dropbox/GDrive/etc.
227 240 Attachment::purgeAttachments($attachments);
228 241
229 242 Attachment::whereIn('id', $attachments->pluck('id')->toArray())->delete();
230 243 }
@@ -233,12 +246,10 @@
233 246 function maybeRemoveOldScheuledActionLogs($group_slug = 'fluent-support', $days_old = 7)
234 247 {
235 248 global $wpdb;
236 249
237 - // Get the timestamp for 7 days ago
238 250 $cutoff_date = gmdate('Y-m-d H:i:s', strtotime("-{$days_old} days"));
239 251
240 - // Get the group ID
241 252 $group_id = $wpdb->get_var($wpdb->prepare(
242 253 "SELECT group_id FROM {$wpdb->prefix}actionscheduler_groups WHERE slug = %s",
243 254 $group_slug
244 255 ));
@@ -243,26 +254,46 @@
243 254 $group_slug
244 255 ));
245 256
246 257 if (!$group_id) {
247 - return false; // Group not found
258 + return false;
248 259 }
249 260
250 - // Delete old actions and their associated logs
251 - $deleted = $wpdb->query($wpdb->prepare("
252 - DELETE a, l
253 - FROM {$wpdb->prefix}actionscheduler_actions a
254 - LEFT JOIN {$wpdb->prefix}actionscheduler_logs l ON a.action_id = l.action_id
255 - WHERE a.group_id = %d
256 - AND a.status IN ('complete', 'failed')
257 - AND a.scheduled_date_gmt < %s", $group_id, $cutoff_date));
261 + $actionsTable = $wpdb->prefix . 'actionscheduler_actions';
262 + $logsTable = $wpdb->prefix . 'actionscheduler_logs';
263 + $claimsTable = $wpdb->prefix . 'actionscheduler_claims';
258 264
259 - // Clean up orphaned claims
260 - $wpdb->query("
261 - DELETE c
262 - FROM {$wpdb->prefix}actionscheduler_claims c
263 - LEFT JOIN {$wpdb->prefix}actionscheduler_actions a ON c.claim_id = a.claim_id
264 - WHERE a.action_id IS NULL");
265 + $deleted = 0;
266 + $started = time();
267 +
268 + // A batch at a time, and only our own actions, logs and claims.
269 + do {
270 + $rows = $wpdb->get_results($wpdb->prepare(
271 + "SELECT action_id, claim_id FROM {$actionsTable}
272 + WHERE group_id = %d AND status IN ('complete', 'failed') AND scheduled_date_gmt < %s
273 + ORDER BY action_id ASC LIMIT 1000",
274 + $group_id, $cutoff_date
275 + ));
276 + if (!$rows) {
277 + break;
278 + }
279 +
280 + $actionIds = implode(',', array_map('intval', wp_list_pluck($rows, 'action_id')));
281 + $claimIds = array_filter(array_map('intval', wp_list_pluck($rows, 'claim_id')));
282 +
283 + $wpdb->query("DELETE FROM {$logsTable} WHERE action_id IN ({$actionIds})"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integer ids
284 + $deleted += (int) $wpdb->query("DELETE FROM {$actionsTable} WHERE action_id IN ({$actionIds})"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integer ids
285 +
286 + if ($claimIds) {
287 + // Claims those actions held, once no action uses them.
288 + $claimList = implode(',', array_unique($claimIds));
289 + $wpdb->query(
290 + "DELETE c FROM {$claimsTable} c
291 + LEFT JOIN {$actionsTable} a ON a.claim_id = c.claim_id
292 + WHERE c.claim_id IN ({$claimList}) AND a.action_id IS NULL"
293 + ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- integer ids
294 + }
295 + } while (count($rows) === 1000 && (time() - $started) < 20);
265 296
266 297 return $deleted;
267 298 }
268 299 }