Skip to content

Show progress while a server side backup runs (+ fix output buffering bugs) - #60

Open
francescobragagna wants to merge 3 commits into
Samnan:masterfrom
francescobragagna:backup-progress
Open

Show progress while a server side backup runs (+ fix output buffering bugs)#60
francescobragagna wants to merge 3 commits into
Samnan:masterfrom
francescobragagna:backup-progress

Conversation

@francescobragagna

Copy link
Copy Markdown

What this does

A server side backup submits the dialog form into its own iframe and writes the whole dump to a file, so nothing reaches the browser until php is done. The dialog sits unchanged for as long as the backup takes, with no way to tell whether anything is happening — and a proxy timeout in between is indistinguishable from a hang.

This adds progress reporting to the backup dialog, and fixes three bugs in Output found along the way.

Progress reporting

The request is sent in the background and the dialog polls status.php, using the getModuleStatus() hook already present for upload progress — no new endpoint, and the poll does not go through index.php (no MySQL connection per poll).

  • lib/backupstate.php (new) keeps the state of a running backup in tmp/, keyed by a token the dialog generates. Writes are throttled to one every 0.4s and go through a scratch file, so a poll never reads a half written state. Stale files are cleaned up on the next run.
  • modules/backup.php answers the poll, reports only on a backup started by the session that is asking, and releases the session lock immediately.
  • modules/download.php registers the token before releasing the session, counts the selected objects, and updates the state per object and per batch of rows. ignore_user_abort() keeps a backup alive when the browser goes away; a shutdown handler records fatal errors so the dialog reports them instead of polling forever.
  • lib/export/export.php gained an optional per batch progress callback.

Because the state lives in a file, the poll decides when the backup is over, not the request: a connection dropped by a proxy no longer loses the result. Selecting no object is now refused uroducing an empty file.

Bug fixes in Output (first commit, stands alone)

  • ob_start() had no chunk size, so the entire dump w only at the end — memory_limit on a large database,and the request dies with nothing to show. Now flushes every 64 KB.
  • When the target file could not be opened (backup fol buffering was started anyway, so the error messageprinted afterwards was swallowed and written nowhere. The user saw an empty page instead of the reason.
  • end() called gzclose() and then fell through to , and re-ran ob_end_flush() when called a second timeby the destructor, ending a buffer it does not own.

Testing

Manual, against MySQL 8.4 on PHP 8.2, on this branch:

  • 120,000 rows / 20.7 MB over 3 tables: 45 distinct progress updates in 17.5s, dump verified (3 CREATE TABLE, 120,000 INSERT, footer
    present, no php noise in the file).
  • The sys schema, 151 objects across tables, views, procedures, functions and triggers: completes and reports correctly.
  • Concurrency: with the backup running for 12s in one d in 4–26 ms throughout, confirming the session lock isgenuinely released.
  • Error paths: invalid filename format, no objects selion that did not start the backup all behave asexpected.

Notes

  • Only lang/it.php is updated; the new strings fall back to English elsewhere.
  • The dialog no longer reloads itself when the backup e.
  • tmp/ needs to be writable; it falls back to the system temp directory if it is not.

francescobragagna and others added 3 commits September 7, 2026 13:57
Three problems with the file-redirecting mode of the Output class, all of
them visible when running a server side backup:

- ob_start() was called without a chunk size, so the whole dump was held in
  memory and only written to the file at the end. On a large database this
  hits memory_limit and the request dies with nothing to show for it.
  Flushing every 64 KB keeps memory flat and lets the file grow as it goes.

- When the target file could not be opened (backup folder missing or not
  writable) the buffer was started anyway, so the error message the caller
  printed afterwards was swallowed by the callback and written nowhere.
  The user saw an empty page instead of the reason. Do not start buffering
  when there is no file to write to.

- end() called gzclose() and then fell through to fclose() on the same
  handle, and ran ob_end_flush() again when invoked a second time by the
  destructor, ending a buffer it does not own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkPb2smVVLCiBbnTC3UcRZ
A server side backup submits the dialog form into its own iframe and the
whole dump is written to a file, so nothing is sent to the browser until
php is done. The dialog therefore sits unchanged for as long as the backup
takes - minutes on a large database - with no way to tell whether anything
is happening, and a proxy timeout in between simply looks like a hang.

The request is now sent in the background and the dialog polls its progress
through status.php, using the getModuleStatus() hook that is already there
for upload progress:

- lib/backupstate.php records the state of a running backup in tmp/, keyed
  by a token the dialog generates. Updates are throttled to one write every
  0.4s and written through a scratch file, so a poll never reads a half
  written state. Stale files are removed on the next run.

- modules/backup.php answers the poll and only reports on a backup started
  by the session that is asking, releasing the session lock right away so
  polling never queues behind the backup itself.

- modules/download.php registers the token before releasing the session,
  counts the selected objects, and updates the state per object and per
  batch of rows. It answers json when a token is given. ignore_user_abort()
  keeps a backup alive when the browser goes away, and a shutdown handler
  records fatal errors so the dialog reports them instead of polling
  forever.

- lib/export/export.php gained an optional per batch progress callback.

The dialog now shows a progress bar with the current object, the object and
row counters, the bytes written and the elapsed time, and ends with the file
name, its size and the duration. Because the state lives in a file, the poll
- not the request - decides when the backup is over: a dropped connection no
longer loses the result. Selecting no object is refused up front instead of
silently producing an empty file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkPb2smVVLCiBbnTC3UcRZ
Also moves the existing backup strings into $LANGUAGE_JS, as the javascript
__() helper only reads from that array.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GkPb2smVVLCiBbnTC3UcRZ
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant