Skip to content

Memory contexts

PgMemoryContext represents a PostgreSQL memory context. Use Create to own a child context and PgAllocation to copy unmanaged values or byte buffers into its native storage:

[PgFunction]
public static int NativeValue(int value)
{
using PgMemoryContext context = PgMemoryContext.Create("native value");
using PgAllocation allocation = context.AllocateZeroed(sizeof(int));
allocation.Write(value);
return allocation.Read<int>();
}

These APIs require a synchronous callback on PostgreSQL’s backend thread. They are available independently of SPI, including initialization and configuration hooks. They do not enable database queries in callback phases where PostgreSQL prohibits queries. Do not call them from a worker thread or after await.

The memory contexts sample ports pgrx’s memory_contexts example. It resets scratch contexts after temporary work, returns sets, and keeps a background worker’s counter in TopMemoryContext. It also shows a stale allocation rejected after reset.

For a growable PostgreSQL binary buffer, use PgStringInfoStream. It supports ordinary stream writes, strict UTF-8 text, checked byte copies, and explicit native ownership transfers.

For native PostgreSQL List containers, use PgList<T>. Its typed cells retain native integer, OID, transaction-ID or pointer identity. Container growth keeps the original context owner, and context cleanup invalidates both owned and borrowed handles. Pointer elements keep their independent ownership.

PgNativeItemPointer supplies checked native tuple-location storage whose fields use the selected server’s ItemPointerData layout. It supports independent copies, borrowed views and raw ownership transfer under the context lifetime rules below.

Current resolves PostgreSQL’s current context at the point of the call. Get(PgMemoryContextKind) resolves predefined contexts such as Top, TopTransaction, and CurTransaction; it returns null when the selected context does not exist in the current phase. Parent returns a borrowed parent handle.

Functions, operators, and casts can receive a borrowed PgMemoryContext as an injected parameter. A scalar call receives its current context. A set factory and its GetEnumerator run in the multi-call context, which remains alive across row requests. An injected handle keeps that context’s identity even when Run or a nested backend call changes the ambient context. Use a fresh Current lookup when you need the context selected at that later point.

For pgrx users, this is the C# counterpart of a virtual &MemCx argument. Ankus supplies a checked owner snapshot; pgrx’s reference follows the native current-context pointer slot. Nullable C# annotations never change the supplied owner or inferred SQL strictness, which depends on SQL inputs only.

Create(name, parent, options) creates an AllocSet child without changing the current context. Omit parent to use the current context. Dispose an owned context to delete it and its descendants. Disposing a borrowed handle leaves the native context intact. Contexts have no finalizers: the garbage collector must never call PostgreSQL from its finalizer thread. PostgreSQL still reclaims a context when its parent is deleted, even if a managed handle remains reachable.

PgMemoryContextOptions supplies the minimum retained context size, initial block size, and maximum block size. Its Default, Small, and StartSmall presets match PostgreSQL’s AllocSet presets. Sizes must satisfy the selected server’s native alignment and range limits; they need not be powers of two. Invalid sizes throw before reaching PostgreSQL’s allocator assertions.

Use Run to select a context temporarily:

using PgMemoryContext context = PgMemoryContext.Create("temporary work");
int result = context.Run(() =>
{
using PgAllocation value = PgMemoryContext.Current.Allocate(sizeof(int));
value.Write(42);
return value.Read<int>();
});

Run restores the previous context on ordinary return and managed exceptions. It supports nested calls. Deleting the current context or an ancestor is rejected; after leaving the scope, disposal can be retried. Native callback storage and its ancestors cannot be reset while the callback is active. Live set-function owners and their ancestors also remain protected between cursor fetches: their executor storage must survive until PostgreSQL closes the invocation. This includes ResetOnly on an ancestor. Resetting children of a suspended set owner preserves that owner but invalidates affected child storage. PostgreSQL’s infrastructure contexts, including its transaction, portal, cache, message and error contexts, cannot be reset or deleted through these checked APIs. Create a child context to own storage that your extension can reclaim.

PostgreSQL error recovery resets ErrorContext (PgMemoryContextKind.Error) and deletes its children. If a guarded operation fails while one of those children is current, Ankus resumes in the surviving ErrorContext; the child and its allocations become stale. The enclosing Run restores its original caller when that caller remains alive.

