Locks are written out at each call site, with four key conventions and no written order #186

Closed
opened 2026-10-06 21:25:45 +00:00 by cgalo5758 · 6 comments
Owner

The pool row lock is SQL in five places (internal/entitlements/grant_acts.go twice, grant_resumption.go, internal/server/operator_org_types.go, operator_plan_ladders.go) beside two generated queries doing the same. Advisory keys are built four ways: hashtext of a bare id (workspace, product, subscription, invoice), sharing one 32-bit space; hashtextextended of a prefixed string (lifecycle, desired state); hashtextextended of a bare name (materialization); a constant (domains registry). The entitlements spec fixes part of the order; the rest lives in call-site comments, and the plan-ending paths take the pool lock at different points (#180). #165 adds a usage-row lock under the workspace lock with nothing to check it against.

Done when: lock functions live in one place; every advisory key is built from a family prefix and an id; the families and their order are written once under docs/; the project lint allows pg_advisory_xact_lock and pool FOR UPDATE only in the helpers; and the deploy that changes keys runs no old process beside new ones.

The pool row lock is SQL in five places (`internal/entitlements/grant_acts.go` twice, `grant_resumption.go`, `internal/server/operator_org_types.go`, `operator_plan_ladders.go`) beside two generated queries doing the same. Advisory keys are built four ways: `hashtext` of a bare id (workspace, product, subscription, invoice), sharing one 32-bit space; `hashtextextended` of a prefixed string (lifecycle, desired state); `hashtextextended` of a bare name (materialization); a constant (domains registry). The entitlements spec fixes part of the order; the rest lives in call-site comments, and the plan-ending paths take the pool lock at different points (#180). #165 adds a usage-row lock under the workspace lock with nothing to check it against. Done when: lock functions live in one place; every advisory key is built from a family prefix and an id; the families and their order are written once under `docs/`; the project lint allows `pg_advisory_xact_lock` and pool `FOR UPDATE` only in the helpers; and the deploy that changes keys runs no old process beside new ones.
cgalo5758 added this to the Public launch milestone 2026-10-06 21:25:45 +00:00
cgalo5758 added the
kind
debt
area/entitlementsarea/billingarea/integrations
labels 2026-10-06 21:25:45 +00:00
Author
Owner

Part of this landed with the test-kit step of #166:

  • The transaction runner. db.InTx and db.Begin in internal/db open a transaction with a kind and return its begin and commit errors unwrapped. entitlements.Materializing and entitlements.RuleChange take the rendezvous lock and set lock_timeout; a plain transaction does neither. Every transaction that materializes or changes a rule opens this way.
  • The pool-lock helper. LockPools and LockPoolOfGrant in internal/entitlements are the only Go callers of the two queries that lock core.resource_pools, and LockPools takes several pools in ascending order. No other Go code locks a pool row. The database functions core.confer, core.end_conferral, core.align_conferral_shape, core.update_conferral_bounds and core.settle_obligation still lock it themselves, as docs/database-locks.md lists.

What remains:

One lock order is inverted (#196): recording a failed drain locks the obligation row before the pool row, while the drain locks the pool first, so the two can deadlock. ReorderPlanLadders has a smaller case of the same kind: it updates each ladder's sort_order in the order the drag submitted, so two opposite reorders at the same moment can deadlock.

The advisory key families are each taken by their own callers:

  • the rendezvous, hashtextextended('entitlement_materialization'), always first (test/mockshot-seed.sql also takes it, which the doc does not mention);
  • prefixed keys through hashtextextended: lifecycle:<instance> before the site and usage rows, and desired-state:<connection>:<kind>:<recipient> before the record row;
  • bare ids through hashtext: the Stripe subscription (after the rendezvous, before the pool rows), and the Stripe invoice, the product and the FedWiki workspace, each taken first;
  • the domain registry's literal key, taken first.

The order written once. docs/database-locks.md states it, but each caller takes its own locks, so nothing holds a new path to it.

The lint rule. Nothing refuses BeginTx outside internal/db; the only guard is a comment in internal/entitlements/tx.go asking that the call stay greppable. A forbidigo pattern like the two in .golangci.yml can hold it once the sites below convert. Tests open 73 more transactions by hand across 33 files, which the rule would also meet unless it excludes tests.

Transactions still opened by hand (14, outside tests). None of them materializes. Converting each to db.InTx with no kind changes only how its begin and commit errors read and that rollback runs through the runner, except where noted:

  • internal/domains/registry.go, WithLock: the registry's advisory lock moves into the function or a kind of its own.
  • internal/integration/lifecycle.go, RecordLifecycleRequest and RecordFirstLifecycleRequest: a failed commit today returns the answer along with the error.
  • internal/integration/registration.go, RegisterProviders: its provider stamp on core.resource_keys waits behind materializers' foreign-key locks with no timeout; under a rendezvous kind the wait would time out instead.
  • internal/integrations/discourse/workflows/activities.go, inTx: holds the transaction across Discourse HTTP calls, and converting leaves that as it is.
  • internal/integrations/fedwiki/usage/usage.go, BoundWorkspace; internal/integrations/fedwiki/workflows/reconcile.go, sweepLocally; internal/integrations/fedwiki/workflows/request_activities.go, finishAllWith: a return that writes nothing would commit instead of rolling back, with the same effect, and a failed commit today returns a result along with the error.
  • internal/integrations/stripe/workflows/invoice_reconcile.go, converge: its commit error is classified for Temporal's retry; converted, it would read like the function's own already-classified errors.
  • internal/server/operator_billing.go, MakeDefaultPrice: rendering moves after the transaction, and the per-step error labels collapse into one.
  • internal/server/operator_billing.go, SyncProductToStripe: renders its answers while holding the product lock and commits at two points, so converting needs an outcome returned from the transaction, one commit, and rendering after it.
  • internal/server/operator_topology.go, ReorderPlanLadders: rendering moves after the transaction.
  • internal/server/workspace_partials.go, CreateWorkspace: its pool-assignment insert waits behind a pool lock with no timeout; under a rendezvous kind the wait would time out instead.
  • internal/systemtenant/systemtenant.go, Ensure: two commit labels collapse into one.
Part of this landed with the test-kit step of #166: - **The transaction runner.** `db.InTx` and `db.Begin` in `internal/db` open a transaction with a kind and return its begin and commit errors unwrapped. `entitlements.Materializing` and `entitlements.RuleChange` take the rendezvous lock and set `lock_timeout`; a plain transaction does neither. Every transaction that materializes or changes a rule opens this way. - **The pool-lock helper.** `LockPools` and `LockPoolOfGrant` in `internal/entitlements` are the only Go callers of the two queries that lock `core.resource_pools`, and `LockPools` takes several pools in ascending order. No other Go code locks a pool row. The database functions `core.confer`, `core.end_conferral`, `core.align_conferral_shape`, `core.update_conferral_bounds` and `core.settle_obligation` still lock it themselves, as `docs/database-locks.md` lists. What remains: **One lock order is inverted** (#196): recording a failed drain locks the obligation row before the pool row, while the drain locks the pool first, so the two can deadlock. `ReorderPlanLadders` has a smaller case of the same kind: it updates each ladder's `sort_order` in the order the drag submitted, so two opposite reorders at the same moment can deadlock. **The advisory key families** are each taken by their own callers: - the rendezvous, `hashtextextended('entitlement_materialization')`, always first (`test/mockshot-seed.sql` also takes it, which the doc does not mention); - prefixed keys through `hashtextextended`: `lifecycle:<instance>` before the site and usage rows, and `desired-state:<connection>:<kind>:<recipient>` before the record row; - bare ids through `hashtext`: the Stripe subscription (after the rendezvous, before the pool rows), and the Stripe invoice, the product and the FedWiki workspace, each taken first; - the domain registry's literal key, taken first. **The order written once.** `docs/database-locks.md` states it, but each caller takes its own locks, so nothing holds a new path to it. **The lint rule.** Nothing refuses `BeginTx` outside `internal/db`; the only guard is a comment in `internal/entitlements/tx.go` asking that the call stay greppable. A forbidigo pattern like the two in `.golangci.yml` can hold it once the sites below convert. Tests open 73 more transactions by hand across 33 files, which the rule would also meet unless it excludes tests. **Transactions still opened by hand** (14, outside tests). None of them materializes. Converting each to `db.InTx` with no kind changes only how its begin and commit errors read and that rollback runs through the runner, except where noted: - `internal/domains/registry.go`, `WithLock`: the registry's advisory lock moves into the function or a kind of its own. - `internal/integration/lifecycle.go`, `RecordLifecycleRequest` and `RecordFirstLifecycleRequest`: a failed commit today returns the answer along with the error. - `internal/integration/registration.go`, `RegisterProviders`: its provider stamp on `core.resource_keys` waits behind materializers' foreign-key locks with no timeout; under a rendezvous kind the wait would time out instead. - `internal/integrations/discourse/workflows/activities.go`, `inTx`: holds the transaction across Discourse HTTP calls, and converting leaves that as it is. - `internal/integrations/fedwiki/usage/usage.go`, `BoundWorkspace`; `internal/integrations/fedwiki/workflows/reconcile.go`, `sweepLocally`; `internal/integrations/fedwiki/workflows/request_activities.go`, `finishAllWith`: a return that writes nothing would commit instead of rolling back, with the same effect, and a failed commit today returns a result along with the error. - `internal/integrations/stripe/workflows/invoice_reconcile.go`, `converge`: its commit error is classified for Temporal's retry; converted, it would read like the function's own already-classified errors. - `internal/server/operator_billing.go`, `MakeDefaultPrice`: rendering moves after the transaction, and the per-step error labels collapse into one. - `internal/server/operator_billing.go`, `SyncProductToStripe`: renders its answers while holding the product lock and commits at two points, so converting needs an outcome returned from the transaction, one commit, and rendering after it. - `internal/server/operator_topology.go`, `ReorderPlanLadders`: rendering moves after the transaction. - `internal/server/workspace_partials.go`, `CreateWorkspace`: its pool-assignment insert waits behind a pool lock with no timeout; under a rendezvous kind the wait would time out instead. - `internal/systemtenant/systemtenant.go`, `Ensure`: two commit labels collapse into one.
Author
Owner

The list of transactions still opened by hand has changed since the comment above.

RecordLifecycleRequest and RecordFirstLifecycleRequest now open theirs through db.InTx (inOwnTx in internal/integration/lifecycle.go), and so does RegisterProviders (internal/integration/registration.go:207).

SyncProductToStripe's transaction moved with its act into enqueue (internal/integrations/stripe/catalogsync/catalogsync.go:179). Its comment says why the transaction outlives the function: a no-op or a failure keeps the product's advisory lock until Sync calls the end it returns. db.Begin returns an open transaction for exactly that case, and nothing in the comment needs BeginTx itself, so it converts like the others.

That leaves 11 calls to BeginTx outside tests, the test kit and internal/db:

  • internal/domains/registry.go:454, WithLock
  • internal/integrations/discourse/workflows/activities.go:64, inTx
  • internal/integrations/fedwiki/usage/usage.go:74, BoundWorkspace
  • internal/integrations/fedwiki/workflows/reconcile.go:304, sweepLocally
  • internal/integrations/fedwiki/workflows/request_activities.go:181, finishAllWith
  • internal/integrations/stripe/catalogsync/catalogsync.go:179, enqueue
  • internal/integrations/stripe/workflows/invoice_reconcile.go:411, converge
  • internal/server/operator_billing.go:1287, MakeDefaultPrice
  • internal/server/operator_topology.go:382, ReorderPlanLadders
  • internal/server/workspace_partials.go:121, CreateWorkspace
  • internal/systemtenant/systemtenant.go:55, Ensure

The three in internal/server also open a transaction inside a handler, which #221 moves out. Opening the moved transactions through db.InTx settles their entries here.

The list of transactions still opened by hand has changed since the comment above. `RecordLifecycleRequest` and `RecordFirstLifecycleRequest` now open theirs through `db.InTx` (`inOwnTx` in `internal/integration/lifecycle.go`), and so does `RegisterProviders` (`internal/integration/registration.go:207`). `SyncProductToStripe`'s transaction moved with its act into `enqueue` (`internal/integrations/stripe/catalogsync/catalogsync.go:179`). Its comment says why the transaction outlives the function: a no-op or a failure keeps the product's advisory lock until `Sync` calls the `end` it returns. `db.Begin` returns an open transaction for exactly that case, and nothing in the comment needs `BeginTx` itself, so it converts like the others. That leaves 11 calls to `BeginTx` outside tests, the test kit and `internal/db`: - `internal/domains/registry.go:454`, `WithLock` - `internal/integrations/discourse/workflows/activities.go:64`, `inTx` - `internal/integrations/fedwiki/usage/usage.go:74`, `BoundWorkspace` - `internal/integrations/fedwiki/workflows/reconcile.go:304`, `sweepLocally` - `internal/integrations/fedwiki/workflows/request_activities.go:181`, `finishAllWith` - `internal/integrations/stripe/catalogsync/catalogsync.go:179`, `enqueue` - `internal/integrations/stripe/workflows/invoice_reconcile.go:411`, `converge` - `internal/server/operator_billing.go:1287`, `MakeDefaultPrice` - `internal/server/operator_topology.go:382`, `ReorderPlanLadders` - `internal/server/workspace_partials.go:121`, `CreateWorkspace` - `internal/systemtenant/systemtenant.go:55`, `Ensure` The three in `internal/server` also open a transaction inside a handler, which #221 moves out. Opening the moved transactions through `db.InTx` settles their entries here.
Author
Owner

One more transaction shape for the lock helpers to settle.

inOwnTx in internal/integration/lifecycle.go runs RecordLifecycleRequest and RecordFirstLifecycleRequest. It opens a transaction only when it is handed a *sql.DB, and uses any other handle as it is. That is right for a *sql.Tx. It is wrong for a *sql.Conn, which also satisfies the package's DBTX. Outside a transaction, each statement on a connection commits on its own, so the instance lock (pg_advisory_xact_lock in LockLifecycleInstance) is released as soon as it is taken. The read of the instance's latest generation and the insert after it are then no longer serialized. An older tab's request could be recorded as new after a newer one, which is the race the lock exists to close. Two requests at the same moment would get a unique-index error instead of the busy answer. I have read this, not run it.

No caller passes a connection today. The concurrency test in internal/integration/lifecycle_db_test.go pins a connection to learn its backend id, and it begins a transaction on that connection before it records. A test that recorded on the connection itself would still pass, because that test's two recordings already run one after the other.

Either of two changes closes it. inOwnTx can ask whether the handle is a db.Beginner (a *sql.DB or a *sql.Conn) instead of a *sql.DB. Or, when the lifecycle lock moves into the shared lock helpers, that helper can take a *sql.Tx, so a connection does not compile.

One more transaction shape for the lock helpers to settle. `inOwnTx` in `internal/integration/lifecycle.go` runs `RecordLifecycleRequest` and `RecordFirstLifecycleRequest`. It opens a transaction only when it is handed a `*sql.DB`, and uses any other handle as it is. That is right for a `*sql.Tx`. It is wrong for a `*sql.Conn`, which also satisfies the package's `DBTX`. Outside a transaction, each statement on a connection commits on its own, so the instance lock (`pg_advisory_xact_lock` in `LockLifecycleInstance`) is released as soon as it is taken. The read of the instance's latest generation and the insert after it are then no longer serialized. An older tab's request could be recorded as new after a newer one, which is the race the lock exists to close. Two requests at the same moment would get a unique-index error instead of the busy answer. I have read this, not run it. No caller passes a connection today. The concurrency test in `internal/integration/lifecycle_db_test.go` pins a connection to learn its backend id, and it begins a transaction on that connection before it records. A test that recorded on the connection itself would still pass, because that test's two recordings already run one after the other. Either of two changes closes it. `inOwnTx` can ask whether the handle is a `db.Beginner` (a `*sql.DB` or a `*sql.Conn`) instead of a `*sql.DB`. Or, when the lifecycle lock moves into the shared lock helpers, that helper can take a `*sql.Tx`, so a connection does not compile.
Author
Owner

Its body says the usage-row lock is taken under the FedWiki workspace lock. That does not hold: the usage row's lock is taken by the slot gate, the outcome steps, settle, the sweep and the sync, which run in workflow activities and boot passes that hold no workspace lock; it comes after the site rows and before the request rows, as docs/database-locks.md now writes down in its general lock order and for each path.

Of the transactions still opened by hand in the list above, BoundWorkspace is deleted, and sweepLocally and finishAllWith now open through db.InTx at READ COMMITTED, which leaves eight BeginTx calls outside tests, the test kit and internal/db. usage.Recount and usage.Hold take a *sql.Tx, so a handle that is not a transaction does not compile, the shape the comment above suggests for the lifecycle lock.

Its body says the usage-row lock is taken under the FedWiki workspace lock. That does not hold: the usage row's lock is taken by the slot gate, the outcome steps, settle, the sweep and the sync, which run in workflow activities and boot passes that hold no workspace lock; it comes after the site rows and before the request rows, as `docs/database-locks.md` now writes down in its general lock order and for each path. Of the transactions still opened by hand in the list above, `BoundWorkspace` is deleted, and `sweepLocally` and `finishAllWith` now open through `db.InTx` at READ COMMITTED, which leaves eight `BeginTx` calls outside tests, the test kit and `internal/db`. `usage.Recount` and `usage.Hold` take a `*sql.Tx`, so a handle that is not a transaction does not compile, the shape the comment above suggests for the lifecycle lock.
cgalo5758 referenced this issue from a commit 2026-10-22 18:40:40 +00:00
Author
Owner

The transaction runner is db.InTx and db.Begin in internal/db/tx.go: they open a transaction of a named kind, which states its isolation and what runs first in it, such as the shared lock every transaction that recomputes an organization's limits takes. golangci-lint now refuses BeginTx and (*sql.DB).Begin anywhere outside internal/db, tests and the test kit, with a message naming the runner. Tests stay outside the rule, because they open raw transactions to hold a lock while the code under test waits. Five of the transactions listed above now open through the runner: the domain registry's, whose advisory lock becomes the first statement of a kind of its own; the Discourse sweep's and the System tenant's boot step, through db.InTx; and the Stripe product sync's and the invoice reconcile's, through db.Begin, which keeps their own commit. Nothing they write changes; a failed begin or commit in the first three reads without the label that named the step. MakeDefaultPrice, ReorderPlanLadders and CreateWorkspace stay as pinned exceptions that name #221, which moves their transactions out of the handlers.

The ladder reorder on the topology page now runs at READ COMMITTED, whatever the database server's default, and locks every ladder row in ascending id order before it reads or writes, with FOR NO KEY UPDATE, the lock its update takes anyway. Two reorders that drag ladders in opposite orders at the same moment no longer deadlock: the second waits, then reads the order the first committed and writes its whole submitted order, where before it could skip a ladder whose stale position happened to match. A conferral that inserts a position on a ladder takes a key-share lock on it through the foreign key, which this lock does not block.

What remains for this issue is the advisory keys: one way to build them, the families written once, and the lint rule for advisory and pool locks.

Commit: 6034b033

The transaction runner is `db.InTx` and `db.Begin` in `internal/db/tx.go`: they open a transaction of a named kind, which states its isolation and what runs first in it, such as the shared lock every transaction that recomputes an organization's limits takes. golangci-lint now refuses `BeginTx` and `(*sql.DB).Begin` anywhere outside `internal/db`, tests and the test kit, with a message naming the runner. Tests stay outside the rule, because they open raw transactions to hold a lock while the code under test waits. Five of the transactions listed above now open through the runner: the domain registry's, whose advisory lock becomes the first statement of a kind of its own; the Discourse sweep's and the System tenant's boot step, through `db.InTx`; and the Stripe product sync's and the invoice reconcile's, through `db.Begin`, which keeps their own commit. Nothing they write changes; a failed begin or commit in the first three reads without the label that named the step. `MakeDefaultPrice`, `ReorderPlanLadders` and `CreateWorkspace` stay as pinned exceptions that name #221, which moves their transactions out of the handlers. The ladder reorder on the topology page now runs at READ COMMITTED, whatever the database server's default, and locks every ladder row in ascending id order before it reads or writes, with `FOR NO KEY UPDATE`, the lock its update takes anyway. Two reorders that drag ladders in opposite orders at the same moment no longer deadlock: the second waits, then reads the order the first committed and writes its whole submitted order, where before it could skip a ladder whose stale position happened to match. A conferral that inserts a position on a ladder takes a key-share lock on it through the foreign key, which this lock does not block. What remains for this issue is the advisory keys: one way to build them, the families written once, and the lint rule for advisory and pool locks. Commit: 6034b033
Author
Owner

An advisory lock is a Postgres lock on a number of the application's choosing rather than on a row; the console takes them per transaction to serialize work on one object, such as two reconciles of one Stripe subscription. Each kind of object now has a family, and every key is built one way: hashtextextended('<family>:<id>', 0), a UUID written in canonical form, so two spellings of one id take one lock. db.Lock and db.LockShared in internal/db/locks.go build the key and take the lock, on a *sql.Tx only, and every advisory lock in the code goes through them. Each family's statement begins with a name line such as -- name: LockSubscription :exec, so pg_stat_activity shows which family a waiting backend waits on, and the statement traces in the tests record that name.

The families, in the order a transaction takes them: materialization (the shared or exclusive lock every transaction that recomputes an organization's limits, or changes a rule, takes first), domains-registry, fedwiki-workspace, lifecycle, stripe-subscription, stripe-invoice, stripe-product-sync, desired-state. docs/database-locks.md lists them in a table and writes one order every path keeps: that lock first, then the families in the table's order, then rows. The project lint fails when the table and the code's list disagree.

The keys of lifecycle and desired-state are unchanged. The other six changed, the materialization lock's included, so a migration changes the key the database's own check of that lock looks for, and the two seeds that take it take the new one. Two processes that build different keys for one object share no lock, so this release must be deployed with every process of the previous one stopped first; the production guide now states that order for every upgrade.

The project lint now refuses an advisory lock function named, in any case, in a Go string or in SQL or shell code outside the helpers, the test kit's probe and its own wait tests, the lint rule's own file and tests, the two migrations that name the rendezvous and internal/db's own tests; a seed that takes any advisory key but the materialization lock's, or takes that one anywhere but first after its BEGIN;; a row lock on core.resource_pools in any query but LockResourcePool and LockGrantPool, or in a Go string outside the tests; and a call or method value of those two queries outside internal/entitlements/tx.go, where LockPools and LockPoolOfGrant call them. Tests take a family's lock through the same helpers and probe one with dbtest.TryLock, so a test holds the key the code takes.

Recording a lifecycle request now comes in two forms: RecordLifecycleRequest opens its own transaction on a database or a pinned connection, and RecordLifecycleRequestInTx runs in the caller's *sql.Tx. A connection handed to the first is no longer used outside a transaction, which would have released the instance lock as soon as it was taken; handed to the second, it does not compile. The same holds for RecordFirstLifecycleRequest.

Commit: e52fdb37

An advisory lock is a Postgres lock on a number of the application's choosing rather than on a row; the console takes them per transaction to serialize work on one object, such as two reconciles of one Stripe subscription. Each kind of object now has a family, and every key is built one way: `hashtextextended('<family>:<id>', 0)`, a UUID written in canonical form, so two spellings of one id take one lock. `db.Lock` and `db.LockShared` in `internal/db/locks.go` build the key and take the lock, on a `*sql.Tx` only, and every advisory lock in the code goes through them. Each family's statement begins with a name line such as `-- name: LockSubscription :exec`, so `pg_stat_activity` shows which family a waiting backend waits on, and the statement traces in the tests record that name. The families, in the order a transaction takes them: `materialization` (the shared or exclusive lock every transaction that recomputes an organization's limits, or changes a rule, takes first), `domains-registry`, `fedwiki-workspace`, `lifecycle`, `stripe-subscription`, `stripe-invoice`, `stripe-product-sync`, `desired-state`. `docs/database-locks.md` lists them in a table and writes one order every path keeps: that lock first, then the families in the table's order, then rows. The project lint fails when the table and the code's list disagree. The keys of `lifecycle` and `desired-state` are unchanged. The other six changed, the materialization lock's included, so a migration changes the key the database's own check of that lock looks for, and the two seeds that take it take the new one. Two processes that build different keys for one object share no lock, so this release must be deployed with every process of the previous one stopped first; the production guide now states that order for every upgrade. The project lint now refuses an advisory lock function named, in any case, in a Go string or in SQL or shell code outside the helpers, the test kit's probe and its own wait tests, the lint rule's own file and tests, the two migrations that name the rendezvous and `internal/db`'s own tests; a seed that takes any advisory key but the materialization lock's, or takes that one anywhere but first after its `BEGIN;`; a row lock on `core.resource_pools` in any query but `LockResourcePool` and `LockGrantPool`, or in a Go string outside the tests; and a call or method value of those two queries outside `internal/entitlements/tx.go`, where `LockPools` and `LockPoolOfGrant` call them. Tests take a family's lock through the same helpers and probe one with `dbtest.TryLock`, so a test holds the key the code takes. Recording a lifecycle request now comes in two forms: `RecordLifecycleRequest` opens its own transaction on a database or a pinned connection, and `RecordLifecycleRequestInTx` runs in the caller's `*sql.Tx`. A connection handed to the first is no longer used outside a transaction, which would have released the instance lock as soon as it was taken; handed to the second, it does not compile. The same holds for `RecordFirstLifecycleRequest`. Commit: e52fdb37
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wiki-cafe/member-console#186