Latest commit

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

latch

Description

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Usage

Install

go get github.com/supazonic/latch

Advisory Locks

Use advisory locks to ensure only one process runs a critical section at a time.

import (
"context""database/sql"
_ "github.com/lib/pq""github.com/supazonic/latch/postgres"
)
dsn:=os.Getenv("DATABASE_URL")
db, _:=sql.Open("postgres", dsn)
l:=postgres.New(db, dsn)
deferl.Close()
acquired, err:=l.AcquireLock(ctx, 42)
iferr!=nil {
// handle error
}
ifacquired {
deferl.ReleaseLock(ctx, 42)
// only one process reaches here at a time
}

A PostgreSQL advisory lock belongs to a session, not to a process, and it is released the instant that session ends. If the connection holding it dies, the server hands the key to whoever asks next while this process still believes it holds it — which is the one thing the lock existed to prevent. latch checks the connection behind every held lock and reports the loss:

l:=postgres.New(db, dsn, postgres.WithLockLost(func(keyint64, errerror) {
log.Printf("latch: lost lock %d: %v", key, err)
// another process can hold this key now; stop the work it was guarding
}))

A connection that stops answering counts as a lost lock even though its session may still be alive: giving up a lock you still hold costs progress, whereas keeping one you have already lost is a correctness bug. Close releases every lock still held.

Checks run every DefaultLockPingInterval (15s) and one may go unanswered for DefaultLockPingTimeout (5s) before the lock is given up, so a lost lock is reported within 20 seconds. Tune both with WithLockPing:

// tolerate a database that stalls for up to a minute under loadpostgres.New(db, dsn, postgres.WithLockPing(15*time.Second, time.Minute))

Raise the timeout if your database stalls and the work a lock guards is expensive to redo; lower the interval to hear about a genuinely lost lock sooner, at the cost of more round trips.

Pub/Sub (LISTEN/NOTIFY)

Send and receive events across processes using PostgreSQL channels. Payloads are []byte, so you can send whatever encoding you like.

// Register handlers. Whatever a handler returns is the response for the// round trip — handlers never send it back themselves.handlers:=map[latch.Event]latch.Handler{
"jobs": func(ctx context.Context, payload []byte) ([]byte, error) {
fmt.Println("received:", string(payload))
return []byte("done"), nil
},
}
err:=l.Listen(ctx, handlers) // runs in background until ctx is cancelled// Send a notification and block until the pod running the handler returns.resp, err:=l.Notify(ctx, "jobs", []byte("payload-here"))
iferr!=nil {
// handle error — includes any error the remote handler returned
}
fmt.Println("response:", string(resp)) // "done"

Listen returns immediately and dispatches notifications in a goroutine until the context is cancelled.

New takes both a *sql.DB and the connection string, because LISTEN needs a connection to itself for as long as the subscription lives and database/sql reclaims connections for its pool. Listen and Subscribe therefore open their own, outside the pool; everything else — Notify, locks, the payload store — uses the *sql.DB you pass. Close releases the connection shared by Subscribe; listeners started by Listen end with their context.

Connection Failures

A listener is the one part of latch that no query will tell you about: if its connection dies, it simply stops hearing anything. Lost connections are re-established automatically and every channel re-subscribed, but PostgreSQL does not queue notifications for a listener that is not there, so anything sent during the outage is gone. Register WithEvents to hear about it and resync whatever state the notifications were driving.

l:=postgres.New(db, dsn, postgres.WithEvents(func(s postgres.ConnState, errerror) {
log.Printf("latch listener: %s: %v", s, err)
ifs==postgres.Reconnected {
// notifications sent while the connection was down were not queued
}
}))

A server that stops answering without dropping the connection is detected by ping and the connection replaced, which takes up to 15 seconds; that reports as postgres.Stalled.

Notify is a round trip: it blocks until the handler on the receiving process returns, then delivers that result as the response. Use ctx to bound the wait. If the handler returns an error, Notify returns it on the calling side.

To wait for a single event without sending one, use Subscribe:

sub, err:=l.Subscribe(ctx, "jobs")
iferr!=nil {
// handle error
}
defersub.Close()
payload, err:=sub.Wait(ctx) // blocks until a notification arrives or ctx is done

Large Payloads

Because pg_notify carries text, round-trip payloads are wrapped in a JSON envelope and base64 encoded. PostgreSQL caps a notification at 7999 bytes, which leaves about 5.9 KB for your payload. Past that, Notify fails.

Rather than making callers work around this, hand latch a PayloadStore and it sends oversized payloads by reference: the bytes go to the store, only the key travels over the channel, and the receiving process loads them back before the handler runs. Callers and handlers see the payload either way.

store:=postgres.NewPayloadTable(db)
store.CreateTable(ctx) // or run store.Schema() through your migration tooll:=postgres.New(db, dsn, postgres.WithStore(store))
// 1 MB payload: stored as a row, notification carries an ~80 byte reference.resp, err:=l.Notify(ctx, "jobs", bigPayload)

Payloads of postgres.DefaultInlineLimit (4096) bytes or fewer still go inline with no extra query; tune with postgres.WithInlineLimit. Replies are offloaded by the same rule.

PayloadTable expires rows on a TTL (default 1 hour) instead of deleting them on read, because every process listening on a channel receives the notification and any number of them may resolve the same key. Call DeleteExpired on a timer to reclaim space.

To store payloads somewhere else — object storage, a cache, another table shape — implement the interface:

typePayloadStoreinterface {
Put(ctx context.Context, payload []byte) (string, error)
Get(ctx context.Context, keystring) ([]byte, error)
}

It must be reachable by every process that might receive the notification, so per-process memory will not do.

Bring Your Own Implementation

The latch.Coordinator interface (combining Locker and Signaler) lets you swap in any backend:

typeCoordinatorinterface {
AcquireLock(ctx context.Context, keyint64) (bool, error)
ReleaseLock(ctx context.Context, keyint64) errorNotify(ctx context.Context, eventEvent, payload []byte) ([]byte, error)
Subscribe(ctx context.Context, eventEvent) (Subscription, error)
Listen(ctx context.Context, handlersmap[Event]Handler) error
}

About

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Latest commit

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

latch

Description

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Usage

Install

go get github.com/supazonic/latch

Advisory Locks

Use advisory locks to ensure only one process runs a critical section at a time.

import (
"context""database/sql"
_ "github.com/lib/pq""github.com/supazonic/latch/postgres"
)
dsn:=os.Getenv("DATABASE_URL")
db, _:=sql.Open("postgres", dsn)
l:=postgres.New(db, dsn)
deferl.Close()
acquired, err:=l.AcquireLock(ctx, 42)
iferr!=nil {
// handle error
}
ifacquired {
deferl.ReleaseLock(ctx, 42)
// only one process reaches here at a time
}

A PostgreSQL advisory lock belongs to a session, not to a process, and it is released the instant that session ends. If the connection holding it dies, the server hands the key to whoever asks next while this process still believes it holds it — which is the one thing the lock existed to prevent. latch checks the connection behind every held lock and reports the loss:

l:=postgres.New(db, dsn, postgres.WithLockLost(func(keyint64, errerror) {
log.Printf("latch: lost lock %d: %v", key, err)
// another process can hold this key now; stop the work it was guarding
}))

A connection that stops answering counts as a lost lock even though its session may still be alive: giving up a lock you still hold costs progress, whereas keeping one you have already lost is a correctness bug. Close releases every lock still held.

Checks run every DefaultLockPingInterval (15s) and one may go unanswered for DefaultLockPingTimeout (5s) before the lock is given up, so a lost lock is reported within 20 seconds. Tune both with WithLockPing:

// tolerate a database that stalls for up to a minute under loadpostgres.New(db, dsn, postgres.WithLockPing(15*time.Second, time.Minute))

Raise the timeout if your database stalls and the work a lock guards is expensive to redo; lower the interval to hear about a genuinely lost lock sooner, at the cost of more round trips.

Pub/Sub (LISTEN/NOTIFY)

Send and receive events across processes using PostgreSQL channels. Payloads are []byte, so you can send whatever encoding you like.

// Register handlers. Whatever a handler returns is the response for the// round trip — handlers never send it back themselves.handlers:=map[latch.Event]latch.Handler{
"jobs": func(ctx context.Context, payload []byte) ([]byte, error) {
fmt.Println("received:", string(payload))
return []byte("done"), nil
},
}
err:=l.Listen(ctx, handlers) // runs in background until ctx is cancelled// Send a notification and block until the pod running the handler returns.resp, err:=l.Notify(ctx, "jobs", []byte("payload-here"))
iferr!=nil {
// handle error — includes any error the remote handler returned
}
fmt.Println("response:", string(resp)) // "done"

Listen returns immediately and dispatches notifications in a goroutine until the context is cancelled.

New takes both a *sql.DB and the connection string, because LISTEN needs a connection to itself for as long as the subscription lives and database/sql reclaims connections for its pool. Listen and Subscribe therefore open their own, outside the pool; everything else — Notify, locks, the payload store — uses the *sql.DB you pass. Close releases the connection shared by Subscribe; listeners started by Listen end with their context.

Connection Failures

A listener is the one part of latch that no query will tell you about: if its connection dies, it simply stops hearing anything. Lost connections are re-established automatically and every channel re-subscribed, but PostgreSQL does not queue notifications for a listener that is not there, so anything sent during the outage is gone. Register WithEvents to hear about it and resync whatever state the notifications were driving.

l:=postgres.New(db, dsn, postgres.WithEvents(func(s postgres.ConnState, errerror) {
log.Printf("latch listener: %s: %v", s, err)
ifs==postgres.Reconnected {
// notifications sent while the connection was down were not queued
}
}))

A server that stops answering without dropping the connection is detected by ping and the connection replaced, which takes up to 15 seconds; that reports as postgres.Stalled.

Notify is a round trip: it blocks until the handler on the receiving process returns, then delivers that result as the response. Use ctx to bound the wait. If the handler returns an error, Notify returns it on the calling side.

To wait for a single event without sending one, use Subscribe:

sub, err:=l.Subscribe(ctx, "jobs")
iferr!=nil {
// handle error
}
defersub.Close()
payload, err:=sub.Wait(ctx) // blocks until a notification arrives or ctx is done

Large Payloads

Because pg_notify carries text, round-trip payloads are wrapped in a JSON envelope and base64 encoded. PostgreSQL caps a notification at 7999 bytes, which leaves about 5.9 KB for your payload. Past that, Notify fails.

Rather than making callers work around this, hand latch a PayloadStore and it sends oversized payloads by reference: the bytes go to the store, only the key travels over the channel, and the receiving process loads them back before the handler runs. Callers and handlers see the payload either way.

store:=postgres.NewPayloadTable(db)
store.CreateTable(ctx) // or run store.Schema() through your migration tooll:=postgres.New(db, dsn, postgres.WithStore(store))
// 1 MB payload: stored as a row, notification carries an ~80 byte reference.resp, err:=l.Notify(ctx, "jobs", bigPayload)

Payloads of postgres.DefaultInlineLimit (4096) bytes or fewer still go inline with no extra query; tune with postgres.WithInlineLimit. Replies are offloaded by the same rule.

PayloadTable expires rows on a TTL (default 1 hour) instead of deleting them on read, because every process listening on a channel receives the notification and any number of them may resolve the same key. Call DeleteExpired on a timer to reclaim space.

To store payloads somewhere else — object storage, a cache, another table shape — implement the interface:

typePayloadStoreinterface {
Put(ctx context.Context, payload []byte) (string, error)
Get(ctx context.Context, keystring) ([]byte, error)
}

It must be reachable by every process that might receive the notification, so per-process memory will not do.

Bring Your Own Implementation

The latch.Coordinator interface (combining Locker and Signaler) lets you swap in any backend:

typeCoordinatorinterface {
AcquireLock(ctx context.Context, keyint64) (bool, error)
ReleaseLock(ctx context.Context, keyint64) errorNotify(ctx context.Context, eventEvent, payload []byte) ([]byte, error)
Subscribe(ctx context.Context, eventEvent) (Subscription, error)
Listen(ctx context.Context, handlersmap[Event]Handler) error
}

About

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

latch

Description

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Usage