Handles may be reused in a later synchronous callback from the same extension and backend while their native owner remains alive. A child of TopTransaction survives multiple SQL calls in that transaction. A child of CurTransaction expires when its subtransaction is rolled back. IsAlive checks native context identity; transaction cleanup and reuse of a native address cannot revive an old handle. Borrowed contexts reset implicitly by PostgreSQL must be resolved again.

Expired-handle checks and rejection of protected context operations validate Ankus’s registry before invoking PostgreSQL. They do not raise a backend error or require transaction rollback. Actual PostgreSQL errors, including allocator and encoding failures, follow the recovery rules below.

RunTransient combines creation, selection, restoration, and deletion:

int result = PgMemoryContext.RunTransient("temporary values", context =>
{
using PgAllocation values = context.CopyFrom<int>([10, 20, 30]);
return values.Read<int>(sizeof(int));
}, options: PgMemoryContextOptions.Small);

Return copied managed data. Checked handles that escape become stale after successful deletion. The helper attempts deletion after both normal return and exceptions, preserving callback, restoration, and deletion failures together. If a cleanup callback throws, older callbacks and the context can remain pending; the parent still owns that native context until cleanup is retried.

Operation Selected context Descendant contexts
Reset() Retained; allocations invalidated Deleted
ResetOnly() Retained; allocations invalidated Unchanged
ResetChildren() Unchanged, including allocations Retained; allocations invalidated
Owned Dispose() Deleted Deleted

An affected allocation rejects later reads, writes, owner lookup, and resizing with ObjectDisposedException. Disposing an already reclaimed allocation or owned context is harmless. Newly allocated bytes use a new identity. Context names remain readable across explicit resets.

Allocate returns uninitialized storage; write it before reading. AllocateZeroed clears every byte. TryAllocate uses PostgreSQL’s no-OOM allocation flag and returns null when that allocator returns null. Invalid sizes and other native errors still throw PgException. Allocation, resize, and free use PostgreSQL’s matching allocator, and errors are caught in native code before managed execution resumes.

A caught native error still requires PostgreSQL rollback. To recover and continue within the same managed callback, put the operation inside PgTransaction.RunInSubtransaction and catch its exception outside the scope. Without that scope, Ankus blocks further backend work and reports the original error when the callback returns, even if a C# catch swallowed it. Owned disposal and context restoration remain available while managed finally blocks unwind.

Allocate<T>(count), AllocateZeroed<T>(count), and TryAllocate<T>(count) check multiplication by sizeof(T) before native access. CopyFrom copies a byte span or an unmanaged value span into independent native storage. These APIs copy unmanaged bytes: they do not infer SQL types or guarantee that an arbitrary .NET struct matches a PostgreSQL C layout. Allocation lengths and access offsets remain measured in bytes.

Use PgAllocationOptions.Zeroed to clear initial storage and Huge to permit PostgreSQL’s larger allocation size limit. Huge allocations still depend on available memory. The alignment argument accepts zero for native default alignment, or a power of two below 128 MiB. Alignment above the server’s native default requires PostgreSQL 16 or later. Aligned TryAllocate and TryReallocate require PostgreSQL 16.15, 17.11, 18.6, or 19 beta 3 or later because earlier releases have unsafe native no-OOM handling. PostgreSQL 19 development snapshots are also rejected for these requests, because their version number cannot show whether the fix is present. These requests raise SQLSTATE 0A000 instead of returning null or acquiring an over-aligned allocation; ordinary alignment remains available on older servers.

AllocateUtf8String copies strict UTF-8 bytes followed by one zero byte. It rejects embedded NUL and malformed UTF-16. This is raw UTF-8 copying, including in a LATIN1 database; it does not convert to the database encoding.

Read and Write copy spans or unmanaged values after checking the live chunk and its bounds. Reallocate preserves the existing prefix when it succeeds; an allocation error preserves the old chunk. It can be used after rollback if its owner was created outside the recovery scope. Clear clears a selected range; omitting its length clears through the end. Zero-length allocations and empty copies are supported when the native allocator accepts their size. Context returns the owner established when the allocation was created or adopted.

