Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/base-image.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Base image (DuckDB 1.5.x)

# Builds + publishes the coldfront-duckdb-base image (libcurl 8.12 + pg_duckdb
# 1.5.3 + patched duckdb-iceberg) to ghcr.io/pgedge for PG 16/17/18. The app image
# 1.5.4 + patched duckdb-iceberg) to ghcr.io/pgedge for PG 16/17/18. The app image
# (docker/Dockerfile.duckdb15) does `FROM` this; CI and local builds pull it.
#
# Rebuilt only when the base inputs change (Dockerfile / iceberg patch / config)
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
- github.com/jackc/pgx/v5 (PostgreSQL driver — use pgxpool directly)
- gopkg.in/yaml.v3 (config)
- github.com/stretchr/testify (test assertions only)
- pg_duckdb 1.5.3 (PR #1025, DuckDB 1.5.3) + patched duckdb-iceberg, prebuilt into the `coldfront-duckdb-base` image — see [DUCKDB_1.5_PATCHED.md](DUCKDB_1.5_PATCHED.md)
- pg_duckdb 1.5.4 (PR #1025, DuckDB 1.5.4) + patched duckdb-iceberg, prebuilt into the `coldfront-duckdb-base` image — see [DUCKDB_1.5_PATCHED.md](DUCKDB_1.5_PATCHED.md)
- `cmd/compactor/` is a separate Go module (apache/iceberg-go) — its heavy deps are quarantined from the lean archiver
- `extension/coldfront/` — PGXS C extension. Requires `pg_config` and PG dev headers. Built inside the Docker image; users on bare-metal install with `make && make install`.

Expand Down
18 changes: 9 additions & 9 deletions DUCKDB_1.5_PATCHED.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PATCHED — duckdb-iceberg patches & build (DuckDB 1.5.x)

ColdFront runs on a **custom-built DuckDB 1.5.3 base image** that carries a
ColdFront runs on a **custom-built DuckDB 1.5.4 base image** that carries a
small set of patches against `duckdb-iceberg` `v1.5-variegata`. This is the one
home for *what* we patch, *why*, *how the base is built*, and *how it is wired
and verified*. The cold-tier compactor's own story (and the three interop
Expand Down Expand Up @@ -131,13 +131,13 @@ patches only.

| Component | Pin | Notes |
|---|---|---|
| pg_duckdb | **PR #1025 head** (`9c9fbcd`) | no released tag carries 1.5.x; `git fetch origin pull/1025/head`. Sets `DUCKDB_VERSION=v1.5.3`. |
| DuckDB | **v1.5.3** (submodule `9a64d338`) | the `duckdb.*` GUCs + PRE_COMMIT iceberg-commit deferral ColdFront relies on are unchanged by the PR. |
| duckdb-iceberg | **`v1.5-variegata` @ `0fad545a`** | transaction code lives in `src/catalog/rest/transaction/`; the four patches apply here. |
| pg_duckdb | **merged PR #1025** (`c04e6a2`) | no released tag carries 1.5.x; `git checkout c04e6a2`. Its duckdb submodule is the v1.5.4 tag (`08e34c4`). |
| DuckDB | **v1.5.4 tag** (`08e34c4`) | pinned by pg_duckdb @ `c04e6a2`; the iceberg build re-pins ITS duckdb submodule to the same tag so the extension ABI matches the engine. The `duckdb.*` GUCs + PRE_COMMIT iceberg-commit deferral ColdFront relies on are unchanged. |
| duckdb-iceberg | **`v1.5-variegata` @ `0fad545a`** | extension code the four patches target — kept fixed, so the patches apply unchanged. The build re-pins its duckdb submodule to the v1.5.4 tag (the branch tracks duckdb `main`, which drifts off the release). Transaction code lives in `src/catalog/rest/transaction/`. |
| avro | **`7f423d69`** | the pin `v1.5-variegata` uses. |
| azure | **`v1.5-variegata` @ `563589b2`** | the ABI-matched sibling of iceberg's branch. **NOT `main`** — azure `main` collides at link (`multiple definition of duckdb::FileFlags::FILE_FLAGS_NULL_IF_NOT_EXISTS`). |
| postgres_scanner | duckdb-postgres **`main` @ `916d862b`** | the `postgres` ext; built bundled (ABI-matched, stamped v1.5.3), **shipped** in the image (never downloaded). Its vcpkg `libpq` build needs **flex** + **bison**. |
| libcurl | **build 8.12.0** (≥ 7.77) | **REQUIRED** — DuckDB 1.5.3 httpfs uses `CURLSSLOPT_AUTO_CLIENT_CERT` (≥ 7.77); the pgEdge base ships 7.76.1. 8.12.0 fixes CVE-2025-0665 (the 8.11.1 resolver SIGABRT); runtime still pins httplib regardless. |
| postgres_scanner | duckdb-postgres **`6b2b12ca`** | the `postgres` ext; built bundled (ABI-matched, stamped v1.5.4), **shipped** in the image (never downloaded). Its vcpkg `libpq` build needs **flex** + **bison**. |
| libcurl | **build 8.12.0** (≥ 7.77) | **REQUIRED** — DuckDB 1.5.4 httpfs uses `CURLSSLOPT_AUTO_CLIENT_CERT` (≥ 7.77); the pgEdge base ships 7.76.1. 8.12.0 fixes CVE-2025-0665 (the 8.11.1 resolver SIGABRT); runtime still pins httplib regardless. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## 6. Build — `docker/Dockerfile.duckdb15-base`

Expand All @@ -155,7 +155,7 @@ requirements (each a real build failure if missing):
- **`flex` + `bison`** for the `postgres_scanner` vcpkg `libpq` build.
- **azure pinned `v1.5-variegata` (`563589b2`), not `main`** (link collision).
- iceberg/avro/azure/postgres_scanner are built **bundled** against one DuckDB
(`make release`, `OVERRIDE_GIT_DESCRIBE=v1.5.3`) so they are ABI-safe; build
(`make release`, `OVERRIDE_GIT_DESCRIBE=v1.5.4`) so they are ABI-safe; build
config is `docker/iceberg-azure-extension-config-v15.cmake`.
- The bakery + three interop patches are `COPY`'d in and `git apply --check`'d
then applied (see the Dockerfile's patch block).
Expand All @@ -172,9 +172,9 @@ current source.

| File | Role |
|---|---|
| `docker/Dockerfile.duckdb15-base` | base: pg_duckdb 1.5.3 (PR #1025) + libcurl 8.12 + patched iceberg/avro/azure/postgres_scanner; runtime stage = the 4 extensions + entrypoint, **no coldfront**. |
| `docker/Dockerfile.duckdb15-base` | base: pg_duckdb 1.5.4 (PR #1025) + libcurl 8.12 + patched iceberg/avro/azure/postgres_scanner; runtime stage = the 4 extensions + entrypoint, **no coldfront**. |
| `docker/Dockerfile.duckdb15` | app: a `cf-build` stage compiles coldfront (PG devel only — coldfront links libpq, not pg_duckdb), then `FROM ${COLDFRONT_BASE}` copies the `.so`/SQL on top. |
| `docker/entrypoint.sh` | first-init: sets `COLDFRONT_DUCKDB_VERSION=v1.5.3`, pre-places the extensions under `$PGDATA/pg_duckdb/extensions/v1.5.3/<platform>/`, writes the GUCs. |
| `docker/entrypoint.sh` | first-init: sets `COLDFRONT_DUCKDB_VERSION=v1.5.4`, pre-places the extensions under `$PGDATA/pg_duckdb/extensions/v1.5.4/<platform>/`, writes the GUCs. |
| `docker/iceberg-azure-extension-config-v15.cmake` | the bundled-build extension config (iceberg + avro + azure + postgres_scanner). |
| `.github/workflows/base-image.yml` | builds + pushes the base via `GITHUB_TOKEN` (base rebuilds are rare). |

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ pgedge-coldfront/
│ ├── topo/ ← vanilla.sh (1 node) · mesh.sh (3-node Spock)
│ └── runbooks/ ← failover-patroni.md (failover delegated to Patroni)
├── docker/
│ ├── Dockerfile.duckdb15-base ← DuckDB 1.5.x base (pg_duckdb 1.5.3 + patched iceberg)
│ ├── Dockerfile.duckdb15-base ← DuckDB 1.5.x base (pg_duckdb 1.5.4 + patched iceberg)
│ ├── Dockerfile.duckdb15 ← thin coldfront app layer (ARG PG_MAJOR=16|17|18)
│ ├── iceberg-*.patch ← duckdb-iceberg patches (bakery commit-refresh + strict-reader interop)
│ ├── entrypoint.sh
Expand All @@ -238,7 +238,7 @@ against:
| Component | Version | Purpose |
|-----------|---------|---------|
| PostgreSQL | 16, 17, or 18 | Database with native partitioning (stock upstream; no fork) |
| pg_duckdb | 1.5.3 (PR #1025) | Iceberg reads + writes via DuckDB in-process |
| pg_duckdb | 1.5.4 (PR #1025) | Iceberg reads + writes via DuckDB in-process |
| duckdb-iceberg | `v1.5-variegata` @ `0fad545a`, patched | Iceberg catalog/IO for DuckDB; carries ColdFront's four patches (see [DUCKDB_1.5_PATCHED.md](DUCKDB_1.5_PATCHED.md)) |
| Lakekeeper | latest | Iceberg REST catalog (Rust binary) |
| S3-compatible store | any | SeaweedFS, MinIO, GCS, Azure Blob, etc. |
Expand Down
116 changes: 116 additions & 0 deletions cmd/compactor/flatwalk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package main

import (
"context"
"fmt"
"io"
stdfs "io/fs"
"net/url"
"reflect"
"strings"
"time"

iceio "github.com/apache/iceberg-go/io"
"github.com/apache/iceberg-go/table"
"gocloud.dev/blob"
)

// flatWalkIO wraps an iceberg-go FileIO and replaces WalkDir with a FLAT object
// list (no delimiter). iceberg-go's blob WalkDir does a hierarchical fs.WalkDir
// whose per-path Open() decides directory-vs-file via Exists(): on an object
// store an object at exactly a "directory" path (e.g. .../data) collides with
// the .../data/ prefix, gets returned by List as BOTH a file and a directory,
// and the recursive ReadDir on the phantom directory fails with the Go stdlib's
// literal "readdir …: not implemented". A flat list never opens a path as a
// directory, so the collision cannot occur. Everything else delegates to the
// wrapped FileIO, and orphan reachability + deletion stay iceberg-go's.
type flatWalkIO struct {
iceio.IO
}

func (f flatWalkIO) WalkDir(root string, fn stdfs.WalkDirFunc) error {
bucket, err := bucketOf(f.IO)
if err != nil {
// Non-blob backend (e.g. local FS): defer to the wrapped walk.
if lw, ok := f.IO.(iceio.ListableIO); ok {
return lw.WalkDir(root, fn)
}
return err
}
u, err := url.Parse(root)
if err != nil {
return fmt.Errorf("invalid URL %s: %w", root, err)
}
prefix := strings.TrimPrefix(u.Path, "/")
iter := bucket.List(&blob.ListOptions{Prefix: prefix}) // empty Delimiter => flat
for {
obj, err := iter.Next(context.Background())
if err == io.EOF {
break
}
if err != nil {
return err
}
if obj.IsDir { // not set without a delimiter, but be defensive
continue
}
full := *u
full.Path = "/" + obj.Key // preserve scheme + container@host, swap the key
if err := fn(full.String(), flatDirEntry{obj}, nil); err != nil {
return err
}
}
return nil
}

// bucketOf extracts the *blob.Bucket from a gocloud-backed iceberg FileIO by
// reflection — the same access iceberg-go itself uses internally
// (table/orphan_cleanup.go getBucketName). Errors for a non-blob FileIO.
func bucketOf(fio iceio.IO) (*blob.Bucket, error) {
v := reflect.ValueOf(fio)
if v.Kind() == reflect.Pointer {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return nil, fmt.Errorf("FileIO %T is not a struct", fio)
}
field := v.FieldByName("Bucket")
if !field.IsValid() {
return nil, fmt.Errorf("FileIO %T has no Bucket field", fio)
}
b, ok := field.Interface().(*blob.Bucket)
if !ok {
return nil, fmt.Errorf("FileIO %T Bucket field is not *blob.Bucket", fio)
}
return b, nil
}

// flatDirEntry / flatFileInfo adapt a gocloud ListObject to fs.DirEntry so
// iceberg-go's scanFiles can read ModTime/Size for the orphan-age filter.
type flatDirEntry struct{ obj *blob.ListObject }

func (e flatDirEntry) Name() string { return e.obj.Key }
func (e flatDirEntry) IsDir() bool { return false }
func (e flatDirEntry) Type() stdfs.FileMode { return 0 }
func (e flatDirEntry) Info() (stdfs.FileInfo, error) { return flatFileInfo(e), nil }

type flatFileInfo struct{ obj *blob.ListObject }

func (i flatFileInfo) Name() string { return i.obj.Key }
func (i flatFileInfo) Size() int64 { return i.obj.Size }
func (i flatFileInfo) Mode() stdfs.FileMode { return 0 }
func (i flatFileInfo) ModTime() time.Time { return i.obj.ModTime }
func (i flatFileInfo) IsDir() bool { return false }
func (i flatFileInfo) Sys() any { return nil }

// withFlatWalk reconstructs tbl so DeleteOrphanFiles walks via flatWalkIO.
// Orphan cleanup only reads metadata and lists/deletes files — it never calls
// the catalog — so a nil CatalogIO is safe here.
func withFlatWalk(ctx context.Context, tbl *table.Table) (*table.Table, error) {
realIO, err := tbl.FS(ctx)
if err != nil {
return nil, err
}
fsF := func(context.Context) (iceio.IO, error) { return flatWalkIO{realIO}, nil }
return table.New(tbl.Identifier(), tbl.Metadata(), tbl.MetadataLocation(), fsF, nil), nil
}
2 changes: 1 addition & 1 deletion cmd/compactor/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26.4
require (
github.com/apache/iceberg-go v0.6.0
github.com/jackc/pgx/v5 v5.10.0
gocloud.dev v0.45.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down Expand Up @@ -108,7 +109,6 @@ require (
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
gocloud.dev v0.45.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/net v0.53.0 // indirect
Expand Down
8 changes: 8 additions & 0 deletions cmd/compactor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,14 @@ func doOrphans(ctx context.Context, cat *rest.Catalog, ns, tableName string, o r
if err != nil {
return err
}
// Walk the table location with a flat object list: an object at exactly a
// directory path (e.g. .../data) otherwise collides with that prefix and
// iceberg-go's hierarchical walk dies with "readdir: not implemented" on
// object stores. See flatwalk.go.
tbl, err = withFlatWalk(ctx, tbl)
if err != nil {
return err
}
if o.dryRun {
n, derr := deleteOrphans(ctx, tbl, o.orphanAge, true)
if derr != nil {
Expand Down
4 changes: 2 additions & 2 deletions docker/Dockerfile.duckdb15
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ColdFront image on the DuckDB 1.5.x stack — the THIN coldfront layer on top of
# the prebuilt coldfront-duckdb-base. The base (docker/Dockerfile.duckdb15-base)
# carries the expensive, STABLE compiles — pg_duckdb 1.5.3 (PR #1025) and the
# carries the expensive, STABLE compiles — pg_duckdb 1.5.4 (PR #1025) and the
# patched duckdb-iceberg extensions (iceberg/avro/azure/postgres_scanner,
# v1.5-variegata + the bakery-aware commit-refresh patch). This build only
# compiles the coldfront C extension (seconds), so CI and local builds are fast
Expand Down Expand Up @@ -35,7 +35,7 @@ COPY extension/coldfront /build/coldfront
RUN DESTDIR=/out make -C /build/coldfront install with_llvm=no

# ─── app runtime: coldfront extension on top of the base ─────────────────────────
# pg_duckdb 1.5.3, the iceberg/avro/azure/postgres_scanner extensions, libcurl
# pg_duckdb 1.5.4, the iceberg/avro/azure/postgres_scanner extensions, libcurl
# 8.12, COLDFRONT_DUCKDB_* env and /data are inherited from the base. This stage
# adds coldfront's .so + SQL/control (distinct filenames — no overwrite of
# pg_duckdb) AND refreshes the entrypoint from the CURRENT branch.
Expand Down
Loading