Install

go get github.com/supazonic/latch

Advisory Locks

Use advisory locks to ensure only one process runs a critical section at a time.

import (
"context""database/sql"
_ "github.com/lib/pq""github.com/supazonic/latch/postgres"
)
dsn:=os.Getenv("DATABASE_URL")
db, _:=sql.Open("postgres", dsn)
l:=postgres.New(db, dsn)
deferl.Close()
acquired, err:=l.AcquireLock(ctx, 42)
iferr!=nil {
// handle error
}
ifacquired {
deferl.ReleaseLock(ctx, 42)
// only one process reaches here at a time
}

A PostgreSQL advisory lock belongs to a session, not to a process, and it is released the instant that session ends. If the connection holding it dies, the server hands the key to whoever asks next while this process still believes it holds it — which is the one thing the lock existed to prevent. latch checks the connection behind every held lock and reports the loss:

l:=postgres.New(db, dsn, postgres.WithLockLost(func(keyint64, errerror) {
log.Printf("latch: lost lock %d: %v", key, err)
// another process can hold this key now; stop the work it was guarding
}))

A connection that stops answering counts as a lost lock even though its session may still be alive: giving up a lock you still hold costs progress, whereas keeping one you have already lost is a correctness bug. Close releases every lock still held.

Checks run every DefaultLockPingInterval (15s) and one may go unanswered for DefaultLockPingTimeout (5s) before the lock is given up, so a lost lock is reported within 20 seconds. Tune both with WithLockPing:

// tolerate a database that stalls for up to a minute under loadpostgres.New(db, dsn, postgres.WithLockPing(15*time.Second, time.Minute))

Raise the timeout if your database stalls and the work a lock guards is expensive to redo; lower the interval to hear about a genuinely lost lock sooner, at the cost of more round trips.

Pub/Sub (LISTEN/NOTIFY)

Send and receive events across processes using PostgreSQL channels. Payloads are []byte, so you can send whatever encoding you like.

// Register handlers. Whatever a handler returns is the response for the// round trip — handlers never send it back themselves.handlers:=map[latch.Event]latch.Handler{
"jobs": func(ctx context.Context, payload []byte) ([]byte, error) {
fmt.Println("received:", string(payload))
return []byte("done"), nil
},
}
err:=l.Listen(ctx, handlers) // runs in background until ctx is cancelled// Send a notification and block until the pod running the handler returns.resp, err:=l.Notify(ctx, "jobs", []byte("payload-here"))
iferr!=nil {
// handle error — includes any error the remote handler returned
}
fmt.Println("response:", string(resp)) // "done"

Listen returns immediately and dispatches notifications in a goroutine until the context is cancelled.

New takes both a *sql.DB and the connection string, because LISTEN needs a connection to itself for as long as the subscription lives and database/sql reclaims connections for its pool. Listen and Subscribe therefore open their own, outside the pool; everything else — Notify, locks, the payload store — uses the *sql.DB you pass. Close releases the connection shared by Subscribe; listeners started by Listen end with their context.

Connection Failures

A listener is the one part of latch that no query will tell you about: if its connection dies, it simply stops hearing anything. Lost connections are re-established automatically and every channel re-subscribed, but PostgreSQL does not queue notifications for a listener that is not there, so anything sent during the outage is gone. Register WithEvents to hear about it and resync whatever state the notifications were driving.

l:=postgres.New(db, dsn, postgres.WithEvents(func(s postgres.ConnState, errerror) {
log.Printf("latch listener: %s: %v", s, err)
ifs==postgres.Reconnected {
// notifications sent while the connection was down were not queued
}
}))

A server that stops answering without dropping the connection is detected by ping and the connection replaced, which takes up to 15 seconds; that reports as postgres.Stalled.

Notify is a round trip: it blocks until the handler on the receiving process returns, then delivers that result as the response. Use ctx to bound the wait. If the handler returns an error, Notify returns it on the calling side.

To wait for a single event without sending one, use Subscribe:

sub, err:=l.Subscribe(ctx, "jobs")
iferr!=nil {
// handle error
}
defersub.Close()
payload, err:=sub.Wait(ctx) // blocks until a notification arrives or ctx is done

Large Payloads

Because pg_notify carries text, round-trip payloads are wrapped in a JSON envelope and base64 encoded. PostgreSQL caps a notification at 7999 bytes, which leaves about 5.9 KB for your payload. Past that, Notify fails.

Rather than making callers work around this, hand latch a PayloadStore and it sends oversized payloads by reference: the bytes go to the store, only the key travels over the channel, and the receiving process loads them back before the handler runs. Callers and handlers see the payload either way.

store:=postgres.NewPayloadTable(db)
store.CreateTable(ctx) // or run store.Schema() through your migration tooll:=postgres.New(db, dsn, postgres.WithStore(store))
// 1 MB payload: stored as a row, notification carries an ~80 byte reference.resp, err:=l.Notify(ctx, "jobs", bigPayload)

Payloads of postgres.DefaultInlineLimit (4096) bytes or fewer still go inline with no extra query; tune with postgres.WithInlineLimit. Replies are offloaded by the same rule.

PayloadTable expires rows on a TTL (default 1 hour) instead of deleting them on read, because every process listening on a channel receives the notification and any number of them may resolve the same key. Call DeleteExpired on a timer to reclaim space.

To store payloads somewhere else — object storage, a cache, another table shape — implement the interface:

typePayloadStoreinterface {
Put(ctx context.Context, payload []byte) (string, error)
Get(ctx context.Context, keystring) ([]byte, error)
}

It must be reachable by every process that might receive the notification, so per-process memory will not do.

Bring Your Own Implementation

The latch.Coordinator interface (combining Locker and Signaler) lets you swap in any backend:

typeCoordinatorinterface {
AcquireLock(ctx context.Context, keyint64) (bool, error)
ReleaseLock(ctx context.Context, keyint64) errorNotify(ctx context.Context, eventEvent, payload []byte) ([]byte, error)
Subscribe(ctx context.Context, eventEvent) (Subscription, error)
Listen(ctx context.Context, handlersmap[Event]Handler) error
}

About

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

latch

Description

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Usage

Install

go get github.com/supazonic/latch