Resizing preserves the original huge policy and alignment. Pass zeroNewMemory: true to clear only the newly added tail; shrinking is still allowed. TryReallocate returns false only when the native allocator returns null, preserving the old storage, length, and contents. Invalid sizes and other native failures still throw. Options and Alignment describe the original allocation policy; initial zeroing does not automatically zero future growth.

TryReallocate and resizes with alignment above the server’s native default allocate replacement storage, copy the retained prefix, and then free the old chunk. Budget for both chunks while resizing, especially with Huge. Zeroed allocation and zeroed growth also write the requested bytes; the huge size policy does not make those operations sparse.

GetAllocatedBytes() includes descendant context storage and native allocator overhead. IsEmpty returns PostgreSQL’s native emptiness result. Registering the invalidation callback marks an AllocSet context nonempty, including after an explicit reset. Other native allocators determine emptiness from their own blocks or live chunks, so this property does not establish a portable count of user allocations. These statistics describe native contexts, not the managed heap. A resize can release a large block while allocating a regular block for its replacement; the reported byte change includes both operations.

PgMemoryContext.DangerousBorrow(address) wraps a native MemoryContext address, such as one obtained through raw bindings, as pgrx’s PgMemoryContexts::For does. The handle can allocate, run code and register callbacks; it survives a reset and becomes stale when the context is deleted. DangerousGetOwner(allocation) finds the context that owns a palloc’d address, as GetMemoryChunkContext does, and DangerousGetPointer() returns a handle’s address for raw PostgreSQL calls. Ankus checks only the context’s node tag, so the address must belong to a live context or allocation in this backend. A Bump context does not record its chunks’ owner, so DangerousGetOwner cannot be used with its allocations.

These three calls require an unsafe context, such as an unsafe block or method (ANKUS129).

Create and RunTransient construct AllocSet contexts. Borrowed handles can also refer to PostgreSQL’s Slab, Generation, or Bump contexts created by native code. Their operations retain the native allocator’s restrictions:

Allocator Allocation and resize Reclamation
Slab Requests must match its configured chunk size; ordinary same-size resize preserves the pointer Individual free or context cleanup; PostgreSQL 16+ may cache empty blocks
Generation Variable sizes and prefix-preserving resize Individual free counts released chunks; whole blocks are reused or reclaimed; PostgreSQL 15+ retains a keeper block
Bump, PostgreSQL 17+ Variable-size allocation; every resize is rejected Context reset or deletion only; individual Dispose() is rejected

TryAllocate and TryReallocate propagate unsupported-operation and invalid-size errors. They return null or false only for allocator exhaustion. Failed resize or disposal preserves the original handle, length, and bytes. Explicit alignment does not remove these restrictions: padding a Slab request can violate its fixed size, and an aligned Bump chunk still cannot be individually freed or resized.

For Bump storage, prefer CreateContextValue or TryCreateContextValue. Checked access, Context, borrowing, and DangerousDetach remain available. Raw adoption is rejected because Bump does not provide a retrievable chunk owner. Use DangerousBorrow with a proven context lifetime for an existing native Bump address. This remains an unsafe provenance obligation; an anchor cannot establish which allocator created an arbitrary address.

Ankus checks the recorded owner before requesting an unsupported Bump operation. Ordinary PostgreSQL builds omit Bump chunk headers, so attempting pointer-based free or resize first would be unsafe even inside an error guard. Reset and deletion still invalidate checked allocations and borrowed generations before later access.

A checked allocation’s DangerousGetPointer() returns its address for unsafe interop. The pointer is valid only until free, resize, or context cleanup. The caller must enforce its lifetime and alignment; checked managed operations do not make a retained raw pointer safe.

DangerousDetach() consumes the checked owner and transfers its raw pointer without freeing the allocation. PostgreSQL still owns the storage through its context. A recipient can take exclusive individual ownership with context.DangerousAdopt(pointer, length, huge, alignment). Adoption checks the native context and rejects a pointer that already has a checked owner. Failed adoption leaves ownership with the caller.

These are unsafe interoperability operations: the caller must guarantee a live palloc-family pointer compatible with pfree, the accessible byte length, original alignment and size policy, and exclusive ownership. A context lookup cannot validate an arbitrary pointer. Do not free or resize a raw pointer while a checked owner remains live. Context-only allocators such as PostgreSQL’s Bump context do not provide the individual-free contract required here.

Use a typed wrapper when one allocation holds one unmanaged value:

Type Ownership Cleanup
PgNativeBox<T> One individually owned native allocation Dispose() frees it; its native context also reclaims it
PgContextValue<T> Storage owned by the native context Context reset or deletion reclaims it; the wrapper has no Dispose()
PgVarlena<T> A registered native-layout SQL value, borrowed or independently allocated Input views expire at callback exit; independent allocations support Dispose() and follow context lifetime
PgNativeReference<T> A borrowed view with no release rights The underlying allocation or explicit lifetime context governs access
using PgMemoryContext context = PgMemoryContext.Create("boxed value");
using PgNativeBox<int> box = context.CreateBox(5);
PgNativeReference<int> reference = box.Borrow();
PgContextValue<int> retained = box.ReleaseToContext();
reference.Value = 21; // retained.Value is now 21
box.Dispose(); // the consumed owner cannot free retained storage
PgContextValue<int> snapshot = reference.CloneInto(context);
reference.Value = 42; // snapshot.Value remains 21

CreateBox and CreateContextValue copy an initialized unmanaged value into native memory. TryCreateBox and TryCreateContextValue return null only when the native allocator returns null; initialization and other errors still throw. AllocateZeroedBox<T> starts with zero bytes. DangerousAllocateUninitializedBox<T> requires initialization before reading. These factories do not run a constructor in native storage. Reclamation never calls the value’s Dispose() method or recursively releases embedded pointers. Use a context cleanup callback for managed cleanup actions.

Value reads or writes a copy. It does not expose a managed reference or span whose lifetime could escape the checks. CloneInto returns a context-owned shallow copy; CloneOwnedInto returns an individually disposable copy. Both copy exactly sizeof(T) bytes, including padding and embedded pointer values, into the specified context or Current when omitted. Clones use the destination’s default allocation policy and alignment. They do not duplicate pointed-to data.

allocation.Borrow<T>(offset) creates a typed view at a byte offset. It checks the range and native identity at creation and on later access. A checked view follows its allocation when resizing moves the chunk; shrinking below its range rejects access. ReleaseToContext consumes the owning wrapper while retaining the checked allocation and existing views. Raw DangerousDetach consumes the allocation’s checked tracking, including shared views, without freeing storage. Garbage collection of any wrapper never invokes PostgreSQL.

For unsafe interoperability, context.DangerousAdoptBox<T>(address) takes individual ownership of a valid palloc-family chunk. A null address returns a null managed reference without a backend call. DangerousAdoptContextValue<T> requires a non-null compatible chunk and transfers it to context ownership. The existing adoption requirements for provenance, native owner, alignment, size policy and exclusivity apply.

context.DangerousBorrow<T>(address) borrows without acquiring ownership or inspecting an allocation header. A null address returns null. Non-null addresses can refer to stack, interior, or resource-owned memory when the caller guarantees accessible, initialized bytes and the required external lifetime. The resulting reference’s LifetimeContext is an explicit lifetime anchor, not an inferred allocator owner. A captured reset generation makes the view stale after context reset even when the context itself survives. No native record is allocated for each borrowed view.

The overload DangerousBorrow<T>(address, byteLength) records an explicitly guaranteed accessible extent, which must contain the complete T value. Use it when a raw base-node view covers a larger native object. Checked PgNodes casts retain that extent and the original reset generation; casting never creates additional storage or renews an expired lifetime.

The anchor does not detect a shorter lifetime caused by stack return, external free or resize, or closing a separate native resource. The unsafe caller must prevent later access in those cases. Failed context cleanup does not invalidate references prematurely: if a cleanup callback throws before native invalidation, the payload and its views remain live until cleanup proceeds.

An unmanaged .NET type does not establish a PostgreSQL C layout or SQL type. These wrappers copy raw bytes; arbitrary boxed values are not automatically SQL arguments or results. Use native PostgreSQL declarations, PgVarlena<T> or a reusable datum mapping when a value needs a PostgreSQL layout or SQL type.

RegisterResetCallback(Action) connects managed cleanup to PostgreSQL’s native context lifetime. The returned PgMemoryCallback remains pending until the context resets or is deleted, or until you cancel the registration:

using PgMemoryContext context = PgMemoryContext.Create("context cleanup");
var events = new List<string>();
PgMemoryCallback first = context.RegisterResetCallback(() => events.Add("first"));
PgMemoryCallback second = context.RegisterResetCallback(() => events.Add("second"));
context.Reset(); // events contains "second", then "first"
bool pending = first.IsPending || second.IsPending; // false

Callbacks run once in reverse registration order within each context. PostgreSQL also invokes callbacks registered by a callback during the same drain. The action is rooted even if application code drops its registration object. Its root is released and IsPending becomes false before user code runs, so self-disposal does not cancel or invoke the action again. Dispose() cancels a pending action without running it; repeated disposal is harmless. Cancellation releases managed references immediately, while a small native cancellation record remains until the context next resets or is deleted. Registrations have no finalizer.

During ordinary reset or deletion, an exception consumes that callback and stops the native drain. Older callbacks remain pending; retrying cleanup invokes them. Already deleted child contexts stay deleted. The diagnostic becomes a PostgreSQL error after the managed callback returns. An explicit managed Reset() or owned Dispose() receives a PgException; recovering requires the surrounding subtransaction to roll back before retrying.

Commit and abort cleanup must finish draining the transaction’s callbacks. A secondary callback failure is written to standard error, which is the server log when PostgreSQL captures it, as lines prefixed ANKUS CLEANUP [backend <pid>] with its SQLSTATE and diagnostic fields. This emergency path does not allocate PostgreSQL diagnostic state, convert through the client encoding, invoke emit_log_hook, or flush an active PostgreSQL error. It therefore cannot replace the original SQL error, interrupt rollback, or turn a completed commit or PREPARE TRANSACTION into a cluster restart. Backslashes, newlines, carriage returns and tabs are written as \\, \n, \r and \t, and other control characters and non-ASCII bytes as \xNN.

A real native cancellation request remains pending until a later interrupt boundary. A callback exception with SQLSTATE 57014 is logged without creating a cancellation request. During abort, FATAL terminates that backend and PANIC requests PostgreSQL crash recovery. After a durable commit or preparation, FATAL is promoted to PANIC because PostgreSQL can no longer abort that transaction during backend exit. Durable changes remain durable. See the transaction-completion contract. Write callbacks to release their resources without throwing. A Fatal or Panic PgLog.Write inside a cleanup callback still terminates the backend or requests crash recovery, even if managed code catches its exception. This does not grant ordinary logging or SQL access during cleanup.

Cleanup permits checked native allocation access and disposal of retained SPI plans and cursors when the registration was created with backend access. Queries, logging, configuration reads, and aggregate operations are unavailable. The resetting context’s payload remains readable until its invalidation callback runs. Destructive operations on the active owner or its ancestors, switching into the context being reclaimed, and child creation within the active cleanup tree are rejected. Independent contexts can still be used and reset. Iterator and aggregate cleanup has the same native owner protection, including during transaction abort. Unsafe raw calls retain their native transaction and ownership preconditions. Releasing owned storage with a valid pfree call remains supported during abort; the raw guard contains errors, but cannot make an arbitrary native operation safe in a transaction that PostgreSQL is dismantling. Checked lifetime access and owned-resource release remain available. Ordinary reset during initialization or idle backend execution retains its existing native-call and error contract.

A callback owned by ErrorContext or one of its children runs inside the error handler’s cleanup phase. It can release managed resources, but guarded memory operations and SPI resource disposal throw PgException with SQLSTATE 55006 in that phase. Those operations could otherwise consume PostgreSQL’s active error stack or recursively reclaim the callback’s own storage. A failed disposal leaves the resource available for a later allowed phase. Choose an ordinary owned context for cleanup that needs native memory or SPI resources. PostgreSQL 19 also resets ErrorContext after an emitted outermost notice or warning; earlier supported versions reset it during error recovery.