Building a Table Access Method, Part 6: TRUNCATE – Database Drops

10 min read Original article ↗

In the previous two posts you could see how to manage storage for create and drop and also to work with transactions. In this post you’ll learn how to handle TRUNCATE, which replaces the arena of a table with a new one. Doing that safely means choosing which arena to release based on how the transaction ends. This post handles the normal path, the in-place path Postgres takes for tables created in the same (sub)transaction, and the rollback cases for both.

In Part 5 you could see how the arena of a table was tied to the outcome of the transaction, so that CREATE TABLE and DROP TABLE neither leak nor lose an arena when a transaction or a savepoint rolls back. When TRUNCATE is called, relation_set_new_filelocator is called for a table that already has an arena, and it should replace that arena with a new one.

The normal TRUNCATE path

Postgres assumes that you truncate a table by giving it a new relfilenode and let the old one be reclaimed. For pg_memtable it is necessary to either create a second arena and point the relfilenode at it, or truncate the arena in-place. It is not possible to blindly truncate the existing arena, and you will soon understand why.

To deal with TRUNCATE, the memtable_ProcessUtility function is extended to call the finalize function for TRUNCATE in the same way as is done for CREATE. Because the finalize function ignores relations that have no cache entry, tables that use other table access methods are unaffected and the finalize function can be called blindly without checking the table access method.

static void memtable_ProcessUtility(PlannedStmt* pstmt, const char* queryString,
                                    bool readOnlyTree,
                                    ProcessUtilityContext context,
                                    ParamListInfo params,
                                    QueryEnvironment* queryEnv,
                                    DestReceiver* dest, QueryCompletion* qc) {
  Node* parsetree = pstmt->utilityStmt;

  if (prev_ProcessUtility)
    prev_ProcessUtility(pstmt, queryString, readOnlyTree, context, params,
                        queryEnv, dest, qc);
  else
    standard_ProcessUtility(pstmt, queryString, readOnlyTree, context, params,
                            queryEnv, dest, qc);

  if (IsA(parsetree, CreateStmt)) {
    CreateStmt* cstmt = (CreateStmt*)parsetree;
    Oid relid = RangeVarGetRelid(cstmt->relation, NoLock, true);

    memtable_finalize_new_relation(relid);
  } else if (IsA(parsetree, TruncateStmt)) {
    TruncateStmt* tstmt = (TruncateStmt*)parsetree;
    ListCell* lc;

    foreach (lc, tstmt->relations) {
      RangeVar* rv = lfirst_node(RangeVar, lc);
      Oid relid = RangeVarGetRelid(rv, NoLock, true);

      memtable_finalize_new_relation(relid);
    }
  }
}

The call sequence for a TRUNCATE of a memtable table is shown below, with the steps executed from top to bottom. The TAM callback is called while RelationSetNewRelfilenumber is still in the middle of its work, and memtable_ProcessUtility does not get to finalize anything until standard_ProcessUtility has returned:

 1. memtable_ProcessUtility()
 2. ├─ standard_ProcessUtility()
 3. │  └─ ExecuteTruncate()
 4. │     └─ ExecuteTruncateGuts()
 5. │        └─ RelationSetNewRelfilenumber()
 6. │           ├─ GetNewRelFileNumber()             <- new relfilenode allocated
 7. │           ├─ relation_set_new_filelocator()    <- TAM callback, the old pg_class row is still current
 8. │           ├─ CatalogTupleUpdate()              <- new pg_class row created, relfilenode = new relfilenode
 9. │           └─ CommandCounterIncrement()         <- new pg_class row visible
10. └─ memtable_finalize_new_relation()              <- once per relation in the TruncateStmt
                                                     <- pg_class row updated again, relfilenode = DSM handle

In the sequence above, you can see that the callback is called in step 7, before Postgres creates the new pg_class row in step 8. The row that is current while the callback runs is therefore still the old one, which is why the callback can read the handle of the existing arena from it. You can also see that the hook finalizes in step 10, which is after standard_ProcessUtility in step 2 has returned. Everything else in this section follows from that order.

The old arena has to outlive a possible rollback

This also means that when relation_set_new_filelocator is called for TRUNCATE, the pg_class row already exists, and it can be read inside relation_set_new_filelocator to capture the original arena handle. Immediately after calling the TAM callback, RelationSetNewRelfilenumber writes its own freshly allocated relfilenode into the row, which overwrites anything the callback could write itself. It is therefore important to capture the handle before it is overwritten.

