Skip to content

Live updates

A live update keeps a detail view current while other people work on the same record. It is a GraphQL subscription that announces that something changed; the client reacts by re-running its ordinary query.

This page describes the pattern, the one shipped implementation (journals), and the specified-but-not-yet-built one (issue attributes). For subscription mechanics - subscribe, update, trigger - see GraphQL.

The contract

The broadcast carries no content. Every client refetches.

One payload is built per event and read by every subscriber, so anything inside it has already escaped per-user authorization. Keeping content out of it means visibility, ordering and formatting stay on the ordinary query path, which is the only place they are already correct.

What a payload may carry:

Carries Why
Identifiers (journalId, changedAttributes) Lets a client decide whether it cares, without learning a value
actorId Who caused the change - for attribution, and for deciding whether to flash a highlight at the person who already knows
Enough state to re-authorize The subscription needs it to decide who may be told at all

What it must not carry: attribute values, rendered text, names, or anything whose visibility or formatting depends on who is reading.

Three forces make this non-negotiable rather than a preference:

  • Visibility is per-subscriber. estimated_hours needs :view_estimated_hours; a custom field needs CustomField#visible_by?; a related entity has its own visible?. A shared payload cannot express that.
  • Editability moves with the value. A new status changes the allowed transitions; a new tracker changes safe_attribute_names. A payload that updates the label but not the workflow leaves a stale control behind correct text.
  • Delivery is not ordered. Two rapid changes can arrive out of order across cable workers. A value payload can leave permanently wrong data on screen; a refetch always converges.

Authorization

A connection outlives the permissions it was opened with, so every event is re-authorized, not just subscribe:

The three checks every live-update subscription makes
1
2
3
4
5
6
7
def update(entity_id:)
  return NO_UPDATE unless subscriber          # user still exists
  return NO_UPDATE unless entity_visible?(id) # still readable
  return NO_UPDATE unless reaches_subscriber? # this change is theirs to know about

  object
end

subscriber must re-read the user row rather than trust context[:subscriber]:

Why the reload matters
1
2
3
4
5
def subscriber
  @subscriber ||= ::User.current.reload
rescue ActiveRecord::RecordNotFound
  @subscriber = nil
end

When access is lost the subscription simply goes quiet. There is no "you can no longer see this" event; the user finds out on their next action.

Triggering

Triggers run from after_commit on the hottest write paths in the application, and errors raised from after_commit propagate into the request that saved the record. Always rescue and log:

app/models/journal.rb
def broadcast_journals_changed(event)
  return unless journalized_type == "Issue"
  return unless easy_type.nil?
  return if journalized.nil? || journalized.destroyed?

  EasyGraphql::AppSchema.subscriptions.trigger("issue_journals_changed",
                                               { issue_id: journalized_id },
                                               { result: { event:, journal_id: id, ... } })
rescue StandardError => e
  Rails.logger.error("issue_journals_changed broadcast failed for journal #{id}: #{e.class}: #{e.message}")
end

Two rules that follow:

  1. Never broadcast an empty change. A trigger that fires on every save wakes every subscriber for nothing. Compute what actually changed first and return early when the set is empty.
  2. Do not gate on a feature flag. The lookup costs more than the trigger does when nobody is subscribed, and the signal is useful to consumers beyond the view that prompted it.

Bulk operations fan out one trigger per record. That is accepted; it matches what journal creation already does.

Frontend

useLiveUpdates (app/frontend/src/shared/composables/useLiveUpdates.ts) turns a subscription into one debounced refetch. It already handles the burst case and a hidden tab (refresh is deferred and caught up on return), so a feature-level composable stays thin:

app/frontend/src/issue_detail_v2/composables/useJournalsLiveUpdates.ts
1
2
3
4
5
useLiveUpdates<IssueJournalsChangedSubscription, IssueJournalsChangedSubscriptionVariables>({
  subscription: issueJournalsChangedSubscription,
  subscriptionVariables: () => ({ issueId: String(toValue(issueId)) }),
  refreshCallback: () => commentsStore.refresh(),
});

