A backend keeps its mapping of the arena in its cache. As a result, if another backend drops or truncates the table, the memory of the old arena is not returned until that backend disconnects, so the cache has to be invalidated when the relation changes. This post uses the relcache invalidation messages of Postgres to find out when a table has changed, shows why the callback can only remember what has to be done, and detaches the mappings when the transaction ends.
“There are only two hard things in Computer Science: cache invalidation and naming things.”
Phil Karlton
In Part 7 any backend could find the arena of a table, and the mapping was kept in the cache entry of the relation so that it only had to be made once. A comparison of the handle of the entry with relfilenode made sure that a backend that had read a table started to use the new arena when another backend truncated the table. That comparison runs when the table is used, though, so it says nothing about the backends that do not use the table again. This post fixes that, and the companion code is the code from Part 7 with the additions described below.
Note: There are a few things in this cache invalidation implementation that I am not fond of and will dig into later, but the current implementation is good enough to explain how invalidation works and how it can be implemented.
Reading the backend cache
Before delving into the nuances of the cache and invalidations, a function for inspecting the cache would be useful. You can add a simple function and view that allows you to view the cache entries of the calling backend. The function is a set-returning function (usually called SRF) that is then used by the view. The view returns the information in the cache, but also the relation name and relfilenode (where the handle is stored) from pg_class.
CREATE FUNCTION memtable_cache_entries(OUT relid oid, OUT handle oid, OUT mapped bool) RETURNS SETOF record AS 'MODULE_PATHNAME' LANGUAGE C STRICT; CREATE VIEW memtable_cache AS SELECT e.relid, c.relname, e.handle, c.relfilenode, e.mapped FROM memtable_cache_entries() e LEFT JOIN pg_class c ON c.oid = e.relid; Like everything else in the extension, the function and the view are created in the memtable schema.
Instead of the SRF_* macros that are normally used for set-returning functions, this function collects all rows and returns them in one go. This is done since using the normal macros would keep the scan of the hash table open between the calls, and a scan that is abandoned early, for example by a LIMIT or an error, is reported as a leak at the end of the transaction.
Datum memtable_cache_entries(PG_FUNCTION_ARGS) { InitMaterializedSRF(fcinfo, 0); if (local_state_cache != NULL) { HASH_SEQ_STATUS status; MemTableStateCacheEntry* entry; ReturnSetInfo* rsinfo = (ReturnSetInfo*)fcinfo->resultinfo; hash_seq_init(&status, local_state_cache); while ((entry = hash_seq_search(&status)) != NULL) { bool nulls[3] = {false, false, false}; Datum values[3] = { ObjectIdGetDatum(entry->relid), ObjectIdGetDatum((Oid)entry->handle), BoolGetDatum(entry->seg != NULL), }; tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls); } } /* The rows are in the tuple store, so the return value is not used. */ return (Datum)0; } The function InitMaterializedSRF sets up the tuple store that collects the rows, then the function walks over the hash table of the cache in a single call and adds one row for each entry.
A mapping that nobody releases
A DSM segment is freed when the last backend that has it mapped detaches. The backend that drops a table releases the arena, as in Part 5, but another backend that has read the table still has the arena mapped, and its mapping is pinned so that it survives the end of the transaction.
For example, if session A creates a table, then session B reads it, and then session A drops the table, it means that session B still remembers the table after it has been dropped. Using the view added above, you can see that below:
-- session A postgres=# CREATE TABLE t (id int) USING memtable; CREATE TABLE postgres=# INSERT INTO t VALUES (1), (2), (3); INSERT 0 3 -- session B postgres=# SELECT count(*) FROM t; count ------- 3 (1 row) postgres=# SELECT * FROM memtable.memtable_cache; relid | relname | handle | relfilenode | mapped -------+---------+-----------+-------------+-------- 16394 | t | 913893128 | 913893128 | t (1 row) -- session A postgres=# DROP TABLE t; DROP TABLE -- session B postgres=# SELECT * FROM memtable.memtable_cache; relid | relname | handle | relfilenode | mapped -------+---------+-----------+-------------+-------- 16394 | | 913893128 | | t (1 row) The table is gone, which can be seen since relfilenode is NULL, but session B still has the cache entry with the handle of the arena and the mapping. The segment of the arena is only freed when session B disconnects, which might not be in a long time. With large arenas, a pool of connections that have each read a table that is later dropped or truncated could hold on to a lot of memory.
So, how can the cache be invalidated? Fortunately, Postgres has relcache invalidations for that.
Relcache invalidations
Whenever a row in pg_class changes, for example as a result of a DROP or a TRUNCATE, Postgres sends a relcache invalidation message. The pending messages are processed when a transaction starts, when a lock is taken on a relation the transaction did not already hold a lock on, and when a backend that has fallen behind on invalidation messages is asked to catch up, which can happen between two rows of a scan. A callback registered with CacheRegisterRelcacheCallback is called with the relation id when a message is processed, and it can remove the cache entry so that the next call attaches the current arena.
static void memtable_relcache_callback(Datum arg, Oid relid) { MemoryContext oldcxt = MemoryContextSwitchTo(TopMemoryContext); if (relid == InvalidOid) { if (local_state_cache != NULL) { HASH_SEQ_STATUS status; MemTableStateCacheEntry* entry; hash_seq_init(&status, local_state_cache); while ((entry = hash_seq_search(&status)) != NULL) memtable_forget_entry(entry); } } else { MemTableStateCacheEntry* entry = memtable_cache_find(relid); if (entry != NULL) memtable_forget_entry(entry); } MemoryContextSwitchTo(oldcxt); } void _PG_init(void) { ... CacheRegisterRelcacheCallback(memtable_relcache_callback, (Datum)0); ... } The callback is called with either the id of the relation that was invalidated or InvalidOid. In the first case only that relation was invalidated, but in the second case all relations were invalidated. We are using a dedicated function memtable_forget_entry here rather than memtable_cache_remove for reasons that will be clear in a few moments.
One complication that needs to be dealt with is that the invalidation callback can be called at any time while a relation is in use, when the same relation is invalidated in another session. One example is in the middle of a scan. Another is a cursor that is open while another statement locks a new table.
Since a scan keeps a copy of the cache entry from Part 7, and that copy points into the mapping, the mapping cannot be detached in the callback. The callback therefore removes the cache entry, but it does not detach the mapping at this point. Instead, the stale handle is saved in a list, and the mapping is detached when the transaction ends.
static List* stale_handles = NIL; static void memtable_forget_entry(MemTableStateCacheEntry* entry) { if (memtable_is_new_arena(entry)) return; if (!list_member_oid(stale_handles, (Oid)entry->handle)) stale_handles = lappend_oid(stale_handles, (Oid)entry->handle); memtable_cache_remove(entry->relid); } You might notice the extra check first where memtable_is_new_arena is called and wonder why that is there. This is necessary to capture an arena that was created by the current transaction in memtable_finalize_new_relation from Part 4, which writes the handle to relfilenode after the DDL command has finished.
The new pg_class row is invalidated before that happens, so removing the entry in the callback would make the finalization find nothing. An arena created by the current transaction is recognized by the item that was scheduled for it in Part 5 to release it if the transaction aborts:
static bool memtable_is_new_arena(MemTableStateCacheEntry* entry) { ListCell* lc; foreach (lc, pending_cleanups) { MemTablePendingCleanup* item = lfirst(lc); if (item->event == XACT_EVENT_ABORT && item->handle == entry->handle) return true; } return false; } All that remains now is to detach the mappings for the stale handles that were saved above.
Detaching when the transaction ends
The mappings are detached by the transaction callback from Part 5, after it has released the arenas. A recorded mapping is only detached if no cache entry has started using the same segment again, which happens in the backend that truncates a table: the callback runs in the middle of the TRUNCATE, and a later INSERT in the same transaction finds the mapping of the new arena. A mapping that has already been detached, for example by memtable_release_arena when the table was dropped, is not found by dsm_find_mapping and is skipped:
static bool memtable_handle_in_use(dsm_handle handle) { HASH_SEQ_STATUS status; MemTableStateCacheEntry* entry; if (local_state_cache == NULL) return false; hash_seq_init(&status, local_state_cache); while ((entry = hash_seq_search(&status)) != NULL) { if (entry->handle == handle) { hash_seq_term(&status); return true; } } return false; } The function memtable_handle_in_use is declared before the transaction callback, like memtable_schedule_cleanup in Part 4. The transaction callback from Part 5 gets a second loop that detaches the mappings of the saved handles, and the list of handles is freed together with the list of pending cleanups:
static void memtable_xact_callback(XactEvent event, void* arg) { if (event == XACT_EVENT_COMMIT || event == XACT_EVENT_ABORT) { ListCell* lc; foreach (lc, pending_cleanups) { MemTablePendingCleanup* item = lfirst(lc); Assert(item->event == XACT_EVENT_COMMIT || item->event == XACT_EVENT_ABORT); if (item->event == event && item->handle != DSM_HANDLE_INVALID) memtable_release_arena(item->handle); memtable_cache_remove(item->relid); pfree(item); } foreach (lc, stale_handles) { dsm_handle handle = (dsm_handle)lfirst_oid(lc); dsm_segment* seg = dsm_find_mapping(handle); if (seg != NULL && !memtable_handle_in_use(handle)) dsm_detach(seg); } list_free(pending_cleanups); list_free(stale_handles); pending_cleanups = NIL; stale_handles = NIL; } } A relation can only be dropped or truncated when no other transaction has it open, since both take an ACCESS EXCLUSIVE lock that conflicts with the lock held by a scan. A TRUNCATE in the same session is refused with cannot TRUNCATE "t" because it is being used by active queries in this session if the table has an open scan. A mapping is therefore not detached while a scan is using it.
Seeing it work
Repeat the example from the beginning, with the code from this post:
-- session A postgres=# CREATE TABLE t (id int) USING memtable; CREATE TABLE postgres=# INSERT INTO t VALUES (1), (2), (3); INSERT 0 3 -- session B postgres=# SELECT count(*) FROM t; count ------- 3 (1 row) postgres=# SELECT * FROM memtable.memtable_cache; relid | relname | handle | relfilenode | mapped -------+---------+------------+-------------+-------- 16394 | t | 2454950692 | 2454950692 | t (1 row) -- session A postgres=# DROP TABLE t; DROP TABLE -- session B postgres=# SELECT * FROM memtable.memtable_cache; relid | relname | handle | relfilenode | mapped -------+---------+--------+-------------+-------- (0 rows) The cache of session B is empty. Session B did not have to use the table. Any transaction processes the pending invalidation messages when it starts, which is when the callback removed the entry, and the mapping is detached when that transaction ends. A session that never starts another transaction keeps its mapping. A session that is idle in a transaction that has used the table holds a lock on it, which keeps the DROP and the TRUNCATE out in the first place.
Concluding remarks
Backends now let go of an arena when the table that owns it is dropped or truncated. Nothing was added to the hot path of reading and writing rows, since an entry that exists is returned as before, and the work is done when a relation changes and when a transaction ends.
pg_memtable can store rows and read them back, but the only way to find a row is still to read all of them. The next posts look at how a row can be found without reading the whole table.
The code
The code of this post is in the pg_memtable repository. The functions are split over several source files there, and the code contains fixes that were made after the posts were written, so the layout differs from the listings in the post.