So, the callback is extended to create a new arena for a table that already has one, which is going to replace the old arena. If the old one were forgotten, it would stay pinned for as long as the server runs, because only an explicit dsm_unpin_segment lets go of a pinned segment, as described in the section on pinning in Part 3.

static void memtable_relation_set_new_filelocator(
    Relation rel, const RelFileLocator* newrlocator, char persistence,
    TransactionId* freezeXid, MultiXactId* minmulti) {
  Oid relid = RelationGetRelid(rel);
  dsm_handle old_handle = memtable_catalog_lookup(relid, NULL);
  MemTableStateCacheEntry* entry = memtable_cache_enter(relid);
  dsm_handle handle = memtable_create_arena(rel, entry);

  /* If this transaction aborts, don't leak the arena just created. */
  memtable_schedule_cleanup(relid, handle, XACT_EVENT_ABORT);

  /* If this transaction commits, and there were an old arena, clean it up. */
  if (old_handle != DSM_HANDLE_INVALID)
    memtable_schedule_cleanup(relid, old_handle, XACT_EVENT_COMMIT);

  *freezeXid = InvalidTransactionId;
  *minmulti = InvalidMultiXactId;
}

You might think that you can just truncate the arena by resetting its cursor (tuple_free), which would effectively remove all data, but that would be wrong. TRUNCATE can run in a transaction that rolls back, and then the old arena holds the real data of the table. The rollback restores relfilenode, but nothing restores the cursor, and rows inserted after the TRUNCATE would have overwritten the old ones. Releasing the old arena right away is wrong for the same reason, since nothing restores a segment that has been released. The old arena must therefore stay untouched until the transaction is known to have committed.

With TRUNCATE, both arenas therefore have to be handled:

Arena Release when transaction
the new one aborts
the old one commits

The first row is what CREATE TABLE already does, so there is nothing you need to do to handle that part. For the second row, the callback looks up the handle of the arena the table has now and schedules it. This works because the new pg_class row does not exist yet, so the lookup finds the row with the old handle if you are executing a TRUNCATE, or no row at all if you are executing CREATE TABLE:

A TRUNCATE thus schedules two entries:

  • If the transaction commits, the old arena is released and the new one stays.
  • If the transaction aborts, the new one is released and the old one stays.

Both arenas go through exactly the same list and the same callbacks as in Part 5, including the subtransaction callback, so a TRUNCATE inside a savepoint that is rolled back is handled as well.

Truncating in place

There is a second way a table can be truncated. If the table was created, or already truncated, earlier in the same (sub)transaction, Postgres knows that a rollback would throw the whole table away anyway. In that case it does not hand out a new relfilenode at all, and calls relation_nontransactional_truncate instead. FOr this case you can therefore simplify the cleanup by just truncating the arena in-place by resetting the cursor to the first free byte past the header.

static void memtable_relation_nontransactional_truncate(Relation rel) {
  MemTableStateCacheEntry* entry = memtable_cache_find(RelationGetRelid(rel));
  dsm_segment* attached = NULL;
  MemTableArenaHeader* hdr;

  if (entry != NULL) {
    hdr = entry->hdr;
  } else {
    /* No cache entry, but relfilenode is still the handle of the arena. */
    dsm_handle handle = (dsm_handle)rel->rd_locator.relNumber;
    dsm_segment* seg = dsm_find_mapping(handle);

    if (seg == NULL)
      seg = attached = dsm_attach(handle);
    if (seg == NULL)
      ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR),
                      errmsg("could not attach to arena %u", handle)));
    hdr = dsm_segment_address(seg);
  }

  LWLockAcquire(&hdr->lock, LW_EXCLUSIVE);
  hdr->tuple_free = MAXALIGN(sizeof(MemTableArenaHeader));
  LWLockRelease(&hdr->lock);

  if (attached != NULL)
    dsm_detach(attached);
}

Some clarifications: this callback finds the arena through the cache entry that CREATE TABLE or an earlier TRUNCATE left behind in the same transaction. In Part 5, memtable_finalize_new_relation removed the entry as soon as the handle had been copied into pg_class, and that would be a problem now. A TRUNCATE right after a CREATE TABLE in the same transaction would find no entry, and do nothing.

The fix is to stop removing the entry in memtable_finalize_new_relation, which means deleting the memtable_cache_remove from it. Otherwise, the function looks the same.

static void memtable_finalize_new_relation(Oid relid) {
  MemTableStateCacheEntry* entry = memtable_cache_find(relid);

  if (entry != NULL) {
    dsm_handle handle = dsm_segment_handle(entry->seg);
    Relation pg_class_rel = table_open(RelationRelationId, RowExclusiveLock);
    HeapTuple tup = SearchSysCacheCopy1(RELOID, ObjectIdGetDatum(relid));

    if (HeapTupleIsValid(tup)) {
      Form_pg_class classForm = (Form_pg_class)GETSTRUCT(tup);

      classForm->relfilenode = (RelFileNumber)handle;
      CatalogTupleUpdate(pg_class_rel, &tup->t_self, tup);
      heap_freetuple(tup);

      CommandCounterIncrement();
      CacheInvalidateRelcacheByRelid(relid);
    } else {
      ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR),
                      errmsg("pg_class row for relid %u not found", relid)));
    }
    table_close(pg_class_rel, RowExclusiveLock);
  }
}

The entry is still removed, it is just removed later. The transaction callback from Part 5 removes the cache entry of every relation it has a pending release for, once the transaction ends. Every statement in the transaction that needs the entry has run by then.

Note that the entry can still be missing when memtable_relation_nontransactional_truncate looks for it. If a TRUNCATE inside a savepoint is rolled back, its entry is removed together with the arena that TRUNCATE created, even though the original arena is the live one. Once the savepoint is released, a TRUNCATE of a table created earlier in the transaction takes the in-place path without an entry. The callback then falls back to relfilenode, which is still the handle of the arena, and maps the segment from it.

BEGIN;
CREATE TABLE my_table (id bigint) USING memtable;
SAVEPOINT sp;
TRUNCATE my_table;
ROLLBACK TO sp;
RELEASE sp;
TRUNCATE my_table;
COMMIT;

The steps, executed from top to bottom, look like this, where A1 is the arena created by CREATE TABLE and A2 the one created by the TRUNCATE that is rolled back:

 1. BEGIN
 2. CREATE TABLE my_table
 3. └─ memtable_ProcessUtility()
 4.    ├─ standard_ProcessUtility()
 5.    │  └─ relation_set_new_filelocator()          <- entry[my_table].seg = A1
 6.    └─ memtable_finalize_new_relation()           <- relfilenode = handle of A1
 7. SAVEPOINT sp
 8. TRUNCATE my_table
 9. └─ memtable_ProcessUtility()
10.    ├─ standard_ProcessUtility()                  <- my_table was created in an outer subtransaction,
                                                     <- so the transactional path is used
11.    │  └─ relation_set_new_filelocator()          <- entry[my_table].seg = A2
12.    └─ memtable_finalize_new_relation()           <- relfilenode = handle of A2
13. ROLLBACK TO sp
14. └─ memtable_subxact_callback()                   <- SUBXACT_EVENT_ABORT_SUB: entry[my_table] removed, A2 is released,
                                                     <- the pending release of A1 is discarded
                                                     <- relfilenode = handle of A1, A1 is the live arena
15. RELEASE sp
16. TRUNCATE my_table
17. └─ memtable_ProcessUtility()
18.    ├─ standard_ProcessUtility()                  <- my_table was created in the current subtransaction,
                                                     <- so the in-place path is used
19.    │  └─ relation_nontransactional_truncate()    <- no entry for my_table, maps the segment from relfilenode
                                                     <- and resets `tuple_free`
20.    └─ memtable_finalize_new_relation()           <- no entry, so nothing to do

Concluding remark

The full lifetime of shared memory arenas is now implemented, but it is not possible to see the effects yet since INSERT is not implemented. Even without working INSERT or SELECT, however, the lifecycle can be followed from psql by watching relfilenode change underneath a table:

CREATE TABLE my_table (id bigint) USING memtable;
SELECT relfilenode FROM pg_class WHERE relname = 'my_table';
TRUNCATE my_table;
SELECT relfilenode FROM pg_class WHERE relname = 'my_table';  -- a different number
DROP TABLE my_table;

The table access method now handles arena creation, truncation, and dropping tables. All three operations are also transactional, with and without savepoints, even though nothing is stored in the arena yet. In the next post you will see how to store tuples in the arena and also consider what format should be used for the tuples.