Rules:

  • Subscribe once per view, in the store - never inside a component that repeats. One subscription per open detail, regardless of how many fields render from it.
  • The actor refreshes too. See below - this is the rule, not a per-feature choice.
  • Guard the store against out-of-order responses. useCommentsStore keeps a monotonically increasing requestId and applies only the newest response. Any store a live update refreshes needs the same, because a refetch can land while a later edit is still in flight.
  • Guard open editors. A field whose editor is open must warn rather than reload under the person using it. Since the actor now refreshes on their own change, this is load-bearing, not defensive: someone can edit a second field while the first field's broadcast is still in flight.

The actor refreshes too

No live update suppresses the echo of the client's own change. The person who made the change refetches along with everyone else.

Suppressing it looks like a free saving and is not:

  • The server's answer can differ from what the actor expects. A status change also moves done_ratio and closed_on, rolls priority and dates up to the parent, and changes which transitions are offered next. Suppression leaves the person who just edited the record looking at the least accurate version of it.
  • The payload names a user, not a session. A suppressed feature never updates the same user's second tab or second device.
  • A per-feature exception is worse than either answer. Two live updates behaving differently is a rule nobody can hold in their head, and the reader cannot tell a deliberate choice from an oversight.

What it costs, and why that is acceptable: the actor's own action usually refreshes already, so the broadcast adds one debounced refetch on top. useLiveUpdates coalesces bursts, and the store's request guard keeps the late response from winning.

useLiveUpdates has no opt-out to reach for: it took an optional skipRefresh predicate once, and that option is gone rather than merely unused, so the rule cannot be bypassed one feature at a time. actorId stays in the payload for a different job: deciding not to flash a highlight at the person who caused the change.

Shipped: journal changes

issueJournalsChanged keeps the v2 activity panel current.

Piece Location
Subscription app/api/easy_graphql/subscriptions/issue_journals_changed.rb
Result type app/api/easy_graphql/subscriptions/results/issue_journals_changed_result.rb
Trigger Journal#broadcast_journals_changed
Composable app/frontend/src/issue_detail_v2/composables/useJournalsLiveUpdates.ts

Its delivery rule is worth reading as an example of how much care a content-free payload still needs: it delivers when the subscriber can read the journal now, or could read it before this change. The second half is what clears a comment switched to private off the screens still showing it - those readers get the content-free event and refetch it away.

Specified: issue attribute changes

Status: specified, not yet implemented. The implementation plan lives in docs/plans/live_issue_attributes.md.

issueAttributesChanged keeps the v2 attributes panel current, where fields are edited one at a time.

One subscription, not one per attribute

Subscription surface
type Subscription {
  issueAttributesChanged(issueId: ID!): IssueAttributesChangedPayload!
}

type IssueAttributesChangedResult {
  event: String!                  # "updated"; room for "deleted"
  changedAttributes: [String!]!   # ["status_id", "assigned_to_id", "cf_12"]
  actorId: ID                     # attribution and highlight suppression; null for system writes
  journalId: ID                   # null when the write opened no journal
}

Per-attribute subscriptions were rejected:

  • graphql-ruby matches triggers on exact arguments, so per-attribute means one websocket subscription per attribute per open tab, and that many update authorizations per write.
  • A subset argument (attributes: [String!]) cannot work at all - the trigger would have to enumerate every subset clients might have subscribed with.
  • Attributes move in groups. A status change also moves done_ratio, and Issue#recalculate_attributes_for rolls priority, dates and done ratio up to the parent. Separate topics would emit four messages where one suffices.

changedAttributes is filtered per subscriber, so two subscribers on the same event can legitimately receive different key sets.

Key vocabulary

Keys are journal prop_key values verbatim - the vocabulary JournalDetail already uses and the history panel already renders. Custom fields force a dynamic string (cf_<id>), so there is no enum to be had and no second vocabulary to keep in sync.

Panel field Key Also moves when
Assignee assigned_to_id -
Status status_id Usually with done_ratio; closing cascades to children
Priority priority_id Rolls up to a parent with priority_derived?
Due date due_date Rolls up to a parent with dates_derived?
Start date start_date Reschedules following issues
Progress done_ratio Derived from status, or averaged from children
Tracker tracker_id Changes safe_attribute_names for everyone
Custom field cf_<id> -

Broadcast source

after_commit on: :update on Issue, not on Journal. It puts the whole changed set in one place - including custom fields, which a journal-based hook would have to reconstruct from details - and it does not depend on a journal having been opened, so a save that never called init_journal still broadcasts.

