Repository navigation
A connection-backed writer has to re-stamp rows to keep them alive #539
Description
I maintain WordSocket, which relays awareness from a WebSocket relay, so this is the external-source case the README describes under Client IDs.
Writing that in works well: wp_set_presence() takes an explicit $date_gmt "for a caller relaying awareness on behalf of other clients". Keeping a present client present is the problem.
The problem
Against 00bdb9f, default TTL of 150s:
// The relay announces a client once, as it would on connect.
wp_set_presence( 'postType/post:1', 'wps-conn-1', array( 'src' => 'relay' ), 1 );
// ... 151 seconds later, socket still open, nothing else written:
wp_get_presence( 'postType/post:1' ); // row is in the table, but not in the resultIt is older than the cutoff in functions.php:163, and cron deletes it via :948. Post-dating to hold it open is blocked deliberately, and rightly, by min( $date_gmt, $current ) at :538.
So an exact source must re-stamp on a timer: 27 writes per hour per live client, which is the cost #444 is measuring, reintroduced by a writer that already knows the answer. Raising wp_presence_default_ttl changes the window for every consumer, with the problems in #515.
What would help
Rows from one writer living until they are removed. I prototyped a prefix exemption, since prefixes already identify a producer:
add_filter( 'wp_presence_ttl_exempt_prefixes', fn( $p ) => array_merge( $p, [ 'wps-' ] ) );Reads keep exempt rows regardless of age, cleanup skips them, wp_remove_presence() is still immediate. Default is empty, so with no filter the queries are exactly what they are today. A nullable per-row expiry column would work as well and is arguably cleaner, at the cost of a schema change.
The trade is explicit: an exempt writer owns its liveness, so a missed removal leaves a row until that writer reconciles it. Right for a source with a connection to answer from, wrong for a heartbeat, hence opt in per prefix rather than a TTL change.
Related: the decision in #311 to keep TTL-based removal rested on presence having no precise disconnect signal, only "it has been over 150s since their last heartbeat". A connection-backed writer is precisely the case where that signal exists, which is why exemption is opt in per prefix: it is safe exactly when the writer can tell you the moment someone leaves, and wrong everywhere else.
Prototype
Working on top of 00bdb9f: one filter plus a clause helper shared by the read and cleanup queries, following the $args spread pattern #530 introduced, phpcs clean. Covered: exempt rows survive without a re-stamp, ordinary rows still age out, cron leaves exempt rows and still deletes ordinary ones, removal is immediate, a quoted prefix cannot alter the query, and it composes with #530's $client_prefix.
Happy to open a PR for this. If the answer is that every row should age regardless of source, that is useful too.
Activity
josephfusco commentedon Sep 23, 2026
Hey @jaredrethman, thanks for the report.
You're right on both diagnoses. Post-dating is blocked on purpose, and raising
wp_presence_default_ttlis the wrong fix for one writer. I think going with your second idea, the per-row expiry, not the prefix exemption would be best.On your last question, every row should still age. What changes is who picks the window.
Rows still need an expiry date. #452 ruled out pinning a row past the TTL, #395 closed "rows that never get deleted" as a GDPR problem, and privacy.php tells users "nothing is retained beyond that" based on the TTL. Per-row expiry keeps all of that true, with the writer picking the number. It also drops the trade you flagged in your own proposal, since a missed removal can't leave a row forever if the expiry still bounds it.
#311 still holds for the same reason. The row ages on a clock, just one the writer sets.
Prefix wouldn't work for gutenberg-sync-engines. It has four transports under one
gse-prefix. On WebSocket it's your case exactly. On polling there's no connection to reconcile from, so exempting would be wrong. Same prefix either way, so the prefix can't tell them apart.Maybe consider making the column always set instead of nullable. Fourteen queries filter on
date_gmtand several would lose their index to anOR. If it's always set they all becomeexpires_gmt > %s.One thing to know before you start: this bakes the expiry in at write time, so
wp_presence_default_ttlstops applying to rows already written. For anyone still present it corrects itself within a heartbeat tick, 60s focused and 120s unfocused. Worst case is one old-TTL window for clients that already left.Proposed API:
Seconds relative to
$date_gmt.nullis current behavior. Capped by a filter so nothing sets a 9000 year expiration.Feel free to create a PR!
jaredrethman commentedon Sep 24, 2026
Thanks @josephfusco , that settles it, and the gutenberg-sync-engines point is decisive: one prefix over four transports means the writer, not the prefix, has to name the window.
PR is up: #541. Per-row
expires_gmt, always set so the liveness checks stay on one indexed predicate,wp_set_presence( ..., $expires_in )with the signature you proposed, capped by awp_presence_max_expires_infilter. Two thiings worth mentioning: the readers'$timeoutparameter is now a no-op, and there is no migration step.