Skip to content

Memory & Thread Safety

PostgreSQL extensions run inside a shared-memory, multi-process server. A function that leaks memory degrades the entire backend. A function that uses global state cannot be parallelized. pg_orbit is designed to be a well-behaved citizen: all memory goes through PostgreSQL’s allocator, and no mutable state survives between function calls.

All heap allocation goes through palloc() / pfree(). No malloc(), no new, no static buffers. This is not a convention --- it is a hard requirement. PostgreSQL’s memory context system tracks every allocation and frees entire contexts at transaction boundaries, query completion, or error recovery. Using malloc() would create memory that PostgreSQL cannot reclaim on error, leading to gradual backend bloat.

Functions like sgp4_propagate() and tle_distance() follow the simplest pattern:

double *params = palloc(sizeof(double) * N_SAT_PARAMS);
/* Initialize and propagate */
SGP4_init(params, &sat);
err = SGP4(tsince, &sat, params, pos, vel);
pfree(params);

The params array (~92 doubles, ~736 bytes) lives in the current memory context. It is allocated before propagation and freed before the function returns. If an ereport(ERROR) fires between palloc and pfree, PostgreSQL’s error recovery frees the current context automatically.

SRF functions like sgp4_propagate_series(), ground_track(), and predict_passes() must maintain state across multiple calls. They use PostgreSQL’s multi_call_memory_ctx:

typedef struct {
tle_t sat;
double params[N_SAT_PARAMS]; /* embedded, not separate allocation */
int is_deep;
double epoch_jd;
int64 start_ts;
int64 step_usec;
} propagate_series_ctx;

The lifecycle:

funcctx = SRF_FIRSTCALL_INIT();
oldctx = MemoryContextSwitchTo(funcctx->multi_call_memory_ctx);
ctx = palloc(sizeof(propagate_series_ctx));
/* Copy TLE and observer into ctx */
/* Initialize propagator */
MemoryContextSwitchTo(oldctx);
funcctx->user_fctx = ctx;

Input functions (tle_in, eci_in, etc.) allocate the result struct with palloc() in the current context. PostgreSQL manages the lifecycle --- the struct may be copied into a tuple for storage or used transiently for a computation.

pg_tle *result = (pg_tle *) palloc(sizeof(pg_tle));
/* Parse input text into result */
PG_RETURN_POINTER(result);

There are no file-scope variables, no static locals that accumulate state, no caches. Every function computes from its arguments alone.

This guarantee has three consequences:

All 57 pg_orbit functions are declared PARALLEL SAFE in the SQL definition. This tells PostgreSQL’s query planner that the function can be executed in parallel worker processes without coordination. For bulk operations like propagating 12,000 TLEs, the planner can distribute work across multiple CPU cores:

-- PostgreSQL may parallelize this across available cores
SELECT tle_norad_id(tle),
eci_x(sgp4_propagate(tle, now())) AS x_km
FROM satellite_catalog;

If any function used global state --- even a read-only cache --- PostgreSQL would need to serialize access or copy state between workers. PARALLEL SAFE cannot be declared for functions with global mutable state; doing so risks data races and incorrect results.

PostgreSQL backends are long-lived processes that serve multiple sessions. A global variable written by session A persists when session B runs in the same backend. pg_orbit avoids this entirely --- no function call leaves any trace in the process state.

Given the same TLE and timestamp, pg_orbit produces the same result regardless of what queries ran before, how many backends are active, or whether the function is running in a parallel worker. There is no path-dependent behavior.

sat_code itself has no global mutable state. The propagator state lives entirely in two caller-provided structures:

StructureSizeContainsOwner
tle_t~200 bytesParsed mean elements, identificationCaller (pg_orbit copies from pg_tle)
params[N_SAT_PARAMS]~736 bytesInitialized propagator coefficientsCaller (pg_orbit pallocs this)

The SGP4_init() / SDP4_init() functions write into the params array. The SGP4() / SDP4() functions read from params and tle_t, and write position/velocity into caller-provided arrays. No internal state is retained between calls.

This maps cleanly to PostgreSQL’s per-call execution model. There is no object lifecycle to manage, no destructor to call, no persistent state to synchronize.

All seven pg_orbit types are fixed-size with STORAGE = plain:

TypeSizeALIGNMENTTOAST?
tle112 bytesdoubleNo
eci_position48 bytesdoubleNo
geodetic24 bytesdoubleNo
topocentric32 bytesdoubleNo
observer24 bytesdoubleNo
pass_event48 bytesdoubleNo
heliocentric24 bytesdoubleNo

No TOAST overhead. Variable-length types (varlena) carry a 4-byte header and may be compressed or moved to a secondary TOAST table. Reading a TOASTed value requires a separate heap fetch. Fixed-size types are stored inline in the tuple --- one pointer dereference, no detoasting.

Direct pointer access. PG_GETARG_POINTER(n) returns a pointer directly into the tuple data. No copy, no allocation. The function reads the struct in place.

Predictable memory layout. All types use ALIGNMENT = double because every struct contains double fields. This satisfies the strictest alignment requirement without platform-specific conditionals.

Binary I/O. The tle_recv() / tle_send() functions implement binary protocol support. The fixed layout means binary transfer is a straight memory copy --- no serialization logic, no endianness concerns beyond what PostgreSQL’s binary protocol handles.

The TLE text format is 138+ bytes (two 69-character lines plus separator). The parsed struct is 112 bytes --- smaller than the text it came from, and it eliminates the ~10x parsing overhead that would be incurred on every propagation call if raw text were stored.

The text representation can be reconstructed from the parsed elements via sat_code’s write_elements_in_tle_format(). The round-trip is lossless for all fields that affect propagation.

For a typical catalog query propagating 12,000 TLEs:

ResourcePer-callPeak (12K TLEs)
params array736 bytes736 bytes (reused)
tle_t conversion200 bytes (stack)200 bytes
Result pg_eci48 bytes48 bytes (returned, then freed)
Total transient~1 KB~1 KB

The 736-byte params array is the largest per-call allocation. It is freed before the function returns. At no point does pg_orbit hold allocations proportional to the number of rows being processed --- each row is computed and returned independently.

When ereport(ERROR) fires inside a pg_orbit function, PostgreSQL’s error recovery mechanism:

  1. Unwinds the call stack via longjmp
  2. Frees the current memory context (including any palloc’d memory)
  3. Rolls back the current transaction
  4. Returns an error message to the client

Because pg_orbit uses only palloc and has no global state, there is nothing to clean up beyond what PostgreSQL’s context system handles automatically. No file handles, no sockets, no mutex locks, no C++ destructors. The extension is always in a consistent state after error recovery.