store

package
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: MIT Imports: 25 Imported by: 0

Documentation

Overview

Package store owns the database layer: migrations, partition maintenance and the sqlc-generated queries.

Index

Constants

View Source
const AddonMaxConns = 4

AddonMaxConns is how much of the database one add-on may hold at once.

Small, and deliberately not derived from the application pool's size: the point of a separate pool is that an add-on cannot starve the product of connections, and a limit that scales with the product's own would give that back. Four is enough for a module answering requests concurrently.

**It is not outside the connection budget, and this comment said it was.** internal/config refuses `DB_MAX_CONNS + DB_REDIRECT_MAX_CONNS > 90` against the compose file's `max_connections = 100`, so ten storage add-ons at four apiece are forty connections that sum cannot see — exactly the re-planning of max_connections this once claimed to avoid. The defaults, 20 and 6, leave room for several; the guard's own ceiling leaves room for none. Teaching the guard and the two operator documents about add-on pools is F278.

View Source
const AddonMaxResultBytes = 1 << 20

AddonMaxResultBytes bounds the JSON one query may hand back.

The host builds the whole result in memory before the guest sees any of it — the ABI's out-parameter convention has no streaming form — so this bounds what crosses to the guest: a megabyte is far more than a configuration read or a token lookup needs and small enough that a module looping over `SELECT * FROM big_table` fails rather than swells.

**It is not a bound on the host's heap, and this comment claimed it was.** [encodeRows] checks it after pgconn has read a whole `DataRow` and after `rows.Values` and `json.Marshal` have materialized that row, so one row wider than this is allocated in full before the refusal — times AddonMaxConns, and an add-on's own schema has no quota by design. Bounding the heap means refusing a row by its size before it is decoded, which is F276.

View Source
const AddonSchemaPrefix = "addon_"

AddonSchemaPrefix is what every add-on's schema and role name begins with.

One prefix for both, and the schema and the role are the *same identifier*: they live in different namespaces in Postgres, and using one string means the enumeration in AddonSchemas finds exactly the objects the confinement is about. It is also what makes an orphan detectable — a schema whose name starts with this and matches no loaded add-on.

View Source
const AddonStatementTimeout = 5 * time.Second

AddonStatementTimeout bounds one statement an add-on runs.

Enforced by Postgres rather than by a context alone, because a context cancellation asks the server to stop and this makes the server stop itself. It is not configurable: an add-on's query is not on any latency budget yet — M66 is what prices the redirect path — and a knob whose only effect is to let a misbehaving module hold a connection longer is a knob with one setting worth having.

View Source
const AuditTable = "audit_logs"

AuditTable is the audit log, retained under its own window.

View Source
const PartitionLookahead = 2

PartitionLookahead is how many months of partitions to create beyond the current one.

Variables

View Source
var AnalyticsTables = []string{"click_events", "visitors"}

AnalyticsTables are the partitioned tables the analytics retention window applies to.

audit_logs is partitioned identically and is deliberately not here. Audit retention is a different policy from analytics retention — the reason to keep an audit trail is that someone may need to ask what happened a long time afterwards — and quietly deleting it on the analytics setting would be a surprise of exactly the wrong kind. It has its own window; see RetentionPolicy.

View Source
var ErrAddonDenied = errors.New("the statement reached outside the add-on's own schema")

ErrAddonDenied is a statement the database refused for want of privilege, which is what confinement looks like from the inside. Distinguished from every other SQL failure because it is the one the add-on's author can do nothing about: it means the statement reached outside the schema they own.

View Source
var Migrations = func() fs.FS {
	sub, err := fs.Sub(migrationsFS, "migrations")
	if err != nil {
		panic("store: cannot open embedded migrations: " + err.Error())
	}
	return sub
}()

Migrations is the embedded set rooted at the migration files themselves. goose scans the root of the FS it is given, so the "migrations/" prefix has to be stripped or it finds nothing.

View Source
var PartitionedTables = []string{"click_events", "visitors", "audit_logs"}

PartitionedTables are the RANGE-partitioned tables. All are keyed on a timestamptz and partitioned by month.

