Every memtable table now has an arena with a proper lifecycle, but nothing is stored in it. This post stores rows in the arena and reads them back with a full table scan. Along the way it shows what tuple format to use for efficiency, how any backend finds the arena of a table, how space is handed out inside the arena, and the format of TIDs and why they are needed. It also settles which rows a transaction is allowed to see, since two backends can now work on the same table at the same time. Finally, it follows two sessions at work and revisits the TRUNCATE from Part 6 with rows in the table, which shows why each TRUNCATE gets a new arena.
In Part 4 to Part 6 you could see how an arena is created when a table is created, how it is released when the table is dropped, and how that stays correct when a transaction or a savepoint rolls back, or when the table is truncated. All of it happened without a single row in the arena. This post adds INSERT and SELECT. Rows can only be added and read at this point. There is no DELETE, no UPDATE, and no way to find a row other than reading all of them.
Before rows can be stored in the arena, there are three things to sort out:
- What a tuple table slot is and what it is used for.
- What a tuple identifier (TID) is and what it is used for.
- Which tuple format to use for the rows in the arena.
A digression into Tuple Table Slots
A tuple table slot is a container holding rows for the executor. It is used, among other things, to pass information into and out from the table access method and also used in executing the plan tree. There are several different tuple table slot types defined in Postgres, and it is also possible for a table access method to define its own slot type.
Each scan, join, projection and sort use the callbacks for a tuple table slot instead of looking directly at the tuple representation. For example, a node can ask the slot for a particular attribute number of the tuple and get a datum back. All tuple table slots use a base definition containing the generic fields that Postgres needs:
typedef struct TupleTableSlot { NodeTag type; uint16 tts_flags; AttrNumber tts_nvalid; const TupleTableSlotOps *const tts_ops; TupleDesc tts_tupleDescriptor; Datum *tts_values; bool *tts_isnull; MemoryContext tts_mcxt; ItemPointerData tts_tid; Oid tts_tableOid; } TupleTableSlot; The fields have the following meaning:
| Field | Description |
|---|---|
type | The node tag. |
tts_flags | State bits. |
tts_nvalid | The number of leading entries in tts_values and tts_isnull that hold valid data. |
tts_ops | The TupleTableSlotOps of the slot, which is what slot_callbacks returned. |
tts_tupleDescriptor | Describes the attributes of the row, such as how many there are and their types. |
tts_values | One Datum per attribute. In a virtual slot this is the row itself. |
tts_isnull | One flag per attribute, which is true when the attribute is null. |
tts_mcxt | The memory context that the slot lives in. |
tts_tid | The TID of the stored tuple. |
tts_tableOid | The OID of the table that the tuple came from. |
Each slot kind keeps the row in whatever representation its type uses, and extracts attributes into the arrays tts_values and tts_isnull only as far as a query asks for them. The type of a slot is given by its TupleTableSlotOps, a set of functions that, among other things, extract attributes, clear the slot, and make a copy of the row that the slot owns, which is called materializing it.
As mentioned above, each table access method can define its own tuple table slot, but Postgres has four built-in slot types:
| Slot type | Where the tuple lives | How to store a tuple | Typical usage |
|---|---|---|---|
TTSOpsVirtual | Nowhere, only as datums | Fill the arrays, then call ExecStoreVirtualTuple | Results of projections and expressions |
TTSOpsHeapTuple | In memory, as a HeapTuple | Call ExecStoreHeapTuple | Tuples formed in memory, for example with heap_form_tuple |
TTSOpsMinimalTuple | In memory, as a MinimalTuple | Call ExecStoreMinimalTuple | Rows kept by executor nodes, such as sorts and hash joins |
TTSOpsBufferHeapTuple | In a page in shared_buffers | Call ExecStoreBufferHeapTuple | Rows returned by scans of heap tables |
Tuple identifier (TID)
Tuple identifiers are used to reference a particular tuple in the relation. They are not the same as the primary key: a table without a primary key still has a tuple identifier for each tuple. Postgres stores the rows of a table in a heap and not inside an index (which is called index-organized storage in some other databases). Every index, including the one for the primary key, therefore needs some way to point at the physical tuple, and that is the tuple identifier.
The main uses of a tuple identifier are:
- Indexes store it to point to a particular tuple in the relation, as discussed above.
UPDATEandDELETEuse it to find the tuple that is affected.- Row locks use it to identify exactly which row is locked, regardless of keys.
A TID is an ItemPointerData, consisting of a block number and an offset number (within the block). Postgres does not care what they mean, only that the offset number is not zero and that the six bytes can be stored in an index. In principle, you can use anything as block number and offset number, but it can cause problems when you start to use bitmap heap scan or index access methods such as brin or gin.
The heap uses them as the number of a page and the index of an item on that page. Since the arena has no pages, pg_memtable uses them to encode the byte offset of a row in the arena. Dividing the offset by BLCKSZ and keeping the remainder is sufficient for our purposes:
static inline void memtable_store_offset(arena_off_t off, ItemPointer tid) { ItemPointerSet(tid, off / BLCKSZ, off % BLCKSZ + 1); } The offset number is the remainder plus one because offset numbers start at one and an offset of zero is treated as an invalid TID. The mapping from a row to its TID must also be reversible, since indexes, UPDATE and DELETE hand a TID back and expect the table access method to find the row again. Nothing needs the inverse yet, but it is added anyway to support index scans and tuple modifications:
static inline arena_off_t memtable_tid_to_offset(ItemPointer tid) { return (arena_off_t)ItemPointerGetBlockNumber(tid) * BLCKSZ + (arena_off_t)ItemPointerGetOffsetNumber(tid) - 1; } Picking tuple format
Postgres calls the slot_callbacks callback, through table_slot_callbacks and table_slot_create, when it needs a slot for the table. A typical example is when it needs a slot in the scan node of a SELECT. The table access method decides what tuple table slot type to pass back by implementing slot_callbacks. Instead of implementing a new tuple table slot, you can use one of the pre-defined ones. The question is which one.
To decide which slot type to use, a benchmark was created that scanned a wide table wide with each alternative tuple table slot. The table has one million rows and 21 columns, 20 of type int and one of type text, which makes each row about 128 bytes. The heap tuple slot is the code in this post. The virtual slot fills tts_values with heap_deform_tuple on every fetch, and the minimal tuple slot stores minimal tuples in the arena, with the xmin of the row in the slot header. The times are the best of seven runs on one machine, with parallel query disabled:
| Query | Heap tuple | Virtual | Minimal tuple |
|---|---|---|---|
SELECT count(*) FROM wide | 28 ms | 57 ms | 27 ms |
SELECT count(*) FROM wide WHERE c1 < 0 | 30 ms | 55 ms | 29 ms |
SELECT sum(c20) FROM wide | 56 ms | 59 ms | 54 ms |
SELECT sum(c20), max(t) FROM wide | 115 ms | 116 ms | 110 ms |
The virtual slot is slower when a query only needs the first columns, since it extracts all attributes of every row. The minimal tuple slot is about as fast as the heap tuple slot, but it has no xmin and xmax, which the visibility check needs and which the heap tuple already carries. The heap tuple format is therefore sufficient for this version of pg_memtable, and alternatives can be looked at later to improve it.
The price of the heap tuple format is a header of 23 bytes on every row, and some of its fields, such as t_cid and t_ctid, are not used at this stage. A tighter format can be considered later if the space matters, but it would bring conversion code along with it.
The callback implementation is then simply:
static const TupleTableSlotOps* memtable_slot_callbacks(Relation rel) { return &TTSOpsHeapTuple; } What a row looks like in the arena
The arena has no pages and no line pointers, so something else has to tell a scan where the next row starts. Every tuple is therefore preceded by a small header with the length of the tuple and some padding:
typedef struct MemTableSlotHeader { uint32 tuple_size; /* byte length of the HeapTupleHeaderData that follows */ uint32 padding; /* keeps the tuple that follows MAXALIGNed */ } MemTableSlotHeader; typedef uint64 arena_off_t; The padding makes the header 8 bytes, which keeps the tuple that follows aligned to MAXALIGN. Postgres reads the attributes of a tuple on the assumption that the tuple starts on such a boundary.
Each row takes up MAXALIGN(sizeof(MemTableSlotHeader) + tuple_size) bytes, and the next row starts right after it:
| Part of the row | Contents |
|---|---|
MemTableSlotHeader | tuple_size, and padding up to 8 bytes |
HeapTupleHeaderData | t_xmin is the inserting transaction, t_xmax is unused until rows can be deleted, and t_infomask has HEAP_XMAX_INVALID set to say that the row is not deleted |
| Attribute data | Written by the executor and read by the scan |
| Padding | Up to the next MAXALIGN boundary |
An offset into the arena is an arena_off_t. The first row starts right after the arena header, at MAXALIGN(sizeof(MemTableArenaHeader)), which is where tuple_free pointed when the arena was created.
Finding the arena from any backend
Until now only the backend that created an arena had a pointer to it. Reading and writing rows has to work from any backend, and the handle needed to attach is already available. Part 4 to Part 6 made sure that relfilenode holds the handle, so rel->rd_locator.relNumber is all that is needed. The mapping is kept in the cache entry of the relation from Part 4, so it only has to be made once per backend. The function that every callback in this post calls to get the entry looks up the entry, and attaches the arena if the entry is new or if the arena has been replaced:
static MemTableStateCacheEntry* memtable_get_state(Relation rel) { dsm_handle handle = (dsm_handle)rel->rd_locator.relNumber; bool found; MemTableStateCacheEntry* entry = memtable_cache_enter(RelationGetRelid(rel), &found); if (found && entry->handle == handle) return entry; if (found && entry->seg != NULL) { dsm_detach(entry->seg); /* the arena was replaced by another backend */ entry->seg = NULL; } memtable_attach_arena(rel, entry); return entry; } The attaching is done by a separate function that fills in the cache entry:
static void memtable_attach_arena(Relation rel, MemTableStateCacheEntry* entry) { dsm_handle handle = (dsm_handle)rel->rd_locator.relNumber; dsm_segment* seg = dsm_find_mapping(handle); if (seg == NULL) { seg = dsm_attach(handle); if (seg == NULL) { memtable_cache_remove(entry->relid); ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("could not attach to arena %u", handle))); } } dsm_pin_mapping(seg); /* survive end-of-transaction in this backend */ entry->seg = seg; entry->arena_base = dsm_segment_address(seg); entry->hdr = entry->arena_base; entry->handle = handle; } There are three details in memtable_attach_arena:
dsm_attachraises an error if the segment is already mapped in the backend, which is the case for the backend that created the arena, sodsm_find_mappingis tried first.- A new mapping is pinned with
dsm_pin_mapping, as described in Part 3. An unpinned mapping belongs to the current resource owner, and Postgres warns about resources that are still open when the transaction commits. Pinning a mapping that is already pinned, such as the one made by the backend that created the arena, does nothing. - A restart of the server destroys all DSM segments, while the handle in
relfilenodestays.dsm_attachthen returnsNULLand the function raises an error. The entry thatmemtable_get_statehas just created is removed first, since the next call would otherwise find an entry without a mapping and return it. A table cannot be used after a restart in this series.
The comparison with relfilenode in memtable_get_state is there for TRUNCATE. As you saw in Part 6, a TRUNCATE gives the table a new arena and writes the new handle to relfilenode. A backend that has read the table earlier has the old arena in its cache entry. Without the comparison it would keep reading from and writing to the old arena, which nobody else uses any more. When the handles differ, some other backend has truncated the table, so the old mapping is detached and the new arena is attached.
Note: The comparison only runs when the table is used, which leaves a loose end. A backend that has read a table and then stays idle keeps its mapping of the arena after another backend has dropped or truncated the table. Postgres frees a segment only when the last mapping of it is gone, so the memory of the arena is not returned until that backend uses the table again, which it never will if the table was dropped, or disconnects. The next post fixes that.
Allocating space for tuples in the arena
The arena is allocated once, when the table is created, so a bump allocator is enough: it hands out memory by moving a cursor forward, and it never frees an individual allocation. The cursor tuple_free is the offset of the next free byte, and allocating means moving it forward under the arena lock:
static arena_off_t memtable_arena_alloc(MemTableStateCacheEntry* local, size_t size) { MemTableArenaHeader* hdr = local->hdr; arena_off_t off; LWLockAcquire(&hdr->lock, LW_EXCLUSIVE); if (hdr->tuple_free + MAXALIGN(size) > hdr->size) { LWLockRelease(&hdr->lock); ereport(ERROR, (errmsg("memtable arena full"))); } off = hdr->tuple_free; hdr->tuple_free += MAXALIGN(size); LWLockRelease(&hdr->lock); return off; } When the arena is full the statement fails with memtable arena full. The size of an arena is fixed when it is created and nothing is ever freed, so space is only reclaimed by TRUNCATE or by dropping the table.
The cursor is moved while the lock is held, but the caller fills in the space after the lock has been released. A scan in another backend can therefore see a slot that is allocated and not completely written yet. That is a known limitation of this version.
Inserting a row
You now have the background to implement row insertion and the full table scan.
Inserting a row is done by the tuple_insert callback, which gets a slot with the row and writes a copy of it into the arena. It is implemented in pg_memtable as memtable_tuple_insert:
static void memtable_tuple_insert(Relation rel, TupleTableSlot* slot, CommandId cid, int options, struct BulkInsertStateData* bistate) { MemTableStateCacheEntry* state = memtable_get_state(rel); HeapTuple tup = ExecFetchSlotHeapTuple(slot, true, NULL); size_t total = sizeof(MemTableSlotHeader) + tup->t_len; arena_off_t off = memtable_arena_alloc(state, total); char* dst = (char*)state->arena_base + off; HeapTupleHeader htup; ((MemTableSlotHeader*)dst)->tuple_size = tup->t_len; htup = (HeapTupleHeader)(dst + sizeof(MemTableSlotHeader)); memcpy(htup, tup->t_data, tup->t_len); HeapTupleHeaderSetXmin(htup, GetCurrentTransactionId()); HeapTupleHeaderSetXmax(htup, InvalidTransactionId); htup->t_infomask |= HEAP_XMAX_INVALID; memtable_store_offset(off, &slot->tts_tid); } The slot is turned into a heap tuple with ExecFetchSlotHeapTuple, space is allocated for the slot header and the tuple, and the tuple is copied in. The three lines after the copy are the important ones. A tuple that the executor has built carries other information in the bytes where t_xmin and t_xmax live, so the visibility fields have to be set explicitly:
t_xminis the transaction doing the insert.t_xmaxisInvalidTransactionId, andHEAP_XMAX_INVALIDsays that the row is not deleted.- The TID of the new row is stored in the slot, so the executor can report it.
The command id is not stored, which means that a row is visible to the rest of its own transaction as soon as it has been inserted. multi_insert is used for batches of rows and inserts them one by one:
static void memtable_multi_insert(Relation rel, TupleTableSlot** slots, int nslots, CommandId cid, int options, struct BulkInsertStateData* bistate) { int i; for (i = 0; i < nslots; i++) memtable_tuple_insert(rel, slots[i], cid, options, bistate); } Reading rows with a full scan
Rows that cannot be read are of little use, so the next step is the simplest of all the scans: a full table scan. It needs four callbacks:
- The
scan_begincallback prepares a full table scan and is implemented bymemtable_scan_begin. - The
scan_endcallback cleans up after a scan and is implemented bymemtable_scan_end. - The
scan_getnextslotcallback fetches the next visible row from the table into a slot and is implemented bymemtable_scan_getnextslot. - The
scan_rescancallback restarts a scan that is already open, for example when the executor needs the rows of the inner side of a nested loop join again for the next row of the outer side, and is implemented bymemtable_scan_rescan.
A scan also needs a scan descriptor, MemTableScanData, to keep track of where it is. Postgres expects a scan descriptor to start with a TableScanDescData, and after that it can contain whatever the scan needs.
typedef struct MemTableScanData { TableScanDescData rs_base; MemTableStateCacheEntry state; arena_off_t scan_next; arena_off_t scan_stop; HeapTupleData htup; } MemTableScanData; Our implementation fills in the descriptor, finds the arena, and remembers the start and end of the tuples that are going to be scanned. We record the end of the scan as well so that rows inserted after the scan has started are never returned.
static TableScanDesc memtable_scan_begin(Relation rel, Snapshot snapshot, int nkeys, struct ScanKeyData* key, ParallelTableScanDesc pscan, uint32 flags) { MemTableScanData* scan = palloc0(sizeof(MemTableScanData)); scan->rs_base.rs_rd = rel; scan->rs_base.rs_snapshot = snapshot; scan->rs_base.rs_nkeys = nkeys; scan->rs_base.rs_flags = flags; scan->state = *memtable_get_state(rel); scan->scan_next = MAXALIGN(sizeof(MemTableArenaHeader)); scan->scan_stop = scan->state.hdr->tuple_free; scan->htup.t_tableOid = RelationGetRelid(rel); return &scan->rs_base; } The executor calls scan_begin once, when the scan starts and before it asks for the first row. Besides the relation it passes the snapshot of the query, any scan keys, the state of a parallel scan, and a flags word that says how the scan is going to be used. The snapshot is stored in the descriptor but not looked at yet. A full table scan from the executor has no scan keys, the flags only hint at strategies that the heap uses, such as synchronized scans, and parallel scans are not supported, so the rest of the arguments are not used.
Reading the next row means finding the next visible tuple, storing it in the slot, and advancing the cursor to the next tuple. The slot points directly at the tuple in the arena, and shouldFree is false since the arena owns the memory:
static bool memtable_scan_getnextslot(TableScanDesc sscan, ScanDirection direction, TupleTableSlot* slot) { MemTableScanData* scan = (MemTableScanData*)sscan; char* base = scan->state.arena_base; if (ScanDirectionIsBackward(direction)) ereport(ERROR, (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), errmsg("backward scans are not supported"))); while (scan->scan_next < scan->scan_stop) { char* hdr_ptr = base + scan->scan_next; MemTableSlotHeader* shdr = (MemTableSlotHeader*)hdr_ptr; HeapTupleHeader htup_data = (HeapTupleHeader)(hdr_ptr + sizeof(MemTableSlotHeader)); arena_off_t this_off = scan->scan_next; scan->scan_next += MAXALIGN(sizeof(MemTableSlotHeader) + shdr->tuple_size); if (!memtable_tuple_visible(htup_data)) continue; scan->htup.t_len = shdr->tuple_size; scan->htup.t_data = htup_data; memtable_store_offset(this_off, &scan->htup.t_self); ExecStoreHeapTuple(&scan->htup, slot, false); return true; } return false; } Backward scans are not implemented here, so the callback rejects a backward scan with an error. It is straightforward to implement should you want to.
The callback memtable_scan_end frees the descriptor when the scan is finished:
static void memtable_scan_end(TableScanDesc sscan) { pfree(sscan); } Nothing else needs to be released. The heap releases buffer pins when a scan ends, but a scan of the arena pins nothing, and the mapping of the arena stays in the cache entry for the next scan.
The callback memtable_scan_rescan restarts a scan that is already open and the executor calls it when a plan node has to produce its rows again. An example is for the inner side of a nested loop join, which is read once for every row of the outer side. Starting over with the same descriptor is cheaper than ending the scan and beginning a new one.
The callback also receives scan keys and flags that say which scan strategies are allowed, which a full scan has no use for. FOr pg_memtable the cursor is simply moved back to the first row and reads the end of the arena again, so rows inserted since the scan began are returned on the second pass:
static void memtable_scan_rescan(TableScanDesc sscan, struct ScanKeyData* key, bool set_params, bool allow_strat, bool allow_sync, bool allow_pagemode) { MemTableScanData* scan = (MemTableScanData*)sscan; scan->scan_next = MAXALIGN(sizeof(MemTableArenaHeader)); scan->scan_stop = scan->state.hdr->tuple_free; } With the TTSOpsHeapTuple slot from slot_callbacks and shouldFree set to false, the slot points directly at the row in the arena and nothing is copied. Attribute values that are passed by reference, such as text, point into the arena as well, and ExecStoreHeapTuple takes the TID of the slot from t_self. The executor copies a row only when it needs to keep it after the scan has moved on, for example to sort it.
Which rows a transaction can see
A row has to be hidden from other transactions while the inserting transaction is running, and has to stay hidden if that transaction rolls back. memtable_tuple_visible does this by looking at t_xmin:
static bool memtable_tuple_visible(HeapTupleHeader htup) { TransactionId xmin = HeapTupleHeaderGetRawXmin(htup); if (!(htup->t_infomask & HEAP_XMAX_INVALID)) return false; /* deleted, once delete exists */ if (TransactionIdIsCurrentTransactionId(xmin)) return true; /* your own uncommitted insert is visible to you */ return TransactionIdDidCommit(xmin); } A transaction sees its own rows, including rows inserted in a savepoint that is still active. Everyone else sees a row once the inserting transaction has committed. A row from a transaction that is still running, or that has rolled back, is hidden. This also covers a savepoint that was rolled back, since its transaction id is no longer a current one and it did not commit.
This is deliberately simple. The function does not look at the snapshot at all, so a row becomes visible the moment its transaction commits, even in the middle of a statement.
Seeing it work
Insert a few rows and read them back:
postgres=# CREATE TABLE t (id bigint, name text) USING memtable; CREATE TABLE postgres=# INSERT INTO t VALUES (1, 'one'), (2, 'two'); INSERT 0 2 postgres=# INSERT INTO t VALUES (3, NULL); INSERT 0 1 postgres=# SELECT ctid, * FROM t; ctid | id | name ---------+----+------ (0,33) | 1 | one (0,81) | 2 | two (0,129) | 3 | (3 rows) The first row is at offset 32, which is MAXALIGN(sizeof(MemTableArenaHeader)), so its TID is block 0 and offset number 33. Each of these rows takes 48 bytes, so the TIDs are 48 apart.
To see two backends at work, run an insert in one session and count the rows in another while the first transaction is still open. In the prompt, the * shows that the session is inside a transaction. Session A starts a transaction, inserts a row, and counts the rows. The row is visible to the transaction that inserted it, so the count is 4:
postgres=# BEGIN; BEGIN postgres=*# INSERT INTO t VALUES (10, 'ten'); INSERT 0 1 postgres=*# SELECT count(*) FROM t; count ------- 4 (1 row) Session B counts the rows while the transaction in session A is still open. The row is not committed yet, so it sees 3 rows:
postgres=# SELECT count(*) FROM t; count ------- 3 (1 row) Session A commits:
postgres=*# COMMIT; COMMIT Now session B sees the row too:
postgres=# SELECT count(*) FROM t; count ------- 4 (1 row) A transaction that rolls back leaves nothing behind that any session can see.
Now the example that explains why TRUNCATE creates a new arena instead of resetting the old one, which was discussed in Part 6:
postgres=# BEGIN; BEGIN postgres=*# TRUNCATE t; TRUNCATE TABLE postgres=*# INSERT INTO t VALUES (100, 'new'); INSERT 0 1 postgres=*# SELECT count(*) FROM t; count ------- 1 (1 row) postgres=*# ROLLBACK; ROLLBACK postgres=# SELECT count(*) FROM t; count ------- 4 (1 row) Inside the transaction only the new row is visible, since the table has a new arena. The old arena was never touched, so the rollback brings back all four rows. Resetting the cursor of the old arena instead would have overwritten the first rows with the new one.
Truncating in place, revisited
In Part 6 the in-place TRUNCATE needed a fallback for the case where the cache entry was gone, and it attached to the segment by itself. There is now a function that finds the arena from any backend, so the callback becomes much shorter:
static void memtable_relation_nontransactional_truncate(Relation rel) { MemTableStateCacheEntry* state = memtable_get_state(rel); LWLockAcquire(&state->hdr->lock, LW_EXCLUSIVE); state->hdr->tuple_free = MAXALIGN(sizeof(MemTableArenaHeader)); LWLockRelease(&state->hdr->lock); } It no longer matters whether there is a cache entry, so the limitation described at the end of Part 6 is gone. After a rolled-back TRUNCATE inside a savepoint, a second TRUNCATE in the same transaction empties the table:
BEGIN; CREATE TABLE v (id int) USING memtable; INSERT INTO v VALUES (1), (2); SAVEPOINT sp; TRUNCATE v; ROLLBACK TO sp; SELECT count(*) FROM v; -- 2 RELEASE sp; TRUNCATE v; SELECT count(*) FROM v; -- 0 COMMIT; Concluding remarks
pg_memtable can now insert rows and read them back with a full scan, from any backend, with visibility that follows the transaction that inserted them. Rows are stored in the heap tuple format and a TID is an arena offset. What is missing is the rest of the table access method: DELETE and UPDATE do not exist, visibility ignores the snapshot, and the only way to find a row is to read all of them.
There is also the loose end in the cache that was described above: a backend that has read a table keeps the memory of its arena after another backend has dropped or truncated the table. The next post removes the cache entries and releases the mappings when a table changes.
The code
The code of this post is in the pg_memtable repository, in the commit feat: store rows and read them with a table scan. The repository has one commit for each post, so the difference between two of them is the code that a post adds. 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.



