Why a plain search-and-trash loop breaks on large inboxes
GmailApp.search(query) with no offset arguments caps at 500 threads. Call it on a backlog of 4,000 old emails and you silently process only the first 500, then stop. The function returns no error and gives no indication it left anything behind.
The other foot-gun: moveThreadsToTrash accepts an array, but passing more than 100 threads in a single call throws a 'Service invoked too many times' error mid-batch. Splitting the work into pages of 100 sidesteps both limits at once.
The three-argument form GmailApp.search(query, start, max) is the fix. It behaves like SQL LIMIT/OFFSET: start is the index of the first result to return, max is how many to return. The do-while loop increments start by however many threads actually came back, so a partially-filled last page (fewer than batchSize results) naturally ends the loop.