Maintaining partitions for a table nothing writes to yet is deliberate rather than an oversight, and it paid off here. audit_logs was maintained through the whole of Phase 1 with no writer; when M21 gave it one, partitions already existed for every month and no backfill was needed. `visitors` is still in that position. The cost is one to_regclass check per table per month — the partition already exists on all but one run an hour. The alternative fails in the direction that matters: rows landing in the default partition, which retention never drops, so a dormant table would quietly become the one place data is kept forever.

Functions

func AddonConfinementViolations added in v0.4.0

func AddonConfinementViolations(ctx context.Context, pool *pgxpool.Pool, name string) ([]string, error)

AddonConfinementViolations is everything about an add-on's schema that the confinement forbids, in four directions: what the role owns outside its own schema, what sits inside its schema that the role does not own, and what it has granted on its schema to anybody but itself.

It should always be empty, and that is why it exists. Privileges are what confine an add-on's DDL, and this asks the catalogue whether they did rather than trusting that they did — a post-condition on a migration written by somebody else, run once per load. A non-empty answer refuses the add-on.

It asks a shape, not a list of places

Three earlier versions of this function enumerated the places an add-on could own something — relations; then relations minus the schemas Postgres reserves; then large objects as well — and each round of review found a fourth. The last was a temp table: `PUBLIC` holds `TEMPORARY` on a database by default, a pooled connection is not a fresh session so the relation survives across ABI calls, and the `NOT LIKE 'pg\_%'` exclusion that TOAST had needed hid `pg_temp_N` too. A list of places is a denylist, and this is the same inversion to default-deny that D242 and D243 made to the log sanitizer, for the same reason.

Postgres already knows the answer, in the two catalogues its own `DROP` statements consult, each authoritative for one direction:

  • `pg_shdepend` is what `DROP OWNED BY` reads, so it is everything a role owns, of whatever kind and wherever it lives. Measured on Postgres 17.10 against a role built statement for statement the way EnsureAddonSchema builds one: a temp relation appears as `pg_class` / `pg_temp_N.name`, a large object as `pg_largeobject` / oid, a function as `pg_proc`, the schema itself as `pg_namespace` — and a TOAST relation appears **not at all**, zero rows in the whole database, which is what lets the exclusion be deleted rather than widened.
  • `pg_depend`'s dependency on a namespace is what `DROP SCHEMA` reads, so it is everything that lives in a schema.

`pg_identify_object` turns a `(classid, objid, objsubid)` into a type, a schema and an identity, so *inside its own schema* is Postgres's judgement rather than a string comparison of this function's. Both catalogues and that function are readable by an ordinary role — measured as the add-on's own role, which holds nothing.

What the shape closes is every catalogued way out, and that is the claim

Not *every* way out, and the difference is worth the paragraph, because the argument for asking a shape rather than keeping a list is what would otherwise be overclaimed. Both catalogues are catalogues of **objects**. Something that is in neither is not found here, and there is one measured case: a `WITH HOLD` cursor materialized at commit holds a temporary **file** for the life of the session — 553 MB of `base/pgsql_tmp` for one 600,000-row cursor inside AddonStatementTimeout, measured through a faithful reproduction of AddonDB.Exec, constant across samples and freed only when the backend ended. It needs no privilege, temp *files* are not temp tables so [restrictDatabaseTemp] does not reach it, and this function is empty while it sits on disk. It is transient rather than stored, which is why no gauge covers it and why this is a residual rather than a hole in the claim; the bound that would close it, `ALTER ROLE … SET temp_file_limit`, needs superuser — measured refused to a NOSUPERUSER CREATEROLE role — which is D251's shape rather than a boundary. F279 carries it.

The third direction is grants, and the schema's ACL is the choke point