Advisory Locks

Use advisory locks to ensure only one process runs a critical section at a time.

import (
"context""database/sql"
_ "github.com/lib/pq""github.com/supazonic/latch/postgres"
)
dsn:=os.Getenv("DATABASE_URL")
db, _:=sql.Open("postgres", dsn)
l:=postgres.New(db, dsn)
deferl.Close()
acquired, err:=l.AcquireLock(ctx, 42)
iferr!=nil {
// handle error
}
ifacquired {
deferl.ReleaseLock(ctx, 42)
// only one process reaches here at a time
}

A PostgreSQL advisory lock belongs to a session, not to a process, and it is released the instant that session ends. If the connection holding it dies, the server hands the key to whoever asks next while this process still believes it holds it — which is the one thing the lock existed to prevent. latch checks the connection behind every held lock and reports the loss:

l:=postgres.New(db, dsn, postgres.WithLockLost(func(keyint64, errerror) {
log.Printf("latch: lost lock %d: %v", key, err)
// another process can hold this key now; stop the work it was guarding
}))

A connection that stops answering counts as a lost lock even though its session may still be alive: giving up a lock you still hold costs progress, whereas keeping one you have already lost is a correctness bug. Close releases every lock still held.

Checks run every DefaultLockPingInterval (15s) and one may go unanswered for DefaultLockPingTimeout (5s) before the lock is given up, so a lost lock is reported within 20 seconds. Tune both with WithLockPing:

// tolerate a database that stalls for up to a minute under loadpostgres.New(db, dsn, postgres.WithLockPing(15*time.Second, time.Minute))

Raise the timeout if your database stalls and the work a lock guards is expensive to redo; lower the interval to hear about a genuinely lost lock sooner, at the cost of more round trips.

Pub/Sub (LISTEN/NOTIFY)

Send and receive events across processes using PostgreSQL channels. Payloads are []byte, so you can send whatever encoding you like.

// Register handlers. Whatever a handler returns is the response for the// round trip — handlers never send it back themselves.handlers:=map[latch.Event]latch.Handler{
"jobs": func(ctx context.Context, payload []byte) ([]byte, error) {
fmt.Println("received:", string(payload))
return []byte("done"), nil
},
}
err:=l.Listen(ctx, handlers) // runs in background until ctx is cancelled// Send a notification and block until the pod running the handler returns.resp, err:=l.Notify(ctx, "jobs", []byte("payload-here"))
iferr!=nil {
// handle error — includes any error the remote handler returned
}
fmt.Println("response:", string(resp)) // "done"

Listen returns immediately and dispatches notifications in a goroutine until the context is cancelled.

New takes both a *sql.DB and the connection string, because LISTEN needs a connection to itself for as long as the subscription lives and database/sql reclaims connections for its pool. Listen and Subscribe therefore open their own, outside the pool; everything else — Notify, locks, the payload store — uses the *sql.DB you pass. Close releases the connection shared by Subscribe; listeners started by Listen end with their context.

Connection Failures

A listener is the one part of latch that no query will tell you about: if its connection dies, it simply stops hearing anything. Lost connections are re-established automatically and every channel re-subscribed, but PostgreSQL does not queue notifications for a listener that is not there, so anything sent during the outage is gone. Register WithEvents to hear about it and resync whatever state the notifications were driving.

l:=postgres.New(db, dsn, postgres.WithEvents(func(s postgres.ConnState, errerror) {
log.Printf("latch listener: %s: %v", s, err)
ifs==postgres.Reconnected {
// notifications sent while the connection was down were not queued
}
}))

A server that stops answering without dropping the connection is detected by ping and the connection replaced, which takes up to 15 seconds; that reports as postgres.Stalled.

Notify is a round trip: it blocks until the handler on the receiving process returns, then delivers that result as the response. Use ctx to bound the wait. If the handler returns an error, Notify returns it on the calling side.

To wait for a single event without sending one, use Subscribe:

sub, err:=l.Subscribe(ctx, "jobs")
iferr!=nil {
// handle error
}
defersub.Close()
payload, err:=sub.Wait(ctx) // blocks until a notification arrives or ctx is done

Large Payloads

Because pg_notify carries text, round-trip payloads are wrapped in a JSON envelope and base64 encoded. PostgreSQL caps a notification at 7999 bytes, which leaves about 5.9 KB for your payload. Past that, Notify fails.

Rather than making callers work around this, hand latch a PayloadStore and it sends oversized payloads by reference: the bytes go to the store, only the key travels over the channel, and the receiving process loads them back before the handler runs. Callers and handlers see the payload either way.

store:=postgres.NewPayloadTable(db)
store.CreateTable(ctx) // or run store.Schema() through your migration tooll:=postgres.New(db, dsn, postgres.WithStore(store))
// 1 MB payload: stored as a row, notification carries an ~80 byte reference.resp, err:=l.Notify(ctx, "jobs", bigPayload)

Payloads of postgres.DefaultInlineLimit (4096) bytes or fewer still go inline with no extra query; tune with postgres.WithInlineLimit. Replies are offloaded by the same rule.

PayloadTable expires rows on a TTL (default 1 hour) instead of deleting them on read, because every process listening on a channel receives the notification and any number of them may resolve the same key. Call DeleteExpired on a timer to reclaim space.

To store payloads somewhere else — object storage, a cache, another table shape — implement the interface:

typePayloadStoreinterface {
Put(ctx context.Context, payload []byte) (string, error)
Get(ctx context.Context, keystring) ([]byte, error)
}

It must be reachable by every process that might receive the notification, so per-process memory will not do.

Bring Your Own Implementation

The latch.Coordinator interface (combining Locker and Signaler) lets you swap in any backend:

typeCoordinatorinterface {
AcquireLock(ctx context.Context, keyint64) (bool, error)
ReleaseLock(ctx context.Context, keyint64) errorNotify(ctx context.Context, eventEvent, payload []byte) ([]byte, error)
Subscribe(ctx context.Context, eventEvent) (Subscription, error)
Listen(ctx context.Context, handlersmap[Event]Handler) error
}

About

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Latest commit

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

latch

Description

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Usage

Install

go get github.com/supazonic/latch

