{"id":49109138,"url":"https://github.com/pivovarit/fencepost","last_synced_at":"2026-04-21T03:33:37.963Z","repository":{"id":348111221,"uuid":"1196363824","full_name":"pivovarit/fencepost","owner":"pivovarit","description":"PostgreSQL-backed distributed queueing and locking with fencing tokens for Java","archived":false,"fork":false,"pushed_at":"2026-04-17T05:14:04.000Z","size":238,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-17T07:19:23.932Z","etag":null,"topics":["distributed-lock","java","postgresql"],"latest_commit_sha":null,"homepage":"https://fencepost.pivovarit.com/","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/pivovarit.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"pivovarit","buy_me_a_coffee":"pivovarit"}},"created_at":"2026-03-30T16:18:14.000Z","updated_at":"2026-04-17T05:14:08.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/pivovarit/fencepost","commit_stats":null,"previous_names":["pivovarit/fencepost"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/pivovarit/fencepost","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pivovarit%2Ffencepost","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pivovarit%2Ffencepost/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pivovarit%2Ffencepost/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pivovarit%2Ffencepost/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/pivovarit","download_url":"https://codeload.github.com/pivovarit/fencepost/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pivovarit%2Ffencepost/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32075243,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-21T02:38:07.213Z","status":"ssl_error","status_checked_at":"2026-04-21T02:38:06.559Z","response_time":128,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["distributed-lock","java","postgresql"],"created_at":"2026-04-21T03:33:37.410Z","updated_at":"2026-04-21T03:33:37.954Z","avatar_url":"https://github.com/pivovarit.png","language":"Java","funding_links":["https://github.com/sponsors/pivovarit","https://buymeacoffee.com/pivovarit"],"categories":[],"sub_categories":[],"readme":"# fencepost\n\nDistributed concurrency toolkit for Java + PostgreSQL.\n\nZero dependencies beyond `org.postgresql:postgresql`. Requires Java 11+.\n\n**Under construction.**\n\n## Features\n\nFencepost provides three lock strategies, leader election, and a message queue, all backed by PostgreSQL.\n\n## Lock Types\n\n| Type | Mechanism | Fencing Token | Auto-Expiry | Holds Connection | Custom Table |\n|------|-----------|:---:|:---:|:---:|:---:|\n| `advisory` | PostgreSQL advisory locks | - | - | + | - |\n| `session` | Table-based, `SELECT ... FOR UPDATE` | + | - | + | + |\n| `lease` | Table-based, timestamp TTL + auto-renew | + | + | - | + |\n\n- **Advisory** - leverages PostgreSQL's built-in advisory locks. No table or schema setup required. Holds a database connection for the duration of the lock. Released automatically on disconnect. Simple and lightweight, but provides no fencing tokens, so it can't protect against stale holders writing to external systems.\n\n- **Session** - uses a dedicated table with `SELECT ... FOR UPDATE` to hold the lock within an open transaction. Issues monotonically increasing fencing tokens on each acquisition. The token lets downstream systems reject writes from holders that have been superseded. Holds a connection for the duration of the lock - if the process crashes, the connection is closed and the lock is released.\n\n- **Lease** - does not hold a connection or transaction. Acquires the lock by writing a timestamp to a table and releases the connection immediately. The lock is held purely via a TTL (`expires_at`) - if a holder crashes, the lock automatically becomes available after the lease duration. An optional auto-renew thread extends the lease periodically to prevent expiry during long-running work. Supports a quiet period to enforce a minimum gap between consecutive acquisitions. Best suited for long-running tasks where occupying a connection pool slot is not acceptable.\n\n## Table Setup\n\nSession and lease locks require a table. Advisory locks don't need any setup.\n\n```sql\nCREATE TABLE fencepost_locks (\n    lock_name   TEXT PRIMARY KEY,\n    token       BIGINT NOT NULL DEFAULT 0,\n    locked_by   TEXT,\n    locked_at   TIMESTAMP WITH TIME ZONE,\n    expires_at  TIMESTAMP WITH TIME ZONE\n);\n```\n\nThe table name defaults to `fencepost_locks` but can be customized via `.tableName(\"my_locks\")` on the builder.\n\n## Examples\n\n### Advisory Lock\n\n```java\nFactory\u003cLock\u003e fencepost = Fencepost.advisoryLock(dataSource).build();\nLock lock = fencepost.forName(\"my-resource\");\n\nlock.lock();                          // blocking\nlock.lock(Duration.ofSeconds(5));     // blocking with timeout\nlock.tryLock();                       // non-blocking\n\n// convenience wrapper\nlock.runLocked(() -\u003e { /* critical section */ });\n```\n\n### Session Lock\n\n```java\nFactory\u003cFencedLock\u003e fencepost = Fencepost.sessionLock(dataSource).build();\nFencedLock lock = fencepost.forName(\"my-resource\");\n\n// fencing token protects against stale writes\nlock.runLocked(token -\u003e {\n    externalStore.write(data, token.value());\n});\n```\n\n### Lease Lock\n\n```java\nFactory\u003cRenewableLock\u003e fencepost = Fencepost.leaseLock(dataSource, Duration.ofSeconds(30))\n    .withAutoRenew(Duration.ofSeconds(10))\n    .withQuietPeriod(Duration.ofSeconds(5))\n    .onAutoRenewFailure(e -\u003e log.error(\"auto-renew failed\", e))\n    .build();\n\nRenewableLock lock = fencepost.forName(\"my-resource\");\n\nFencingToken token = lock.lock();\ntry {\n    longRunningTask(token);\n} finally {\n    lock.unlock();\n}\n```\n\n### Leader Election\n\nUse leader election when you want one of N instances to *pick up* a piece of work and *keep doing it*, with automatic failover when the leader dies. It's built on top of `leaseLock` — a sticky single-leader primitive, not per-iteration mutual exclusion (use `leaseLock` directly for that).\n\n```java\nLeaderElection election = Fencepost.leaderElection(dataSource, \"import-job\", Duration.ofSeconds(30))\n    .withRenewInterval(Duration.ofSeconds(10))\n    .withPollInterval(Duration.ofSeconds(5))\n    .withInstanceId(\"worker-pod-7\")               // optional, written to locked_by\n    .onElected(token -\u003e startWorker(token))       // overload: receives the FencingToken\n    .onRevoked(() -\u003e stopWorker())\n    .onCallbackError(e -\u003e log.warn(\"...\", e))     // optional\n    .build();\n\nelection.start();\n\n// elsewhere:\nif (election.isLeader()) {\n    // safe to act\n}\n\n// on shutdown:\nelection.close();   // fires onRevoked synchronously, then releases the lease\n```\n\n`onElected` and `onRevoked` are state-change callbacks — they should return quickly. Real work runs on your own thread, gated by `isLeader()`. If the leader's lease can't be renewed (DB hiccup, GC pause longer than the lease), `onRevoked` fires and the loop returns to standby; another instance takes over within roughly one lease duration.\n\n## Docker Compose Example\n\nThe `examples/docker-compose` directory contains a ready-to-run demo where three container instances compete to increment a shared counter in PostgreSQL.\n\n```\ncd examples/docker-compose\ndocker compose up --build\n```\n\nThe output shows each instance racing to acquire the lock. Winners increment the counter; losers skip. At the end of each phase, the final counter value confirms that no updates were lost.\n\n## Queue\n\nFencepost includes a PostgreSQL-backed message queue with at-least-once delivery, visibility timeouts, and `LISTEN/NOTIFY`-based blocking dequeue.\n\n### Queue Table Setup\n\n```sql\nCREATE TABLE fencepost_queue (\n    id            BIGSERIAL PRIMARY KEY,\n    queue_name    TEXT NOT NULL,\n    payload       BYTEA NOT NULL,\n    type          TEXT,\n    headers       JSONB,\n    visible_at    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),\n    picked_by     TEXT,\n    attempts      INT NOT NULL DEFAULT 0\n);\n```\n\nThe table name defaults to `fencepost_queue` but can be customized via `.tableName(\"my_queue\")` on the builder.\n\n### Queue Example\n\n```java\nFactory\u003cQueue\u003e fencepost = Fencepost.queue(dataSource)\n    .visibilityTimeout(Duration.ofSeconds(30)) // required\n    .build();\n\nQueue queue = fencepost.forName(\"my-queue\");\n\nqueue.enqueue(\"hello\".getBytes());\nqueue.enqueue(\"delayed hello\".getBytes(), Duration.ofSeconds(10));\n\n// enqueue with type and headers\nqueue.enqueue(\"{\\\"to\\\":\\\"user@example.com\\\"}\".getBytes(), \"send-email.v1\", Map.of(\"priority\", \"high\"));\nqueue.enqueue(\"{\\\"to\\\":\\\"user@example.com\\\"}\".getBytes(), \"send-email.v1\", Map.of(\"priority\", \"high\"), Duration.ofSeconds(10));\n\nMessage msg = queue.dequeue();           // blocking (LISTEN/NOTIFY)\nMessage msg = queue.dequeue(Duration.ofSeconds(5)); // with timeout\nOptional\u003cMessage\u003e msg = queue.tryDequeue();         // non-blocking\n\nmsg.type();       // Optional[send-email.v1]\nmsg.headers();    // {\"priority\": \"high\"}\n\n// ack() deletes the message, nack() makes it visible again immediately\nmsg.ack();\nmsg.nack();\n```\n\nEach message can optionally carry a `type` (a plain text label for routing or versioning) and `headers` (a `Map\u003cString, String\u003e` stored as JSONB). Both are nullable - plain `enqueue(payload)` and `enqueue(payload, delay)` still work as before.\n\nIf processing fails without calling `ack()` or `nack()`, the message becomes visible again after the visibility timeout expires, with an incremented `attempts` counter.\n\n## Important: PostgreSQL Clock Behavior\n\nPostgreSQL's `clock_timestamp()` / `now()` relies on the system clock, which is **not monotonic** and is subject to clock skew (e.g., NTP adjustments, VM clock drift, leap second handling). This means that timestamp-based lease expiry can, in rare cases, behave unexpectedly - a lease may appear to expire early or late if the database server's clock jumps.\n\nIf your use case requires **absolute mutual-exclusion guarantees** (e.g., protecting writes to an external system), you have two options:\n\n1. **Use fencing tokens** - the fencing token is a monotonically increasing value that lets downstream systems reject stale writes, regardless of clock behavior. Pass the token to any external resource and have that resource reject requests with a token lower than the highest it has already seen. This works with both `session` and `lease` locks.\n\n2. **Use a `session` lock instead** - since `session` locks are held via `SELECT ... FOR UPDATE` within an open transaction, they don't depend on timestamps at all and are immune to clock skew. The trade-off is that a session lock holds a database connection for the entire duration of the lock, which may not be acceptable for long-running tasks or applications with limited connection pools.\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpivovarit%2Ffencepost","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpivovarit%2Ffencepost","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpivovarit%2Ffencepost/lists"}