Ownership is not the whole of *no other add-on reads this*, because a grant is not an object and neither catalogue above records one. The two ownership directions catch only the sub-case where another add-on *creates* a relation in a schema it was granted `CREATE` on. Measured on Postgres 17.10 as two roles built statement for statement the way EnsureAddonSchema builds one: after `GRANT USAGE ON SCHEMA addon_a TO PUBLIC` and `GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA addon_a TO PUBLIC` — one statement each through the write path, no privilege the role does not already hold over its own schema — the second role read and wrote the first's table, and this function's ownership half answered **zero rows**. `pg_roles` is readable, so the other add-on's role name is discoverable too.

So the ACLs are read: `pg_namespace.nspacl` for the schema and `pg_class.relacl` for every relation in it, `aclexplode`d, and any grantee that is not the add-on's own role is a finding. `PUBLIC` is grantee 0 and has no `pg_roles` row, which is why the message coalesces the name.

**It cannot come from `pg_shdepend` even though that catalogue looks right.** A `deptype = 'a'` row records a role *mentioned in an ACL*, but a grant to `PUBLIC` mentions no role and gets no row: measured in the same state, a column-level grant to the other add-on's role appeared as one `'a'` row and the two `PUBLIC` grants that actually leaked the data appeared not at all.

**Two ACL columns and not five, because `USAGE` on the schema is necessary for every path.** With the table and column grants left in place and only `USAGE ON SCHEMA` revoked from `PUBLIC`, the other role's qualified read answers *permission denied for schema* — so nspacl is the gate every reach through has to pass, and relacl is where the privilege on the data itself lives. The columns not read are `pg_proc.proacl`, `pg_type.typacl` and `pg_attribute.attacl`, and reading them would buy less than it looks: `NULL` there is not *no grant*, it is *the default*, and the default for a function is `EXECUTE` to `PUBLIC` — the other role called `addon_a.f()` the moment it had schema `USAGE`, with `proacl` still `NULL`. An enumeration of ACL columns therefore cannot express *no other reader* at all while the gate is open, and closing the gate is what puts every one of them out of reach. A column-level grant is the same shape from the other side: it sets `attacl` and leaves `relacl` `NULL` — measured — so relacl is not complete over grants either, and the schema branch is what makes that not matter. A large object needs no schema, but a role that owns one is already reported by the ownership half, so its ACL is moot.

**A load-time narrowing, not a boundary**, for the same reason [restrictDatabaseTemp] is. Postgres has no way to stop an owner granting on what it owns, and an add-on's data is the add-on's to give: what this adds is that the host *notices*, at the add-on's next load, and refuses it until an operator revokes. The cost is that a grant an operator made deliberately — a reporting role on an add-on's schema — refuses the add-on too, and for a `required` one stops the instance; nothing this product documents asks for such a grant, the finding names the privilege and the grantee, and the remedy is one `REVOKE`. D255.

The two ownership directions are not symmetric, and the asymmetry is measured

`pg_shdepend` records **no row** for an object owned by the bootstrap superuser, and in the compose file's cluster the application *is* that role: 248 relations in `public`, zero `pg_shdepend` rows for `linkctrl`. So the inside direction cannot ask *who owns this* through `pg_shdepend` — in this very cluster the answer would be empty for the case it exists to catch. It asks what is in the schema and subtracts what the role owns instead, and the schema's own owner is read from `pg_namespace` directly. The outside direction is unaffected: an add-on's role is created by `CREATE ROLE` and is never pinned, so everything it owns is recorded — five rows for five objects, measured.

A setting is not an object, and the sixth branch is the only one that sees it

The query below has six branches: what the role owns outside its schema, what is inside its schema that it does not own, a schema it does not own that is its own, `nspacl`, `relacl`, and this one. `pg_shdepend` records a role's *objects* and a session default is not one, so the first three cannot see the thing F288 found however carefully they enumerate, and the two ACL branches read a privilege rather than a parameter. **This comment said *the fourth of five* for exactly as long as it took a reviewer to count**, which is the same class of error one directory over from the one the milestone was reopened for. The sixth reads `pg_db_role_setting` directly, which is the catalogue that holds both scopes: the cluster-wide row `RESET ALL` clears, and the `IN DATABASE` row it does not — see [resetRoleSettings] for why that distinction cost this milestone a reopening. The one row it permits is the search-path pin the load itself writes.