Advisory Locks

Use advisory locks to ensure only one process runs a critical section at a time.

import (
"context""database/sql"
_ "github.com/lib/pq""github.com/supazonic/latch/postgres"
)
dsn:=os.Getenv("DATABASE_URL")
db, _:=sql.Open("postgres", dsn)
l:=postgres.New(db, dsn)
deferl.Close()
acquired, err:=l.AcquireLock(ctx, 42)
iferr!=nil {
// handle error
}
ifacquired {
deferl.ReleaseLock(ctx, 42)
// only one process reaches here at a time
}

A PostgreSQL advisory lock belongs to a session, not to a process, and it is released the instant that session ends. If the connection holding it dies, the server hands the key to whoever asks next while this process still believes it holds it — which is the one thing the lock existed to prevent. latch checks the connection behind every held lock and reports the loss:

l:=postgres.New(db, dsn, postgres.WithLockLost(func(keyint64, errerror) {
log.Printf("latch: lost lock %d: %v", key, err)
// another process can hold this key now; stop the work it was guarding
}))

A connection that stops answering counts as a lost lock even though its session may still be alive: giving up a lock you still hold costs progress, whereas keeping one you have already lost is a correctness bug. Close releases every lock still held.

Checks run every DefaultLockPingInterval (15s) and one may go unanswered for DefaultLockPingTimeout (5s) before the lock is given up, so a lost lock is reported within 20 seconds. Tune both with WithLockPing:

// tolerate a database that stalls for up to a minute under loadpostgres.New(db, dsn, postgres.WithLockPing(15*time.Second, time.Minute))

Raise the timeout if your database stalls and the work a lock guards is expensive to redo; lower the interval to hear about a genuinely lost lock sooner, at the cost of more round trips.

Pub/Sub (LISTEN/NOTIFY)

Send and receive events across processes using PostgreSQL channels. Payloads are []byte, so you can send whatever encoding you like.

// Register handlers. Whatever a handler returns is the response for the// round trip — handlers never send it back themselves.handlers:=map[latch.Event]latch.Handler{
"jobs": func(ctx context.Context, payload []byte) ([]byte, error) {
fmt.Println("received:", string(payload))
return []byte("done"), nil
},
}
err:=l.Listen(ctx, handlers) // runs in background until ctx is cancelled// Send a notification and block until the pod running the handler returns.resp, err:=l.Notify(ctx, "jobs", []byte("payload-here"))
iferr!=nil {
// handle error — includes any error the remote handler returned
}
fmt.Println("response:", string(resp)) // "done"

Listen returns immediately and dispatches notifications in a goroutine until the context is cancelled.

New takes both a *sql.DB and the connection string, because LISTEN needs a connection to itself for as long as the subscription lives and database/sql reclaims connections for its pool. Listen and Subscribe therefore open their own, outside the pool; everything else — Notify, locks, the payload store — uses the *sql.DB you pass. Close releases the connection shared by Subscribe; listeners started by Listen end with their context.

Connection Failures

A listener is the one part of latch that no query will tell you about: if its connection dies, it simply stops hearing anything. Lost connections are re-established automatically and every channel re-subscribed, but PostgreSQL does not queue notifications for a listener that is not there, so anything sent during the outage is gone. Register WithEvents to hear about it and resync whatever state the notifications were driving.

l:=postgres.New(db, dsn, postgres.WithEvents(func(s postgres.ConnState, errerror) {
log.Printf("latch listener: %s: %v", s, err)
ifs==postgres.Reconnected {
// notifications sent while the connection was down were not queued
}
}))

A server that stops answering without dropping the connection is detected by ping and the connection replaced, which takes up to 15 seconds; that reports as postgres.Stalled.

Notify is a round trip: it blocks until the handler on the receiving process returns, then delivers that result as the response. Use ctx to bound the wait. If the handler returns an error, Notify returns it on the calling side.

To wait for a single event without sending one, use Subscribe:

sub, err:=l.Subscribe(ctx, "jobs")
iferr!=nil {
// handle error
}
defersub.Close()
payload, err:=sub.Wait(ctx) // blocks until a notification arrives or ctx is done

Large Payloads

Because pg_notify carries text, round-trip payloads are wrapped in a JSON envelope and base64 encoded. PostgreSQL caps a notification at 7999 bytes, which leaves about 5.9 KB for your payload. Past that, Notify fails.

Rather than making callers work around this, hand latch a PayloadStore and it sends oversized payloads by reference: the bytes go to the store, only the key travels over the channel, and the receiving process loads them back before the handler runs. Callers and handlers see the payload either way.

store:=postgres.NewPayloadTable(db)
store.CreateTable(ctx) // or run store.Schema() through your migration tooll:=postgres.New(db, dsn, postgres.WithStore(store))
// 1 MB payload: stored as a row, notification carries an ~80 byte reference.resp, err:=l.Notify(ctx, "jobs", bigPayload)

Payloads of postgres.DefaultInlineLimit (4096) bytes or fewer still go inline with no extra query; tune with postgres.WithInlineLimit. Replies are offloaded by the same rule.

PayloadTable expires rows on a TTL (default 1 hour) instead of deleting them on read, because every process listening on a channel receives the notification and any number of them may resolve the same key. Call DeleteExpired on a timer to reclaim space.

To store payloads somewhere else — object storage, a cache, another table shape — implement the interface:

typePayloadStoreinterface {
Put(ctx context.Context, payload []byte) (string, error)
Get(ctx context.Context, keystring) ([]byte, error)
}

It must be reachable by every process that might receive the notification, so per-process memory will not do.

Bring Your Own Implementation

The latch.Coordinator interface (combining Locker and Signaler) lets you swap in any backend:

typeCoordinatorinterface {
AcquireLock(ctx context.Context, keyint64) (bool, error)
ReleaseLock(ctx context.Context, keyint64) errorNotify(ctx context.Context, eventEvent, payload []byte) ([]byte, error)
Subscribe(ctx context.Context, eventEvent) (Subscription, error)
Listen(ctx context.Context, handlersmap[Event]Handler) error
}

About

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

latch

Description

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Usage

Install

go get github.com/supazonic/latch

Advisory Locks

Use advisory locks to ensure only one process runs a critical section at a time.

import (
"context""database/sql"
_ "github.com/lib/pq""github.com/supazonic/latch/postgres"
)
dsn:=os.Getenv("DATABASE_URL")
db, _:=sql.Open("postgres", dsn)
l:=postgres.New(db, dsn)
deferl.Close()
acquired, err:=l.AcquireLock(ctx, 42)
iferr!=nil {
// handle error
}
ifacquired {
deferl.ReleaseLock(ctx, 42)
// only one process reaches here at a time
}

A PostgreSQL advisory lock belongs to a session, not to a process, and it is released the instant that session ends. If the connection holding it dies, the server hands the key to whoever asks next while this process still believes it holds it — which is the one thing the lock existed to prevent. latch checks the connection behind every held lock and reports the loss:

l:=postgres.New(db, dsn, postgres.WithLockLost(func(keyint64, errerror) {
log.Printf("latch: lost lock %d: %v", key, err)
// another process can hold this key now; stop the work it was guarding
}))

A connection that stops answering counts as a lost lock even though its session may still be alive: giving up a lock you still hold costs progress, whereas keeping one you have already lost is a correctness bug. Close releases every lock still held.

Checks run every DefaultLockPingInterval (15s) and one may go unanswered for DefaultLockPingTimeout (5s) before the lock is given up, so a lost lock is reported within 20 seconds. Tune both with WithLockPing:

// tolerate a database that stalls for up to a minute under loadpostgres.New(db, dsn, postgres.WithLockPing(15*time.Second, time.Minute))

Raise the timeout if your database stalls and the work a lock guards is expensive to redo; lower the interval to hear about a genuinely lost lock sooner, at the cost of more round trips.

Pub/Sub (LISTEN/NOTIFY)

Send and receive events across processes using PostgreSQL channels. Payloads are []byte, so you can send whatever encoding you like.

// Register handlers. Whatever a handler returns is the response for the// round trip — handlers never send it back themselves.handlers:=map[latch.Event]latch.Handler{
"jobs": func(ctx context.Context, payload []byte) ([]byte, error) {
fmt.Println("received:", string(payload))
return []byte("done"), nil
},
}
err:=l.Listen(ctx, handlers) // runs in background until ctx is cancelled// Send a notification and block until the pod running the handler returns.resp, err:=l.Notify(ctx, "jobs", []byte("payload-here"))
iferr!=nil {
// handle error — includes any error the remote handler returned
}
fmt.Println("response:", string(resp)) // "done"

Listen returns immediately and dispatches notifications in a goroutine until the context is cancelled.

New takes both a *sql.DB and the connection string, because LISTEN needs a connection to itself for as long as the subscription lives and database/sql reclaims connections for its pool. Listen and Subscribe therefore open their own, outside the pool; everything else — Notify, locks, the payload store — uses the *sql.DB you pass. Close releases the connection shared by Subscribe; listeners started by Listen end with their context.

Connection Failures

A listener is the one part of latch that no query will tell you about: if its connection dies, it simply stops hearing anything. Lost connections are re-established automatically and every channel re-subscribed, but PostgreSQL does not queue notifications for a listener that is not there, so anything sent during the outage is gone. Register WithEvents to hear about it and resync whatever state the notifications were driving.

l:=postgres.New(db, dsn, postgres.WithEvents(func(s postgres.ConnState, errerror) {
log.Printf("latch listener: %s: %v", s, err)
ifs==postgres.Reconnected {
// notifications sent while the connection was down were not queued
}
}))

A server that stops answering without dropping the connection is detected by ping and the connection replaced, which takes up to 15 seconds; that reports as postgres.Stalled.

Notify is a round trip: it blocks until the handler on the receiving process returns, then delivers that result as the response. Use ctx to bound the wait. If the handler returns an error, Notify returns it on the calling side.

To wait for a single event without sending one, use Subscribe:

sub, err:=l.Subscribe(ctx, "jobs")
iferr!=nil {
// handle error
}
defersub.Close()
payload, err:=sub.Wait(ctx) // blocks until a notification arrives or ctx is done

Large Payloads

Because pg_notify carries text, round-trip payloads are wrapped in a JSON envelope and base64 encoded. PostgreSQL caps a notification at 7999 bytes, which leaves about 5.9 KB for your payload. Past that, Notify fails.

Rather than making callers work around this, hand latch a PayloadStore and it sends oversized payloads by reference: the bytes go to the store, only the key travels over the channel, and the receiving process loads them back before the handler runs. Callers and handlers see the payload either way.

store:=postgres.NewPayloadTable(db)
store.CreateTable(ctx) // or run store.Schema() through your migration tooll:=postgres.New(db, dsn, postgres.WithStore(store))
// 1 MB payload: stored as a row, notification carries an ~80 byte reference.resp, err:=l.Notify(ctx, "jobs", bigPayload)

Payloads of postgres.DefaultInlineLimit (4096) bytes or fewer still go inline with no extra query; tune with postgres.WithInlineLimit. Replies are offloaded by the same rule.

PayloadTable expires rows on a TTL (default 1 hour) instead of deleting them on read, because every process listening on a channel receives the notification and any number of them may resolve the same key. Call DeleteExpired on a timer to reclaim space.

To store payloads somewhere else — object storage, a cache, another table shape — implement the interface:

typePayloadStoreinterface {
Put(ctx context.Context, payload []byte) (string, error)
Get(ctx context.Context, keystring) ([]byte, error)
}

It must be reachable by every process that might receive the notification, so per-process memory will not do.

Bring Your Own Implementation

The latch.Coordinator interface (combining Locker and Signaler) lets you swap in any backend:

typeCoordinatorinterface {
AcquireLock(ctx context.Context, keyint64) (bool, error)
ReleaseLock(ctx context.Context, keyint64) errorNotify(ctx context.Context, eventEvent, payload []byte) ([]byte, error)
Subscribe(ctx context.Context, eventEvent) (Subscription, error)
Listen(ctx context.Context, handlersmap[Event]Handler) error
}

About

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

latch

Description

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Usage

Install

go get github.com/supazonic/latch

Advisory Locks

Use advisory locks to ensure only one process runs a critical section at a time.

