Skip to content

Conversation

@thruflo
Copy link
Contributor

@thruflo thruflo commented Apr 6, 2025

If you add experimental_live_sse=true to your live requests, then the server streams SSEs rather than returning immediately when there's new data.

Requests are closed after 60 seconds, in order to support request collapsing. We also diverge slightly from default SSE behaviour by requiring the client to re-connect on a new URL once the request is closed. Because we require the client to honour our API mechanism of advancing the offset.

This can be worked around using a standard JS EventStream client by closing in the event of error and reconnecting manually. Just a rough sketch for reference:

const url = `http://localhost:3000/v1/shape?table=items&live=true&experimental_live_sse=true&handle=${handle}&offset=${offset}`
const es = new EventSource(url)
es.onerror = () => {
  es.close()
}

Note that the HTTP headers are returned at the start of the response. This means that the current header mechanism to return the next offset isn't valid. At the moment, you get a response like this:

curl -v 'http://localhost:3000/v1/shape?table=items&handle=22194952-1743787019364&live=true&experimental_live_sse=true&offset=5501319691220011464_0'
> GET /v1/shape?table=items&handle=22194952-1743787019364&live=true&experimental_live_sse=true&offset=5501319691220011464_0 HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< date: Sun, 06 Apr 2025 11:12:45 GMT
< cache-control: public, max-age=59
< x-request-id: GDO2V0hAm4_Dp5cAABBj
< electric-server: ElectricSQL/1.0.5
< access-control-allow-origin: *
< access-control-expose-headers: *
< access-control-allow-methods: GET, HEAD, DELETE, OPTIONS
< content-type: text/event-stream
< electric-cursor: 15505980
< etag: "22194952-1743787019364:5501319691220011464_0:5501319691220011464_0"
< electric-handle: 22194952-1743787019364
< electric-up-to-date:
< electric-offset: 5501319691220011464_0
< connection: keep-alive
<
data: {"value":{"id":"9a33ad9e-39a6-4ab4-aebf-4a4c54c8dbc6"},"key":"\"public\".\"items\"/\"9a33ad9e-39a6-4ab4-aebf-4a4c54c8dbc6\"","headers":{"last":true,"relation":["public","items"],"operation":"insert","lsn":"5501319691220014840","op_position":0,"txids":[792]}}

data: {"headers":{"control":"up-to-date","global_last_seen_lsn":"5501319691220014840"}}

The global_last_seen_lsn is the correct lsn to resume from, so you can reconnect with e.g.: offset= 5501319691220014840_0 and it will continue streaming from the correct point.

I've tried to make sure that I keep everything a stream and don't either materialise or encode anything potentially expensive.

@netlify
Copy link

netlify bot commented Apr 6, 2025

Deploy Preview for electric-next ready!

Name Link
🔨 Latest commit 5c3f5fc
🔍 Latest deploy log https://app.netlify.com/projects/electric-next/deploys/685c119f0c48790008b66916
😎 Deploy Preview https://deploy-preview-2544--electric-next.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@thruflo thruflo force-pushed the thruflo/experimental-live-sse branch from b11fc3c to a52a353 Compare April 6, 2025 14:50
@thruflo thruflo marked this pull request as ready for review April 6, 2025 15:19
@thruflo
Copy link
Contributor Author

thruflo commented Apr 6, 2025

As per my previous discord note about diffing, the payload format of:

data: {"value":{"id":"9a33ad9e-39a6-4ab4-aebf-4a4c54c8dbc6"},"key":"\"public\".\"items\"/\"9a33ad9e-39a6-4ab4-aebf-4a4c54c8dbc6\"","headers":{"last":true,"relation":["public","items"],"operation":"insert","lsn":"5501319691220014840","op_position":0,"txids":[792]}}

data: {"headers":{"control":"up-to-date","global_last_seen_lsn":"5501319691220014840"}}

To send the smallest insert is not exactly optimal. Perhaps it gzips down ok over the wire to remove the duplication but it's possible to consider many approaches that could optimise down to an enum code and value, perhaps with a new offset at the end of the stream.

Copy link
Contributor

@icehaunter icehaunter left a comment

Choose a reason for hiding this comment

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

LGTM

@balegas
Copy link
Contributor

balegas commented May 12, 2025

If electric-offset is incorrect, should we use a special value when in SSE mode?

@kevin-dp
Copy link
Contributor

If electric-offset is incorrect, should we use a special value when in SSE mode?

Perhaps even drop it from the headers?

@balegas
Copy link
Contributor

balegas commented May 22, 2025

yeah, but should also be clear that this is an SSE response from the headers (in case it isn't already) because this is a protocol change

@kevin-dp
Copy link
Contributor

yeah, but should also be clear that this is an SSE response from the headers (in case it isn't already) because this is a protocol change

Agree. Could add a flag in the headers that indicates this is an SSE response.

Copy link
Contributor

@kevin-dp kevin-dp left a comment

Choose a reason for hiding this comment

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

On a 409 the returned location header is wrong:

curl -i 'http://localhost:3000/v1/shape?table=foo&offset=26783992_0&handle=103702391-174791678028&live=true&experimental_live_sse=true'
HTTP/1.1 409 Conflict
date: Thu, 22 May 2025 12:32:23 GMT
content-length: 48
vary: accept-encoding
cache-control: public, max-age=60, must-revalidate
x-request-id: GEHZYbPCsu850hcAAAHB
electric-server: ElectricSQL/1.0.5
access-control-allow-origin: *
access-control-expose-headers: *
access-control-allow-methods: GET, HEAD, DELETE, OPTIONS
content-type: application/json; charset=utf-8
etag: "103702391-1747916780281::"
location: /v1/shape?experimental_live_sse=true&handle=103702391-1747916780281&offset=-1&table=foo
electric-handle: 103702391-1747916780281
electric-schema: {"a":{"type":"int4","not_null":true,"pk_index":0},"b":{"type":"int4"}}

data: [{"headers":{"control":"must-refetch"}}]

The location header contains the experimental_live_sse=true param but it does not include the live param, which is an invalid combination. So if i curl the returned location i get a 400:

curl -i 'http://localhost:3000/v1/shape?experimental_live_sse=true&handle=103702391-1747916780281&offset=-1&table=foo'
HTTP/1.1 400 Bad Request
date: Thu, 22 May 2025 12:33:29 GMT
content-length: 107
vary: accept-encoding
cache-control: no-cache
x-request-id: GEHZcSpbuSks7l4AAAIh
electric-server: ElectricSQL/1.0.5
access-control-allow-origin: *
access-control-expose-headers: *
access-control-allow-methods: GET, HEAD, DELETE, OPTIONS
content-type: application/json; charset=utf-8
electric-schema: null

{"message":"Invalid request","errors":{"experimental_live_sse":["can't be true unless live is also true"]}}

@kevin-dp
Copy link
Contributor

Also, the data is always a JSON object, except on a 409, then it is an array:

curl -v "http://localhost:3000/v1/shape?cursor=&handle=62395453-1748244587988&live=true&offset=0_inf&table=electric_test.%22issues+for+181577045_0_3_0.ee54678b91ae8%22&experimental_live_sse=true"

data: {"value":{"id":"c4e5118d-4426-47cf-a509-27b05236d19d","priority":"10","title":"other title"},"key":"\"electric_test\".\"issues for 181577045_0_3_0.ee54678b91ae8\"/\"c4e5118d-4426-47cf-a509-27b05236d19d\"","headers":{"last":true,"relation":["electric_test","issues for 181577045_0_3_0.ee54678b91ae8"],"operation":"insert","lsn":"27322256","op_position":0,"txids":[807]}}

data: {"value":{"id":"4a36620f-2415-4c16-b41f-33cab1571f46","priority":"10","title":"other title2"},"key":"\"electric_test\".\"issues for 181577045_0_3_0.ee54678b91ae8\"/\"4a36620f-2415-4c16-b41f-33cab1571f46\"","headers":{"last":true,"relation":["electric_test","issues for 181577045_0_3_0.ee54678b91ae8"],"operation":"insert","lsn":"27322472","op_position":0,"txids":[808]}}

data: {"headers":{"control":"up-to-date","global_last_seen_lsn":"27322472"}}

Then i delete the shape, and curl again and i get a JSON array:

data: [{"headers":{"control":"must-refetch"}}]

kevin-dp added a commit that referenced this pull request Jun 9, 2025
This is a follow up PR on
#2546 and
#2544. It solves a bug
related with 409s (must refetch) in SSE mode and it replaces the
EventSource browser API by the
[fetch-event-source](https://github.com/Azure/fetch-event-source)
library. I refactored the `ShapeStream.#start` method which was becoming
very big and complex. To this end, i split the logic into helper methods
that handle the different parts that need to happen (building the shape
URL, making the request, parsing the response headers, handling the
response body, etc.).

I had to patch the
[fetch-event-source](https://github.com/Azure/fetch-event-source)
library because it relies on browser-specific features such as
`document` and `window` (cf.
Azure/fetch-event-source#41). But we want our
client to also work in server-side JS environments.

I also had to patch the `fetch-event-source` library because it does not
abort the fetch when you pass an already aborted signal. A complete
description of the bug and the fix can be found here:
Azure/fetch-event-source#98.
@KyleAMathews
Copy link
Contributor

Shipped in #2776

@kevin-dp kevin-dp reopened this Jun 25, 2025
@kevin-dp
Copy link
Contributor

I rebased this in #2856 and merged into main.

@kevin-dp kevin-dp closed this Jun 30, 2025
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.

7 participants