In ordinary operation the branch is silent, because EnsureAddonSchema ran moments earlier in the same load and left exactly the pin. What it catches is a repair that stopped working — a scope nobody swept, a statement that failed while the load carried on — and it catches it as a refused add-on rather than as a number nobody reads. **The shipped test could not have caught either**: it set the cluster-wide variant and read `pg_roles`, so it was blind to the whole catalogue the defect lived in.

The cost is the one D255 already accepted for grants: an add-on that parks a setting between this load's reset and this query refuses itself, and a `required` one stops the instance. That is the same trade — the host notices, and an add-on that sabotages its own confinement does not load — and the remedy is the same shape, one `ALTER ROLE … RESET ALL` per scope the report named, which docs/operations.md spells out. D279.

Indexes are in neither set and need not be: `ALTER INDEX … OWNER TO` answers *cannot change owner of index*, and `ALTER TABLE … OWNER TO` moves the table's indexes and its owned sequences with it — measured both ways.

Why the inside direction is here at all

`pg_dump` carries no roles; that is `pg_dumpall --roles-only`, and the restore procedure docs/deployment.md ships uses neither. Measured: dump a database, drop an add-on's role and schema, restore — the three `ALTER … OWNER TO` lines fail with *role does not exist*, the next boot's `ALTER SCHEMA … OWNER TO` repairs the schema, and **nothing re-owns the tables**. The add-on's role is then refused on its own rows, MigrateAddon fails on `goose_db_version`, and a `required` add-on stops the instance. Asking only the outside direction passes that state, which is why this asks both: the load then says what is wrong instead of failing inside goose. docs/deployment.md tells an operator to restore roles as well.

func AddonLargeObjects added in v0.4.0

func AddonLargeObjects(ctx context.Context, pool *pgxpool.Pool, name string) (int64, error)

AddonLargeObjects is how many large objects an add-on's role owns.

The other half of *stored growth is visible by metric* — the two gauges cover data an add-on has stored and nothing else, which is the qualifier F279 exists under. *And nothing else* was the half that had to be earned: the claim also asserts the two are complete over stored data, and it was false while AddonSchemaBytes enumerated relation kinds instead of excluding the ones that double, because a sequence is stored data in the schema that the enumeration missed (D254).

It is a count rather than a size because a size is not available: `pg_largeobject` holds the bytes and is readable by superusers only — measured as a non-superuser CREATEROLE role, the shape docs/deployment.md requires, `SELECT sum(length(data)) FROM pg_largeobject` answers *permission denied for table pg_largeobject*. `pg_largeobject_metadata` is readable, one row per object, so what an operator gets from this product is *how many* and the ceiling is what AddonStatementTimeout and AddonMaxConns make of it. docs/operations.md gives the superuser query for the bytes.

Nonzero is a defect by construction: nothing in LinkCtrl creates a large object — measured, `pg_largeobject_metadata` is empty on both instances — and the ABI offers an add-on no way to want one. AddonConfinementViolations refuses such an add-on at its next load, so this gauge is what shows the growth *between* loads.

func AddonSchema added in v0.4.0

func AddonSchema(name string) string

AddonSchema is the schema an add-on owns, which is also the name of the role that reaches it.

func AddonSchemaBytes added in v0.4.0

func AddonSchemaBytes(ctx context.Context, pool *pgxpool.Pool, name string) (int64, error)

AddonSchemaBytes is the on-disk size of one add-on's schema — every relation in it that has storage, with its indexes and its TOAST.

Catalogue arithmetic, not a scan, for the reason PartitionedTableBytes is: this is a measurement taken on a schedule and it must stay cheap on the schema it matters most for.

The relkind filter excludes, it does not enumerate

For the reason AddonConfinementViolations asks a shape: a list of the kinds that have storage is a denylist of every kind not on it, and this function kept one — `relkind IN ('r', 'm')` — one function away from where that argument was made. A **sequence** is `relkind 'S'`, lives in the add-on's own schema, holds an 8192-byte page from the moment it is created, and `pg_total_relation_size` of a table does **not** include a sequence that table owns. So a schema of nothing but sequences measured zero: 24,000 of them from three faithful reproductions of AddonDB.Exec, well inside AddonStatementTimeout, moved `pg_database_size` by 188 MB with this gauge reading 0 throughout. Found by M63's fourth review.