import (
"context""database/sql"
_ "github.com/lib/pq""github.com/supazonic/latch/postgres"
)
dsn:=os.Getenv("DATABASE_URL")
db, _:=sql.Open("postgres", dsn)
l:=postgres.New(db, dsn)
deferl.Close()
acquired, err:=l.AcquireLock(ctx, 42)
iferr!=nil {
// handle error
}
ifacquired {
deferl.ReleaseLock(ctx, 42)
// only one process reaches here at a time
}

A PostgreSQL advisory lock belongs to a session, not to a process, and it is released the instant that session ends. If the connection holding it dies, the server hands the key to whoever asks next while this process still believes it holds it — which is the one thing the lock existed to prevent. latch checks the connection behind every held lock and reports the loss:

l:=postgres.New(db, dsn, postgres.WithLockLost(func(keyint64, errerror) {
log.Printf("latch: lost lock %d: %v", key, err)
// another process can hold this key now; stop the work it was guarding
}))

A connection that stops answering counts as a lost lock even though its session may still be alive: giving up a lock you still hold costs progress, whereas keeping one you have already lost is a correctness bug. Close releases every lock still held.

Checks run every DefaultLockPingInterval (15s) and one may go unanswered for DefaultLockPingTimeout (5s) before the lock is given up, so a lost lock is reported within 20 seconds. Tune both with WithLockPing:

// tolerate a database that stalls for up to a minute under loadpostgres.New(db, dsn, postgres.WithLockPing(15*time.Second, time.Minute))

Raise the timeout if your database stalls and the work a lock guards is expensive to redo; lower the interval to hear about a genuinely lost lock sooner, at the cost of more round trips.

Pub/Sub (LISTEN/NOTIFY)

Send and receive events across processes using PostgreSQL channels. Payloads are []byte, so you can send whatever encoding you like.

// Register handlers. Whatever a handler returns is the response for the// round trip — handlers never send it back themselves.handlers:=map[latch.Event]latch.Handler{
"jobs": func(ctx context.Context, payload []byte) ([]byte, error) {
fmt.Println("received:", string(payload))
return []byte("done"), nil
},
}
err:=l.Listen(ctx, handlers) // runs in background until ctx is cancelled// Send a notification and block until the pod running the handler returns.resp, err:=l.Notify(ctx, "jobs", []byte("payload-here"))
iferr!=nil {
// handle error — includes any error the remote handler returned
}
fmt.Println("response:", string(resp)) // "done"

Listen returns immediately and dispatches notifications in a goroutine until the context is cancelled.

New takes both a *sql.DB and the connection string, because LISTEN needs a connection to itself for as long as the subscription lives and database/sql reclaims connections for its pool. Listen and Subscribe therefore open their own, outside the pool; everything else — Notify, locks, the payload store — uses the *sql.DB you pass. Close releases the connection shared by Subscribe; listeners started by Listen end with their context.

Connection Failures

A listener is the one part of latch that no query will tell you about: if its connection dies, it simply stops hearing anything. Lost connections are re-established automatically and every channel re-subscribed, but PostgreSQL does not queue notifications for a listener that is not there, so anything sent during the outage is gone. Register WithEvents to hear about it and resync whatever state the notifications were driving.

l:=postgres.New(db, dsn, postgres.WithEvents(func(s postgres.ConnState, errerror) {
log.Printf("latch listener: %s: %v", s, err)
ifs==postgres.Reconnected {
// notifications sent while the connection was down were not queued
}
}))

A server that stops answering without dropping the connection is detected by ping and the connection replaced, which takes up to 15 seconds; that reports as postgres.Stalled.

Notify is a round trip: it blocks until the handler on the receiving process returns, then delivers that result as the response. Use ctx to bound the wait. If the handler returns an error, Notify returns it on the calling side.

To wait for a single event without sending one, use Subscribe:

sub, err:=l.Subscribe(ctx, "jobs")
iferr!=nil {
// handle error
}
defersub.Close()
payload, err:=sub.Wait(ctx) // blocks until a notification arrives or ctx is done

Large Payloads

Because pg_notify carries text, round-trip payloads are wrapped in a JSON envelope and base64 encoded. PostgreSQL caps a notification at 7999 bytes, which leaves about 5.9 KB for your payload. Past that, Notify fails.

Rather than making callers work around this, hand latch a PayloadStore and it sends oversized payloads by reference: the bytes go to the store, only the key travels over the channel, and the receiving process loads them back before the handler runs. Callers and handlers see the payload either way.

store:=postgres.NewPayloadTable(db)
store.CreateTable(ctx) // or run store.Schema() through your migration tooll:=postgres.New(db, dsn, postgres.WithStore(store))
// 1 MB payload: stored as a row, notification carries an ~80 byte reference.resp, err:=l.Notify(ctx, "jobs", bigPayload)

Payloads of postgres.DefaultInlineLimit (4096) bytes or fewer still go inline with no extra query; tune with postgres.WithInlineLimit. Replies are offloaded by the same rule.

PayloadTable expires rows on a TTL (default 1 hour) instead of deleting them on read, because every process listening on a channel receives the notification and any number of them may resolve the same key. Call DeleteExpired on a timer to reclaim space.

To store payloads somewhere else — object storage, a cache, another table shape — implement the interface:

typePayloadStoreinterface {
Put(ctx context.Context, payload []byte) (string, error)
Get(ctx context.Context, keystring) ([]byte, error)
}

It must be reachable by every process that might receive the notification, so per-process memory will not do.

Bring Your Own Implementation

The latch.Coordinator interface (combining Locker and Signaler) lets you swap in any backend:

typeCoordinatorinterface {
AcquireLock(ctx context.Context, keyint64) (bool, error)
ReleaseLock(ctx context.Context, keyint64) errorNotify(ctx context.Context, eventEvent, payload []byte) ([]byte, error)
Subscribe(ctx context.Context, eventEvent) (Subscription, error)
Listen(ctx context.Context, handlersmap[Event]Handler) error
}

About

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Latest commit

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

latch

Description

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Usage

Install

go get github.com/supazonic/latch

Advisory Locks

Use advisory locks to ensure only one process runs a critical section at a time.

