Keep vCons as Parquet files in a bucket
Turn on Parquet storage over an S3 bucket: what the bucket must be, the configuration, a check that the server packs its objects into Parquet files and still reads each vCon back, a read of the files with DuckDB, what a purge removes from the bucket and from the storage's disks, and what each refusal means.
Parquet storage is in release 0.4.0 and later; release 0.3.0 refuses a
[storage] table as an unknown key. Install 0.4.0 or later as
install shows.
With S3 object storage, the server keeps one sealed
object per stored version in a bucket. With Parquet storage on as well, it
packs those objects into Apache Parquet
files: one file for each tenant and each date a version was stored, at
<prefix>tenants/<tenant id>/parquet/date=<yyyy-mm-dd>/part-<file id>.parquet.
Each row of a file is one version's sealed object, byte for byte, beside
its version id and the time it was stored, and nothing else: the files
hold no clear part of any vCon.
A new version is still written first as its own object. A pack run, at
start and then every pack_interval seconds, packs the objects into files
and removes them. A read finds a packed version by its row and fetches
that row's bytes with one ranged read. A purge rewrites the file without
the purged row, deletes the old file, and checks that the bucket no longer
has it, before the purge is done.
- The files hold no clear column. Each row is a version's id, the time
it was stored and its sealed bytes, whatever the tenant's metadata level.
For queryable columns (form, times, counts, and at
fullthe uuid, subject and parties), useexport parquet, which writes them at the tenant's level into files of their own. - Manifests and compaction. Each change of which files hold which rows,
and each pack run that finds the live versions changed, writes the next
numbered manifest, naming the files and the live versions. Each pack run
merges a date's small files into one. A file merged away, and an older
manifest, stay for
reader_graceseconds (900 unless set), so a reader on an older manifest can finish; a purge deletes them at once. A replaced or deleted version stays in its file, hidden, until retention purges it. - There is no way back. No command turns packed versions back into
objects, so once a tenant has a packed version, keep
[storage.parquet]. A start without it is refused, naming the table (see packed versions without the table): without it the server would pack no more, and its start would no longer refuse a bucket with Object Lock or versioning, either of which keeps the bytes of a pack file that a purge rewrote.
This page was run on Debian 13 under systemd, after keep vCons in an S3 bucket with RustFS 1.0.4 and export to Parquet. MinIO and AWS S3 were not run with these steps. CI runs the store's conformance suite on Parquet storage against RustFS and against MinIO on each change.
Before you start
- A server keeping its objects in a bucket, as
keep vCons in an S3 bucket sets it up. The
steps use that page's files in
/etc/vcon-serverand its shell variables from getting started ($TENANT,$RECORDERto write,$MONITORto read). - A bucket whose versioning was never turned on, with no Object Lock.
A versioned bucket keeps a deleted file's bytes as an earlier version, and
Object Lock can refuse to delete a file, so a purge could not remove the
bytes. The server refuses such a bucket for Parquet storage at start,
whatever
storage.s3.allow_versioningsays. - The key's rights. The pack run and the purge need
s3:GetObject(with ranges),s3:PutObject,s3:DeleteObjectands3:ListBucketon the bucket, as the objects already do, and the start reads the bucket's Object Lock setting (s3:GetBucketObjectLockConfiguration). - DuckDB, to read the files. The export page installs it.
Check the bucket
The steps sign requests with curl, as the S3 page does. Its clean-up
unset the key; set it again:
S3_SECRET=$(sudo cat /etc/rustfs/secret_key)
S3=(--cacert /usr/local/share/vcon-server/s3-ca.pem --aws-sigv4 aws:amz:us-east-1:s3 --user "vcon-server:$S3_SECRET")
The S3 page checked that the bucket was never versioned. Check that it has no Object Lock: a bucket without it answers 404 for its lock configuration:
curl -sS -o /dev/null -w '%{http_code}\n' "${S3[@]}" "https://objects.example.com:9000/vcon?object-lock"
404
From objects in the data directory
A server that keeps its objects in the data directory moves to Parquet
storage in two steps, with one rule each: first to objects in a bucket,
with vcon-server storage move-objects while the server is stopped (see
move objects stored in the data directory),
then to Parquet storage over that bucket, as below. There is no direct
step from the data directory to Parquet files.
Turn Parquet storage on
The bucket already holds an object for each version stored on the earlier pages. Turning Parquet storage on over the same bucket and prefix packs those too, oldest date first, as the first pack runs go: this is how a server moves from S3 objects to Parquet storage. Reads work all through, since a version is either an object or a packed row.
Save the configuration again with a [storage.parquet] table. Here the
pack run comes every 10 seconds, so the check below is quick; leave
pack_interval out for the default of 300:
listen = "0.0.0.0:8443"
data_dir = "/var/lib/vcon-server"
master_key = "/etc/vcon-server/master.key"
[tls]
cert = "/etc/vcon-server/cert.pem"
key = "/etc/vcon-server/key.pem"
[storage]
objects = "s3"
[storage.s3]
endpoint = "https://objects.example.com:9000"
region = "us-east-1"
bucket = "vcon"
path_style = true
access_key_id = "vcon-server"
secret_access_key_file = "/etc/vcon-server/s3.secret"
ca_file = "/usr/local/share/vcon-server/s3-ca.pem"
[storage.parquet]
pack_interval = 10
pack_limit, 16 MiB unless set, is the largest sealed object packed; a
larger one, such as a vCon with a long recording inline, stays an object.
Every key is on the configuration page.
Install it and restart the server. At start, it reads the bucket's versioning and Object Lock, and stops with an error naming the bucket if either is on:
sudo install -m 0400 -o vcon -g vcon vcon-server.toml /etc/vcon-server/vcon-server.toml
sudo systemctl restart vcon-server
timeout 30 sh -c 'until curl -s -o /dev/null --cacert /usr/local/share/vcon-server/ca.pem https://vcon.example.com:8443/health; do sleep 1; done' && echo answering
answering
Check that the server packs its objects
The pack run at start packs the objects the earlier pages stored. Wait until no object is left under the tenant's name, and note the files:
LIST="https://objects.example.com:9000/vcon?list-type=2&prefix=tenants/"
FILES="https://objects.example.com:9000/vcon?list-type=2&prefix=tenants/$TENANT/parquet/"
for i in $(seq 30); do
curl -sS --fail-with-body "${S3[@]}" "$LIST" | grep -q '/objects/' || break
sleep 2
done
BEFORE=$(curl -sS --fail-with-body "${S3[@]}" "$FILES" | grep -o '<Key>[^<]*' | cut -c6-)
echo "$BEFORE"
tenants/<tenant id>/parquet/date=<yyyy-mm-dd>/part-<file id>.parquet
Store a vCon with the write key:
UUID=$(cat /proc/sys/kernel/random/uuid)
cat > packed.vcon <<EOF
{"vcon":"0.4.0","uuid":"$UUID","created_at":"2026-11-06T12:00:00Z","subject":"Packed example","parties":[{"tel":"+15555550102","name":"Packed Example"}],"dialog":[],"attachments":[],"analysis":[]}
EOF
curl -sS -o /dev/null -w '%{http_code}\n' --cacert /usr/local/share/vcon-server/ca.pem https://vcon.example.com:8443/vcons \
-H "Authorization: Bearer $RECORDER" -H "Content-Type: application/vcon" --data-binary @packed.vcon
201
Its object is in the bucket until the next pack run takes it. That run packs it into a file of its own, then merges the date's two small files into a third. Wait for it, then list the tenant's files: the file from before and the new one, both merged away and kept for the reader grace, and the merged file, which the catalog names now:
for i in $(seq 30); do
curl -sS --fail-with-body "${S3[@]}" "$LIST" | grep -q '/objects/' || break
sleep 2
done
AFTER=$(curl -sS --fail-with-body "${S3[@]}" "$FILES" | grep -o '<Key>[^<]*' | cut -c6-)
echo "$AFTER" | wc -l
3
Each pack run is logged with counts only, never a tenant:
sudo journalctl -u vcon-server --since -2min -o cat | grep 'pack run' | grep -v 'versions=0' | tail -n 1
<time> INFO vcon_server::serve: pack run files=1 versions=1 failed=0
The vCon reads back byte for byte, from its row:
curl -sS --fail-with-body --cacert /usr/local/share/vcon-server/ca.pem "https://vcon.example.com:8443/vcons/$UUID" \
-H "Authorization: Bearer $MONITOR" -o read.vcon
cmp read.vcon packed.vcon && echo "the same bytes"
the same bytes
vcon-server check finds every packed version's file in the bucket, at the
length the catalog records:
sudo systemctl stop vcon-server
sudo -u vcon vcon-server check --config /etc/vcon-server/vcon-server.toml | jq -c 'select(.name != null) | {name, damaged}'
sudo systemctl start vcon-server
timeout 30 sh -c 'until curl -s -o /dev/null --cacert /usr/local/share/vcon-server/ca.pem https://vcon.example.com:8443/health; do sleep 1; done' && echo answering
{"name":"acme","damaged":[]}
answering
Read the files with DuckDB
The files are plain Parquet. DuckDB reads the date= directories as a
DATE column, and the version id as a UUID; sealed is ciphertext that
only the server can open:
sudo -u vcon duckdb -csv -c "
INSTALL httpfs; LOAD httpfs;
SET ca_cert_file = '/usr/local/share/vcon-server/s3-ca.pem';
CREATE SECRET (TYPE s3, KEY_ID 'vcon-server', SECRET '$S3_SECRET', REGION 'us-east-1',
ENDPOINT 'objects.example.com:9000', URL_STYLE 'path');
SELECT typeof(version) AS version, typeof(date) AS date, count(*) > 0 AS rows
FROM read_parquet('s3://vcon/tenants/$TENANT/parquet/*/*.parquet', hive_partitioning = true)
GROUP BY ALL;"
Success
true
version,date,rows
UUID,DATE,true
A glob reads every file in the bucket, so it can count a row twice while a
merged-away file waits out the reader grace. To read exactly what the
server holds, read through the newest manifest: list manifest/, take the
highest number, and read the files it names, keeping the rows it lists as
live (the current version of each vCon not deleted). Here DuckDB checks
that every live version is in those files:
MANIFEST=$(curl -sS --fail-with-body "${S3[@]}" "https://objects.example.com:9000/vcon?list-type=2&prefix=tenants/$TENANT/manifest/" \
| grep -o '<Key>[^<]*' | cut -c6- | tail -n 1)
sudo -u vcon duckdb -csv -c "
INSTALL httpfs; LOAD httpfs;
SET ca_cert_file = '/usr/local/share/vcon-server/s3-ca.pem';
CREATE SECRET (TYPE s3, KEY_ID 'vcon-server', SECRET '$S3_SECRET', REGION 'us-east-1',
ENDPOINT 'objects.example.com:9000', URL_STYLE 'path');
SET VARIABLE files = (SELECT list('s3://vcon/tenants/$TENANT/' || f.path) FROM (SELECT unnest(files) AS f FROM read_json('s3://vcon/$MANIFEST')));
SET VARIABLE live = (SELECT live FROM read_json('s3://vcon/$MANIFEST'));
SELECT count(*) = len(getvariable('live')) AS every_live_version
FROM read_parquet(getvariable('files'))
WHERE list_contains(getvariable('live'), replace(version::VARCHAR, '-', ''));"
Success
true
every_live_version
true
A file a manifest names stays at least reader_grace seconds after a newer
manifest stops naming it, unless a purge deletes it at once; a reader that
then finds a file gone takes the newest manifest again.
Check what a purge removes
A purge removes the row's data key in its own transaction, so from then on
nobody can open the purged version, even with a copy of the file. Then it
writes the file again without the row, deletes the old file, and checks
with HeadObject that the bucket no longer has it. Only then is the purge
done. A purge also deletes, at once, every file still kept for the reader
grace: any of them may hold the purged row. With a retention period of
0d, a delete purges at once:
DELETER=$(sudo -u vcon vcon-server key create --tenant "$TENANT" --scope delete --label parquet-check \
--config /etc/vcon-server/vcon-server.toml | jq -r .token)
sudo -u vcon vcon-server tenant set-retention "$TENANT" 0d --config /etc/vcon-server/vcon-server.toml | jq -c '{retention}'
curl -sS -o /dev/null -w '%{http_code}\n' --cacert /usr/local/share/vcon-server/ca.pem -X DELETE \
"https://vcon.example.com:8443/vcons/$UUID" -H "Authorization: Bearer $DELETER"
sudo -u vcon vcon-server tenant set-retention "$TENANT" default --config /etc/vcon-server/vcon-server.toml | jq -c '{retention}'
for key in $AFTER; do
curl -sS -o /dev/null -w '%{http_code}\n' "${S3[@]}" -I "https://objects.example.com:9000/vcon/$key"
done
{"retention":"0d"}
204
{"retention":null}
404
404
404
HeadObject on each of the three files answers 404: all are gone from the bucket. The merged file held the purged row, so its other rows were written again into one new file under a new name; the two merged away were deleted without waiting for the grace. One file is left, and no object in the bucket holds the purged vCon's uuid:
curl -sS --fail-with-body "${S3[@]}" "$FILES" | grep -o '<Key>[^<]*' | cut -c6- | wc -l
for key in $(curl -sS --fail-with-body "${S3[@]}" "https://objects.example.com:9000/vcon?list-type=2" | grep -o '<Key>[^<]*' | cut -c6-); do
if curl -sS --fail-with-body "${S3[@]}" "https://objects.example.com:9000/vcon/$key" | grep -q -a "$UUID"; then
echo "$key holds it"
fi
done
echo "scanned"
1
scanned
What a delete leaves on the storage's disks
An S3 DELETE removes the bytes from what any S3 call can read, at once. The storage system removes them from its disks later, on its own schedule:
- RustFS. Measured on RustFS 1.0.1 on 2026-11-05: a deleted object's
bytes were moved to
.rustfs.sys/tmp/.trash/under the data directory. They were still on disk about a minute after the DELETE, and gone about 3.6 minutes after it. RustFS removes them with a background task whose interval was not measured. - MinIO and AWS S3. When they remove a deleted object's bytes from their disks was not measured, and AWS does not document it.
Until then, the purged row's sealed bytes are on the disk, but nobody can open them: the data key that opened them was in the catalog row the purge deleted. The files hold no clear column. A bucket's lifecycle rules, and backups of the storage host's disks, are the storage operator's: a copy taken there keeps a file after the server deletes it.
Clean up
rm -f packed.vcon read.vcon vcon-server.toml
unset S3_SECRET S3 LIST FILES BEFORE AFTER DELETER MANIFEST
When it does not start
The storage table does not hold together
[storage.parquet] without storage.objects = "s3":
vcon-server: configuration: storage.parquet packs the objects of a bucket, so it needs storage.objects = "s3"
A pack_interval or pack_limit of 0 is refused by name, such as
storage.parquet.pack_interval must be more than 0.
The bucket has versioning on
vcon-server: the storage cannot be used: bucket "vcon" has versioning Enabled, which keeps a deleted file's bytes as an earlier version; Parquet storage (storage.parquet) needs a bucket that was never versioned, and storage.s3.allow_versioning does not apply
A bucket that ever had versioning on reports Suspended once it is turned
off, and is refused too. Use a new bucket, and move the objects to it while
the server is stopped, with Parquet storage off.
The bucket has Object Lock on
vcon-server: the storage cannot be used: bucket "vcon" has Object Lock on, which can refuse to delete a file a purge rewrote; Parquet storage (storage.parquet) needs a bucket without Object Lock
Object Lock can only be set when a bucket is made. Use a bucket made without it. A legal hold on a vCon is kept in the catalog, not with Object Lock: see retention and holds.
Packed versions without the table
Once a tenant has a packed version, a configuration without
[storage.parquet] is refused at start:
vcon-server: 1 tenant(s) have versions packed into Parquet files, and the configuration has no [storage.parquet]: without it the server packs no more, and the start no longer refuses a bucket with Object Lock or versioning, either of which keeps the bytes of a pack file that a purge rewrote; put the [storage.parquet] table back, since packed versions cannot be turned back into objects; vcon-server check names the tenants
Put the [storage.parquet] table back, over the same bucket and prefix,
and start the server again. To see which tenants have packed versions, run
vcon-server check with the server stopped: each such tenant's line gives
packed above 0 and the problem
<n> version(s) are packed into Parquet files, and the configuration has no [storage.parquet]: serve refuses to start until the table is put back,
and check exits 1.
Only serve refuses. check reports the problem and goes on, since its
report is how an operator finds the tenants. The other commands neither
refuse nor warn: none of them runs a pack run or a purge sweep, and with
storage.objects = "s3" they read packed versions from the bucket as the
server does, so a backup or a key rotation can still be
made while the configuration is put right.
When a pack run or a purge fails
A pack run that fails leaves every version readable, as an object or as a row, and the next run, or start-up recovery, finishes or removes what it left. A purge that fails after it removed the data key reports a failure, and the purged version is gone at once; the next purge sweep or start finishes the file. Each of these is logged without a tenant or a name:
pack run files=<n> versions=<n> failed=<n>withfailedabove 0: a tenant's run failed, as when the bucket cannot be reached.a pack file's new name was already taken; the pack run stops: a write withIf-None-Match: *was refused, so another writer used a name this server chose. Check that no second server uses the same bucket and prefix (see one server per bucket and prefix).the storage still holds a pack file it was told to delete: the bucket answered a DELETE with success but still has the file, which a versioned bucket does. The purge does not report itself done.purges left pack files to rewrite after 4 rounds: another purge kept rewriting the same file; the next sweep goes on.start-up recovery removed pack files that no catalog row names removed=<n>: a pack run or a rewrite was cut off after it wrote a file and before the catalog recorded it.
Source of this page: website/content/docs/parquet-storage.md