**It was never only an adversary's case.** A `serial` or identity column carries a sequence, so for a **well-behaved** add-on the number an operator read was 8192 bytes short per such column from the day the gauge shipped.

Three kinds are excluded, each because counting it would double something already counted. An index — `'i'`, and `'I'` for the parent of a partitioned one — is inside `pg_total_relation_size` of its table. A TOAST relation, `'t'`, is inside its table's as well, and it lives in `pg_toast` rather than here, so excluding it is belt and braces. Everything else either has storage of its own or answers zero: a view, a composite type and a partitioned table each measure 0, and a partitioned table's answer does not include its partitions, which are counted as the ordinary tables they are.

Measured before shipping, against a schema holding all of that at once: this sum equals `sum(pg_table_size(oid))` over every relation in the schema, which counts each relation exactly once and each index as itself rather than through its parent. TestSchemaSizeCountsEveryRelationWithStorage asserts that identity, and it bites in both directions — short if a kind with storage is dropped, long if anything is counted twice.

func AddonSchemaSuffix added in v0.4.0

func AddonSchemaSuffix(schema string) string

AddonSchemaSuffix is the add-on name inside a schema name, or "" for a schema that is not an add-on's.

The inverse of AddonSchema, and it exists so the orphan report can name the add-on an operator would look for rather than the schema they have never typed.

func AddonSchemas added in v0.4.0

func AddonSchemas(ctx context.Context, pool *pgxpool.Pool) ([]string, error)

AddonSchemas is every schema in this database that belongs to an add-on.

The enumeration m63.md's orphan bullet needs: subtract the loaded add-ons from this and what remains is data whose module is gone. Nothing here deletes anything — a purge is an operator's explicit act, and M68's flow.

The underscore in the prefix is escaped, because in LIKE it is a wildcard and an unescaped one would also match a schema called `addonx`.

func DecodeAddonArgs added in v0.4.0

func DecodeAddonArgs(raw []byte) ([]any, error)

DecodeAddonArgs turns the JSON array an add-on passes into query arguments.

Numbers arrive as json.Number and are converted to int64 where they are whole, because encoding/json's default is float64 and a float64 bound to a bigint column is a value nobody wrote. Anything else crosses as itself: a string, a bool, or null.

It lives in this package rather than in the host because the shape it produces is a pgx argument list, and the rule about what pgx does with each Go type is this layer's to know.

func DefaultPartitionCounts

func DefaultPartitionCounts(ctx context.Context, pool *pgxpool.Pool) (map[string]int64, error)

DefaultPartitionCounts reports how many rows sit in each default partition.

A non-zero count is an operational alert, not a curiosity: it means rows arrived outside every explicit range, and attaching the partition that should have held them will now fail until they are moved out.

func Down

func Down(ctx context.Context, dsn string) error

Down rolls back the most recent migration.

func DropExpiredPartitions

func DropExpiredPartitions(ctx context.Context, pool *pgxpool.Pool, policy RetentionPolicy, now time.Time) ([]string, error)

DropExpiredPartitions drops monthly partitions whose entire range is older than their table's retention window, and reports what it dropped.

A window of zero or less keeps that table forever, matching the configuration contract that 0 means "forever". A table absent from the policy is never touched at all, which is what keeps a partitioned table added later from silently inheriting somebody else's window.

Retention is enforced at month granularity, and only when the newest row a partition could hold is already outside the window. The alternative — deleting rows older than exactly N days — would mean a DELETE across the largest table in the system, then a VACUUM to reclaim the space, on a schedule. Dropping a partition is instant, reclaims the space immediately, and cannot half-finish. The cost is that data survives up to a month past the nominal window, which is the right way to be wrong: keeping data slightly too long is recoverable, and deleting it slightly too early is not.

Daily rollups live in their own unpartitioned tables and are untouched, so historical charts keep working after the raw events are gone.