import (
"context""database/sql"
_ "github.com/lib/pq""github.com/supazonic/latch/postgres"
)
dsn:=os.Getenv("DATABASE_URL")
db, _:=sql.Open("postgres", dsn)
l:=postgres.New(db, dsn)
deferl.Close()
acquired, err:=l.AcquireLock(ctx, 42)
iferr!=nil {
// handle error
}
ifacquired {
deferl.ReleaseLock(ctx, 42)
// only one process reaches here at a time
}

A PostgreSQL advisory lock belongs to a session, not to a process, and it is released the instant that session ends. If the connection holding it dies, the server hands the key to whoever asks next while this process still believes it holds it — which is the one thing the lock existed to prevent. latch checks the connection behind every held lock and reports the loss:

l:=postgres.New(db, dsn, postgres.WithLockLost(func(keyint64, errerror) {
log.Printf("latch: lost lock %d: %v", key, err)
// another process can hold this key now; stop the work it was guarding
}))

A connection that stops answering counts as a lost lock even though its session may still be alive: giving up a lock you still hold costs progress, whereas keeping one you have already lost is a correctness bug. Close releases every lock still held.

Checks run every DefaultLockPingInterval (15s) and one may go unanswered for DefaultLockPingTimeout (5s) before the lock is given up, so a lost lock is reported within 20 seconds. Tune both with WithLockPing:

// tolerate a database that stalls for up to a minute under loadpostgres.New(db, dsn, postgres.WithLockPing(15*time.Second, time.Minute))

Raise the timeout if your database stalls and the work a lock guards is expensive to redo; lower the interval to hear about a genuinely lost lock sooner, at the cost of more round trips.

Pub/Sub (LISTEN/NOTIFY)

Send and receive events across processes using PostgreSQL channels. Payloads are []byte, so you can send whatever encoding you like.

// Register handlers. Whatever a handler returns is the response for the// round trip — handlers never send it back themselves.handlers:=map[latch.Event]latch.Handler{
"jobs": func(ctx context.Context, payload []byte) ([]byte, error) {
fmt.Println("received:", string(payload))
return []byte("done"), nil
},
}
err:=l.Listen(ctx, handlers) // runs in background until ctx is cancelled// Send a notification and block until the pod running the handler returns.resp, err:=l.Notify(ctx, "jobs", []byte("payload-here"))
iferr!=nil {
// handle error — includes any error the remote handler returned
}
fmt.Println("response:", string(resp)) // "done"

Listen returns immediately and dispatches notifications in a goroutine until the context is cancelled.

New takes both a *sql.DB and the connection string, because LISTEN needs a connection to itself for as long as the subscription lives and database/sql reclaims connections for its pool. Listen and Subscribe therefore open their own, outside the pool; everything else — Notify, locks, the payload store — uses the *sql.DB you pass. Close releases the connection shared by Subscribe; listeners started by Listen end with their context.

Connection Failures

A listener is the one part of latch that no query will tell you about: if its connection dies, it simply stops hearing anything. Lost connections are re-established automatically and every channel re-subscribed, but PostgreSQL does not queue notifications for a listener that is not there, so anything sent during the outage is gone. Register WithEvents to hear about it and resync whatever state the notifications were driving.

l:=postgres.New(db, dsn, postgres.WithEvents(func(s postgres.ConnState, errerror) {
log.Printf("latch listener: %s: %v", s, err)
ifs==postgres.Reconnected {
// notifications sent while the connection was down were not queued
}
}))

A server that stops answering without dropping the connection is detected by ping and the connection replaced, which takes up to 15 seconds; that reports as postgres.Stalled.

Notify is a round trip: it blocks until the handler on the receiving process returns, then delivers that result as the response. Use ctx to bound the wait. If the handler returns an error, Notify returns it on the calling side.

To wait for a single event without sending one, use Subscribe:

sub, err:=l.Subscribe(ctx, "jobs")
iferr!=nil {
// handle error
}
defersub.Close()
payload, err:=sub.Wait(ctx) // blocks until a notification arrives or ctx is done

Large Payloads

Because pg_notify carries text, round-trip payloads are wrapped in a JSON envelope and base64 encoded. PostgreSQL caps a notification at 7999 bytes, which leaves about 5.9 KB for your payload. Past that, Notify fails.

Rather than making callers work around this, hand latch a PayloadStore and it sends oversized payloads by reference: the bytes go to the store, only the key travels over the channel, and the receiving process loads them back before the handler runs. Callers and handlers see the payload either way.

store:=postgres.NewPayloadTable(db)
store.CreateTable(ctx) // or run store.Schema() through your migration tooll:=postgres.New(db, dsn, postgres.WithStore(store))
// 1 MB payload: stored as a row, notification carries an ~80 byte reference.resp, err:=l.Notify(ctx, "jobs", bigPayload)

Payloads of postgres.DefaultInlineLimit (4096) bytes or fewer still go inline with no extra query; tune with postgres.WithInlineLimit. Replies are offloaded by the same rule.

PayloadTable expires rows on a TTL (default 1 hour) instead of deleting them on read, because every process listening on a channel receives the notification and any number of them may resolve the same key. Call DeleteExpired on a timer to reclaim space.

To store payloads somewhere else — object storage, a cache, another table shape — implement the interface:

typePayloadStoreinterface {
Put(ctx context.Context, payload []byte) (string, error)
Get(ctx context.Context, keystring) ([]byte, error)
}

It must be reachable by every process that might receive the notification, so per-process memory will not do.

Bring Your Own Implementation

The latch.Coordinator interface (combining Locker and Signaler) lets you swap in any backend:

typeCoordinatorinterface {
AcquireLock(ctx context.Context, keyint64) (bool, error)
ReleaseLock(ctx context.Context, keyint64) errorNotify(ctx context.Context, eventEvent, payload []byte) ([]byte, error)
Subscribe(ctx context.Context, eventEvent) (Subscription, error)
Listen(ctx context.Context, handlersmap[Event]Handler) error
}

About

latch is a Go package that provides distributed coordination primitives — advisory locking and pub/sub signaling — backed by PostgreSQL. It lets multiple processes safely coordinate work and exchange events using a database they already have.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages