Skip to content

Conversation

@thruflo
Copy link
Contributor

@thruflo thruflo commented Jun 23, 2025

When building shape params on the server, for example using sync_render, it's useful to be able to respond to database changes that would cause the shape params to be different.

For example, /sync/projects -> looks up posts for user, syncs where id IN $userProjectIds. Then the user joins another thread. Now, ... at the moment, this can wait until 20s for a long poll to return before the /sync/projects controller code runs to reconstruct the new shape params with the new where clause.

What this PR does is provide a Phoenix.Sync.interrupt(Projects.Project) function that other backend code can call in order to interrupt current long polling requests. This is similar to using the abort controller with the javascript client.

@thruflo thruflo requested a review from magnetised June 23, 2025 08:46
Copy link
Contributor

@magnetised magnetised left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the expected client behaviour if it receives a 200 response when the shape has been invalidated? With the current version it would just retry on the same handle but get a different response since the underlying query had changed. Think that's just weird.


defp fetch_upstream(sync_client, conn, request) do
# Skip interruptibility for initial requests.
defp fetch_upstream(%{shape_definition: nil, client: client}, conn, request) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not the "initial request" version -- that would be when handle is nil. this is a generic version where the shape is not predefined in the server code, so the version that allows for any shape defined by the request params.

What you really want is to ignore requests where live=false as they're never going to be long polling anyway. Is it worth interrupting anything other than a long poll?

cursor =
case Map.get(params, "cursor", nil) do
nil -> nil
val -> String.to_integer(val)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why convert to an integer here just to put it into the request params where it'll be cast to a string anyway

Comment on lines 67 to 69
key = Base.encode64(:crypto.strong_rand_bytes(18))

ShapeRequestRegistry.register_shape(key, shape)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
key = Base.encode64(:crypto.strong_rand_bytes(18))
ShapeRequestRegistry.register_shape(key, shape)
key = ShapeRequestRegistry.register_shape(key, shape)

end

%Client.Fetch.Response{
status: 200,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be a 409 with a must-refetch message?


{:DOWN, ^ref, :process, _pid, :normal} ->
# Task was successful, return the result.
Task.await(task, 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no point using Task.async for this - you just want a linked process which you then wait on a message from (or an interrupt message). You're basically wanting an interruptible Task.await/1 but that doesn't exist and at the point where the task has finished why do Task.await to get the value rather than just send a valid response from the task process.

parent = self()
{:ok, pid} = Task.start_link(fn ->
  send(parent, {:response, Client.Fetch.request(client, request)})
end)
# etc

GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end

def register_shape(key, %ShapeDefinition{} = shape_definition) when is_binary(key) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need to support anything other than a predefined shape here -- my poor naming choices in the client suggest otherwise but ignore that

@thruflo
Copy link
Contributor Author

thruflo commented Jun 23, 2025

Notes on this following chat with @magnetised:

  1. focus only on interrupting live requests that are pending any response
  2. handle the interrupt in the sync_render function rather than the client adapter
  3. avoid responding to the client on interrupt
  4. instead, use the conn to re-make another internal request
  5. with reduced timeout being the remaining timeout on the original long poll, so 20s, wait for 7s, interrupt, reconnect with 13s timeout

Plus an optimisation might be to :

  • add the previous shape defn to the conn when re-making the request
  • look for a prev defn in sync_render, if the defn has changed, immediately return a must-refetch without making a new upstream request

This:

  • makes the whole thing transparent to an http client in the event that the shape def didn't change
  • minimises the requests / latency to refetch in the event that the shape defn has changed

Which seems like the optimal approach, assuming it is actually possible.

@magnetised magnetised force-pushed the thruflo/interruptible branch from 978c9c2 to 614aa22 Compare July 1, 2025 12:31
@magnetised magnetised changed the base branch from main to changeset-shapes July 1, 2025 12:31
@magnetised magnetised marked this pull request as ready for review July 1, 2025 12:34
@magnetised magnetised self-assigned this Jul 31, 2025
Copy link
Contributor Author

@thruflo thruflo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great 👍 added a few comments, mainly about docs.

@thruflo thruflo merged commit 6562564 into changeset-shapes Aug 11, 2025
3 checks passed
magnetised added a commit that referenced this pull request Aug 12, 2025
When building shape params on the server, for example using
`sync_render`, it's useful to be able to respond to database changes
that would cause the shape params to be different.

For example, `/sync/projects` -> looks up posts for user, syncs `where
id IN $userProjectIds`. Then the user joins another thread. Now, ... at
the moment, this can wait until 20s for a long poll to return before the
/sync/projects controller code runs to reconstruct the new shape params
with the new where clause.

What this PR does is provide a
`Phoenix.Sync.interrupt(Projects.Project)` function that other backend
code can call in order to interrupt current long polling requests. This
is similar to using the abort controller with the javascript client.

---------

Co-authored-by: Garry Hill <garry@magnetised.net>
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.

3 participants