func EnsureAddonSchema added in v0.4.0

func EnsureAddonSchema(ctx context.Context, admin *pgxpool.Pool, name string, log *slog.Logger) (string, error)

EnsureAddonSchema creates the schema and the role an add-on is confined to, and returns the password its own pool must authenticate with.

Idempotent, which is what makes a second boot and a re-load cheap: the role and the schema are created when absent and left alone when present. The password is **not** idempotent and that is the design — a fresh one is generated every time this is called, so the credential *this host* uses lives no longer than the process that uses it and nothing has to store it. There is nowhere to store it that would not be a new secret for an operator to manage.

**That is a claim about the host's credential and not about the role's**, and it once read as both. Postgres lets any role change its own password and offers no way to forbid it: measured through AddonDB.Exec's path as the confined role, `ALTER ROLE CURRENT_USER PASSWORD 'x'` is accepted and a session then authenticates with `x`, while `NOLOGIN`, `CONNECTION LIMIT 0` and a read of `pg_authid` are each refused. So a password an add-on set itself does outlive the process — it sits in `pg_authid` until the next load's `ALTER ROLE … PASSWORD` replaces it. `PASSWORD NULL` is accepted too, after which every connection gets 28P01 and [AddonDB.acquire] re-mints, so the availability half self-heals. F280 carries what the exposure is worth, which is more than this schema.

On more than one replica that also means **the newest boot invalidates every other replica's credential**, which is why [AddonDB.acquire] re-mints on 28P01 rather than treating a refused connection as the add-on's problem. D250 has the measurement and the two shapes that were rejected.

Runs as the application's own database user, which therefore needs CREATEROLE (or superuser). docs/deployment.md names that requirement; an instance that cannot meet it cannot load an add-on that asks for storage, and the failure says so rather than degrading into an unconfined one.

It also **clears every role-level setting, in every database**, before pinning the search path, so a parameter the add-on set on its own role does not outlive the load that found it — [resetRoleSettings] is that, and *in every database* is the half this sentence claimed for a phase without doing.

And it narrows the database once, which is the one statement here that is not about this add-on alone — see [restrictDatabaseTemp].

func EnsurePartitionRange

func EnsurePartitionRange(ctx context.Context, pool *pgxpool.Pool, from, to time.Time) (int, error)

EnsurePartitionRange creates monthly partitions covering every month from `from` to `to` inclusive, plus a default partition per table.

Separate from EnsurePartitions because the months that need to exist are not always the ones around today: restoring a backup and seeding a load-test dataset both write into the past, and an insert with no matching partition lands in the default one, where it silently blocks attaching the partition that should have held it.

func EnsurePartitions

func EnsurePartitions(ctx context.Context, pool *pgxpool.Pool, ahead int) (int, error)

EnsurePartitions creates monthly partitions for the current month and the next `ahead` months, plus a default partition per table. It reports how many it created and is safe to call repeatedly.

Two things here are load-bearing.

The session timezone is pinned to UTC for the DDL. Bounds on a timestamptz column resolve against the session timezone at DDL time, so the identical bound literal produces a different absolute range under a different timezone, leaving either a gap that silently routes rows to the default partition or an overlap that makes attaching fail. Demonstrated in docs/adr/0001-partitioning-and-sqlc.md.

It looks more than one month ahead. Creating next month's partition on the last day of this one is a single point of failure with a hard deadline; two months of headroom turns a missed run into a warning rather than an outage.

func Migrate

func Migrate(ctx context.Context, dsn string, log *slog.Logger) error

Migrate applies all pending migrations, then ensures partitions exist.

Runs in-process at boot, before the listener opens. An init container would need either a shell (distroless has none) or a second image, plus depends_on wiring that confuses a first-time operator; in-process means `docker compose up` on an empty volume produces a working app with no extra concepts.

A Postgres session lock serializes replicas racing at startup, so a rolling deploy cannot run the same migration twice.

func MigrateAddon added in v0.4.0

func MigrateAddon(ctx context.Context, dsn, name, password string, fsys fs.FS, log *slog.Logger) error

MigrateAddon applies an add-on's own migrations inside the schema it owns.

The same discipline as Migrate, deliberately: in-process, before the listener opens, and serialized across replicas by a Postgres session lock. What differs is who runs them. The connection authenticates as the add-on's role, so the DDL is bounded by the same privileges the add-on's queries are — a migration naming another schema is refused by Postgres rather than by a check this package would have to write, and a `SECURITY DEFINER` function it creates is owned by the add-on's role and therefore escalates to nothing.

goose's bookkeeping goes in the add-on's schema too, which is what makes a re-load idempotent and an orphaned schema self-describing: the versions applied to it are inside it, so nothing about an add-on's state lives in a table the product owns.

func PartitionName

func PartitionName(table string, at time.Time) string

PartitionName returns the partition a timestamp belongs to.

func PartitionedTableBytes added in v0.2.0

func PartitionedTableBytes(ctx context.Context, pool *pgxpool.Pool, table string) (int64, error)

PartitionedTableBytes reports the on-disk size of a partitioned table: every partition, including indexes and TOAST.

This exists because the audit log's retention default is "keep forever", and that default is only defensible if the growth it permits is visible. An operator who never sets AUDIT_RETENTION_DAYS has chosen unbounded growth, and they should find that out from a graph rather than from a full disk.

Summed over the partitions rather than read from the parent: a partitioned table has no storage of its own, so pg_total_relation_size on the parent answers 0 no matter how much data is underneath it.

Catalogue and free-space-map arithmetic only — no scan of the table — so this stays cheap on the table it is most needed for.

func PurgeAddonSchema added in v0.4.0

func PurgeAddonSchema(ctx context.Context, admin *pgxpool.Pool, name string) error

PurgeAddonSchema drops one add-on's schema and everything in it.

M68's one destructive mechanism, and the only statement in this product that deletes an add-on's data. Two callers reach it and they are the same act on two surfaces — the Add-on manager's orphan list and `DELETE /api/v1/addons/orphaned-data/{name}` behind it, both through [addon.Host.PurgeData] and both under `addons.manage`. What it is never reached by is a *removal*: that deliberately leaves the schema standing (M63, and lifecycle.go's Remove), so a purge is always a second, explicit act taken against something an operator can already see the size of. An operator with a `psql` prompt has a third route and this function is not it — docs/operations.md carries the three statements and says why all three.

What it drops, and what it deliberately leaves

`DROP SCHEMA … CASCADE`, which is every relation, sequence, function, view and type the add-on created inside it. Four things survive and each is named because a purge that quietly left them would be worse than one that says so:

  • **The login role.** `addon_<name>` keeps existing, with its password, its membership and its grants. Dropping it is a `DROP ROLE`, which fails while the role owns anything anywhere — a large object, a temp relation — and which would make this operation's success depend on state AddonConfinementViolations exists to police. The role owns nothing an add-on can reach without its schema, and re-installing the same add-on re-uses it, which is what EnsureAddonSchema's existence check is for.
  • **Large objects the role owns.** They live outside every schema by construction, which is exactly why AddonLargeObjects counts them separately. A well-behaved add-on owns none; one that owns any is refused at load, so a purge is not the tool that cleans up after it.
  • **`addon_identity_links` rows written under this name.** They are the host's, not the add-on's, and they are the subject of F330 rather than of this function.
  • **`addon_settings` rows written under this name.** The host's as well (04800), keyed on the name for the reason the mappings are, and inherited by whatever is installed under the name next — including a stored `secret`. **Nothing in this product deletes one**, which is F332; the manager counts them at the point of decision the way it counts the mappings.

Refusing to purge a schema something still owns

The caller establishes that the schema is an *orphan*; this refuses to run against a name that resolves to a loaded add-on only in the sense that it cannot — it takes a name and drops that name's schema, and the manager is what decides which names are offered. The safety that is here is the one that can be stated in a statement: [checkAddonName] runs first, so the identifier interpolated below is `addon_` plus a validated name and cannot be anything else. Nothing about a schema name is caller-supplied text.

