Skip to content

Run app-group-lifecycle plugin hooks after the response - #585

Draft
somethingnew2-0 wants to merge 8 commits into
brynna/google-group-manager-refactor-cf8e89from
pcollins/defer-lifecycle-hooks-a41f7c
Draft

Run app-group-lifecycle plugin hooks after the response#585
somethingnew2-0 wants to merge 8 commits into
brynna/google-group-manager-refactor-cf8e89from
pcollins/defer-lifecycle-hooks-a41f7c

Conversation

@somethingnew2-0

@somethingnew2-0 somethingnew2-0 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

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.

Stacked on #577. Base is brynna/google-group-manager-refactor-cf8e89; GitHub retargets this to main once #577 merges. It genuinely depends on that work — see below.

Motivation

These hooks were the slowest thing left on the request path. group_created on 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 AsyncSession and 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 inside invoke_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 with create_task at 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_description delegates to ModifyGroupDetails, which reaches for the ambient db.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. ModifyGroupType fires group_deleted while the row is still an app group and deletes the app_group row immediately after; a replay would re-load a plain OktaGroup and 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_task and 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-groups re-converges on its next run, which is only true because #577 made sync_group per-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 AppGroupLifecyclePluginStatusProperty gains pending_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_values has 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

  • The most consequential single line is the scope binding in run_deferred_lifecycle; the accompanying comment explains what breaks without it.
  • The generated API client is regenerated in the same commit as the schema change, which the openapi-client-drift job requires. I learned that the hard way — the first push failed on it — so .claude/access-dev-testing.md now documents the gate and the offline regeneration CI uses, since npm run codegen otherwise wants a dev backend on 6060.
  • The group-page poll is driven from state rather than a ref. The ref version looked right and did nothing: react-query re-reads refetchInterval when 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.
  • Commit-by-commit works well here: commit 3 is the only one that changes behavior.

🤖 Generated with Claude Code

somethingnew2-0 and others added 7 commits August 11, 2026 21:55
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
somethingnew2-0 force-pushed the pcollins/defer-lifecycle-hooks-a41f7c branch from 622cf60 to 595e30e Compare August 12, 2026 05:36
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>
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