app/models/issue.rb
1
2
3
4
5
6
7
def broadcast_changed_attribute_keys
  columns = saved_changes.keys -
            self.class.journalized_options[:non_journalized_columns] -
            %w[id created_on updated_on lft rgt lock_version]

  columns + Array(changed_custom_fields)
end

Two existing mechanisms carry the weight:

  • journalized_options[:non_journalized_columns] is already the right denylist. It drops easy_last_updated_by_id, easy_status_updated_on, closed_on, root_id and the rest - the columns that move on nearly every save and would otherwise make the broadcast fire constantly for nothing.
  • changed_custom_fields (from acts_as_customizable) is an attr_reader set in save_custom_field_values and cleared only on reload, so it is still readable in after_commit, and it yields ["cf_12"] directly. saved_changes alone misses custom fields entirely, because they live in their own records.

System and derived writes broadcast too. Automations and recalculate_attributes_for go through init_system_journal, and a parent's panel should update from them.

Known gaps

after_commit does not run for writes that bypass callbacks, so these leave a panel stale until the user refreshes:

Path What it changes Notes
Issue#close_children status_id, done_ratio on descendants via update_columns The only such path inside Issue. It writes a journal by hand right after, so issueJournalsChanged does fire for the descendant - only the attribute broadcast is missing. Add an explicit trigger there if closing a parent should move an open subtask's panel.
IssuePriority#destroy, IssueStatus, Tracker, IssueCategory reassignment priority_id, status_id, tracker_id, category_id via update_all Administrative, low frequency. Not worth a trigger unless it proves otherwise.
User/Group destroy assigned_to_id nulled via update_all Same.

None of these are regressions - they are simply outside the hook's reach, and each can be given an explicit trigger later if it matters.

Filtering keys per subscriber

Filtering the key list rather than values keeps the permission surface small and directly testable, and a subscriber who cannot see estimated_hours is not woken when only that changed.

app/api/easy_graphql/subscriptions/issue_attributes_changed.rb
# Mirrors Journal#visible_details: journalized_attribute_names carries the estimated_hours
# permission and the tracker's disabled core fields; custom fields carry their own.
def visible_changed_attributes
  attr_keys, cf_keys = Array(payload[:changed_attributes]).partition { |k| !k.start_with?("cf_") }

  allowed = attribute_names_for_subscriber
  attr_keys.select! { |key| allowed.include?(key) }
  cf_keys.select! { |key| CustomField.find_by(id: key.delete_prefix("cf_"))&.visible_by?(project, subscriber) }

  attr_keys + cf_keys
end

Issue#journalized_attribute_names reads User.current, while subscriber is a separately reloaded object. attribute_names_for_subscriber must bind User.current around that call, or take the user explicitly the way Journal#visible_details(user) does. Getting this wrong is silent - it filters against a stale permission set rather than raising.

Overlap with journals

Every journaled attribute change fires both subscriptions. That is intended: they answer different questions and feed different stores, and they debounce independently.

flowchart TD
  A[Someone changes the status] --> B[Issue saves, journal created in the same transaction]
  B --> C[issue_journals_changed from Journal]
  B --> D[issue_attributes_changed from Issue]
  C --> E[Authorized against the private-notes rule]
  D --> F[Authorized against the visible key set]
  E --> G[Activity panel refetches journals]
  F --> H[Attributes panel refetches attributes]

Reusing issueJournalsChanged for attributes was rejected: its gate is the private-notes rule, which is the wrong question for an attribute, and it would make the attributes panel refetch on every comment posted on a busy issue.

Adding a new live attribute

Attributes backed by issues columns and custom fields are covered by the after_commit hook and need nothing. Association-backed ones do not appear in Issue#saved_changes and need their own trigger point calling the same method:

Attribute Key Trigger point
Watchers watcher_ids Watcher after_commit, scoped to watchable_type == "Issue"
Tags tag_list Tagging callback
Relations relations IssueRelation after_commit, both ends
Spent time spent_hours TimeEntry after_commit, plus ancestors for total_spent_hours
Attachments attachments Already journaled; needs an explicit trigger for the panel

Each costs one line in a callback, one row in the vocabulary table above, and one case in the frontend map. No GraphQL change, no new subscription, no new websocket topic.

Ancestor fan-out deserves care when it arrives: a time entry on a deep subtask should wake every ancestor's panel, which is one trigger per ancestor. Batch it if it shows up in profiling.