func Status

func Status(ctx context.Context, dsn string) ([]string, error)

Status reports applied and pending migrations.

func ValidAddonName added in v0.4.0

func ValidAddonName(name string) bool

ValidAddonName reports whether a string is a name an add-on could have.

The grammar itself, exposed because two things outside this package rest on it rather than merely on [checkAddonName] refusing at the last moment. The Add-on manager's orphan endpoints live on a path segment that has to be unclaimable by any add-on, and *unclaimable* is a property of this expression — a hyphen is not in it — rather than of a reserved list somebody keeps. A test asserts the segment against this rather than against a comment saying so.

Types

type AddonDB added in v0.4.0

type AddonDB struct {
	// contains filtered or unexported fields
}

AddonDB is one add-on's confined connection to its own schema.

Held for the life of the host, closed with it. The pool authenticates as the add-on's role — see this file's header for why nothing weaker is a boundary — and every statement runs inside a transaction that pins the search path and the statement timeout locally, so nothing a previous statement left on a pooled connection changes what the next one means.

func OpenAddonDB added in v0.4.0

func OpenAddonDB(ctx context.Context, admin *pgxpool.Pool, dsn, name, password string, log *slog.Logger) (*AddonDB, error)

OpenAddonDB opens an add-on's own pool and proves it works.

The Ping is not a courtesy. Password authentication has to be available for the add-on's role — a deployment authenticating by peer or by IAM cannot offer it — and finding that out at boot is what lets a `required` add-on's failure class do its job. Discovering it at the add-on's first query instead would mean an instance that booted clean and then refused every call.

`admin` is the application's own pool and it is what makes this survive a second replica. EnsureAddonSchema mints a fresh password on every load, so replica B booting invalidates the credential replica A is holding — measured: after `ALTER ROLE … PASSWORD`, a connection with the old one is refused with `FATAL: password authentication failed` (SQLSTATE 28P01). With [AddonDB.acquire] re-minting on exactly that code, A recovers on its next connection instead of failing quietly for the rest of its life. D250 records the alternatives and why this one needs no new secret and no new table.

func (*AddonDB) Close added in v0.4.0

func (a *AddonDB) Close()

Close releases the add-on's connections.

func (*AddonDB) Exec added in v0.4.0

func (a *AddonDB) Exec(ctx context.Context, statement string, args []any) error

Exec runs one write.

func (*AddonDB) Query added in v0.4.0

func (a *AddonDB) Query(ctx context.Context, statement string, args []any) ([]byte, error)

Query runs one read and returns its rows as a JSON array of objects.

Read-only at the server, which is what separates this from AddonDB.Exec rather than a promise about what the statement looks like: a transaction begun READ ONLY refuses a write whatever the SQL says, so an add-on cannot use the read function to write and the ABI's two functions mean two different things.

A payload carrying more than one statement is refused by Postgres, because OpenAddonDB parses through the extended protocol. That matters more than it sounds: `RESET ROLE` as a second statement is the obvious escape, and it is refused twice over — once here and once by the role being the session's own.

func (*AddonDB) Schema added in v0.4.0

func (a *AddonDB) Schema() string

Schema is the schema this connection is confined to.

type RetentionPolicy added in v0.2.0

type RetentionPolicy map[string]int

RetentionPolicy maps a partitioned table to the number of days its data is kept. Zero or less keeps that table forever, matching the configuration contract that 0 means "forever".

A map rather than one number and a list of tables, because the two policies have different defaults and answer to different settings: 395 days from ANALYTICS_RETENTION_DAYS, and forever from AUDIT_RETENTION_DAYS. Expressing that as a single window over a table list was how audit_logs came to be exempt-by-omission, which worked only for as long as there was exactly one window.

func NewRetentionPolicy added in v0.2.0

func NewRetentionPolicy(analyticsDays, auditDays int) RetentionPolicy

NewRetentionPolicy builds the policy from the two configured windows.

Directories

Path Synopsis
Package pgerr classifies Postgres errors, and it is one function.
Package pgerr classifies Postgres errors, and it is one function.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL