Run app-group-lifecycle plugin hooks after the response - #585
Draft
somethingnew2-0 wants to merge 8 commits into
Draft
Run app-group-lifecycle plugin hooks after the response#585somethingnew2-0 wants to merge 8 commits into
somethingnew2-0 wants to merge 8 commits into
Conversation
The lifecycle host commits and rolls back, so it has to act on the very session its caller's `group` is attached to. Reading `db.session` inside worked only because every caller happened to be on the request/CLI scope; making it a parameter puts the choice where the knowledge is. Drops the last `api.extensions.db` reference from the plugin module: with the session threaded through, nothing on this path reaches for the ambient scoped-session proxy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records a lifecycle fire as plain data (ids and scalars) and replays it after the response against a session this module opens and owns, re-loading the group by id so the hook observes committed truth. Nothing calls the deferring path yet -- with no collector bound, `defer_or_invoke_lifecycle_hook` is a direct call to the host. The module docstring covers why this can't reuse `_fan_out.py`; the short version is that its primitive spawns eagerly, and these hooks share a session and are order-dependent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every lifecycle fire on the request path now goes through `defer_or_invoke_lifecycle_hook`, so reconciliation against the external system stops holding the HTTP response open. One fire stays inline, in ModifyGroupType: it fires while the group is still an app group and the app_group row is deleted immediately after, so a replay would re-load a plain OktaGroup and drop the fire without a trace. The drain is registered ahead of the Okta/notification drain. Those Okta tasks are already in flight -- operations spawn them with create_task and only the await was ever deferred -- so this keeps hooks racing the pushes rather than newly trailing them. The visible consequence is that a create/update response no longer carries status the hook writes; the group page picks it up on the next read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Access has no opinion on what a plugin's status values mean -- they are the plugin's vocabulary -- so nothing in the host or the client could tell a converged group from one still waiting on an external system. Matching "pending" client-side would have hardcoded one plugin's strings into the open-source UI. `pending_values` on a status property lets the plugin say it: the group page refreshes while any status sits on one of them. The Google plugin declares SYNC_PENDING, which it uses while waiting on an Okta import or push. Not SYNC_ERROR -- that is terminal until something changes, and polling on it would never stop. Includes the regenerated API client, since the field changes the spec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With the hooks running after the response, a freshly created or edited group reaches the page before its plugin has written any status -- so the page has to poll rather than trust what it was handed. Two signals, both derived from data rather than from how the user got here (two of the three post-submit paths do a full reload, so navigation state would not survive): a status sitting on one of its declared `pending_values`, or a plugin that declares them having written nothing yet. Bounded, so a plugin that never reports does not leave a timer running on an open tab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both docs described these hooks as running inline. The README entry is written for plugin authors -- what changes for them is that a hook can now take as long as the external system needs, sees committed state rather than a snapshot, and has to declare `pending_values` for its status to surface promptly. The sync doc gets the load-bearing connection stated outright: the sync-app-groups cronjob is what makes a lost deferred fire cost latency instead of divergence, so disabling it removes that safety net. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The doc said no frontend tests had been written; there are three suites. It also said nothing about the openapi-client-drift gate, which is what a schema change trips -- I tripped it on this branch with a single optional field. Documents the offline regeneration CI uses, so it works without a dev backend on port 6060. Leaves the `.claude/CLAUDE.md` maintenance watermark alone: bumping it would assert a reconciliation against current main that this change did not do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
somethingnew2-0
force-pushed
the
pcollins/defer-lifecycle-hooks-a41f7c
branch
from
August 12, 2026 05:36
622cf60 to
595e30e
Compare
The ref never worked. react-query re-reads refetchInterval when its options change, and mutating a ref does not re-render -- so the effect set the flag on a page that never asked again, and the group page sat there not polling. Caught by watching the requests against a running backend; no unit test on this file would have. Moving the interval into state makes the options genuinely change. `data` is a dependency so each completed poll re-checks the window and stops on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
App-group-lifecycle plugin hooks no longer run on the request path. A fire is recorded as plain data and replayed from a post-response
BackgroundTask, so a group create, rename, or membership change commits and answers the request without waiting on the plugin's reconciliation against an external system.Motivation
These hooks were the slowest thing left on the request path.
group_createdon a plugin-managed app group does Okta plus whatever the plugin talks to (for the Google reference plugin: a lookup, a push-mapping create, and a wait for Okta to import), all before the user gets a response. Notifications and Okta calls were moved off the response path in #481; lifecycle hooks were the remaining holdout, and the reason they were is that they write, so the detached-snapshot trick notifications use does not apply to them.#577 is what makes this tractable rather than an ecosystem migration. Before it, hooks received the raw
AsyncSessionand plugins used it directly, so deferring meant every operator's plugin had to survive running on a different session. After it, the only session reference on this path is insideinvoke_app_group_lifecycle_hook, and the change is a host concern.Design notes
A deferred fire carries ids, not objects. This deliberately does not reuse
_fan_out.py: its primitive spawns withcreate_taskat defer time, which would put a second coroutine on the live request session; and one request's fires are order-dependent. Replay re-loads the group by id, which is better than a snapshot would have been — a hook sees committed truth, and a group deleted (or converted out of being an app group) in the meantime is skipped rather than reconciled against a row that is no longer there.The drain binds
_session_scope, not just a session.ctx.set_group_descriptiondelegates toModifyGroupDetails, which reaches for the ambientdb.session. If that resolved to a different session than the one the context holds, its writes would land in a transaction nobody commits — silently persisting nothing. There is a test whose only job is to catch that.One fire stays inline.
ModifyGroupTypefiresgroup_deletedwhile the row is still an app group and deletes theapp_grouprow immediately after; a replay would re-load a plainOktaGroupand drop the fire without a trace.Ordering is unchanged. The lifecycle drain is registered ahead of the Okta/notification drain. Those Okta tasks are already in flight — operations spawn them with
create_taskand only the await was ever deferred — so hooks keep racing the pushes rather than newly trailing them. Making them trail is arguably more correct and is a separate question.Losing a fire costs latency, not correctness. A pod killed between response and drain drops it;
sync-app-groupsre-converges on its next run, which is only true because #577 madesync_groupper-group and idempotent.The plugin interface addition, and why it's here
Deferring means a create/update response no longer carries status the hook writes, so the group page has to poll. Gating that on "pending" would have hardcoded the Google plugin's vocabulary into a client every operator shares — Access has no opinion on what a plugin's status values mean.
So
AppGroupLifecyclePluginStatusPropertygainspending_values: the plugin names the values that mean "still converging", and the group page refreshes while a status sits on one. It is additive and defaults to opting out. Landing it now rather than as a follow-up because 2.0 is the window for interface changes, and because the polling it enables also fixes a case that predates this PR — a group parked in the Google plugin's "awaiting Okta import" state never updated without a manual reload.The client also polls when a plugin that declares
pending_valueshas written no status at all, which is what a freshly created group looks like before its first hook lands. Both signals are derived from data rather than navigation state, since two of the three post-submit paths do a full page reload.Notes for reviewers
run_deferred_lifecycle; the accompanying comment explains what breaks without it.openapi-client-driftjob requires. I learned that the hard way — the first push failed on it — so.claude/access-dev-testing.mdnow documents the gate and the offline regeneration CI uses, sincenpm run codegenotherwise wants a dev backend on 6060.refetchIntervalwhen its options change, and a ref mutation does not re-render. It took watching the requests against a running backend to catch, and there is no page-level test infrastructure here that would have.🤖 Generated with Claude Code