Redshift connector
Extracts a Redshift table by UNLOAD to your own S3 bucket: the cluster
writes UNLOAD ('SELECT <mapped columns> …') TO 's3://{bucket}/{prefix}/tessera/{sync_id}/part_' IAM_ROLE '{iam_role_arn}' FORMAT PARQUET PARALLEL ON MAXFILESIZE 256 MB; Tessera
reads the produced Parquet objects back, ingests them, then deletes them.
A deletion failure never fails the sync — it surfaces as a
redshift_unload_cleanup_failed warning in the sync's details so you can
clean the prefix yourself. Only mapped columns leave the cluster;
incremental cursor literals are rendered server-side, never from raw user
strings.
Prerequisites
- An API key with the
adminscope. - An S3 bucket + prefix for unloads, in the cluster's region.
- An IAM role attached to the cluster with
s3:PutObjecton the prefix (theUNLOADwriter). - Credentials Tessera reads the unloaded objects with: an access key pair
holding
s3:ListBucket,s3:GetObject,s3:DeleteObjecton the same prefix. - A cluster endpoint with a public address. Tessera connects only to
public internet addresses.
config.hostfollows the Postgres connector's destination rules, and the port is 5439 or 1024–65535. A refused endpoint failsauthenticatewithfailure: configuration.PUT /mappingreturns 400invalid_argumentwith afield_violationonconfig/hostorconfig/port, and a sync fails withextraction_failed.
Create the credential (least privilege)
A read-only database user:
CREATE USER tessera_reader PASSWORD '...';
GRANT USAGE ON SCHEMA ops TO tessera_reader;
GRANT SELECT ON ops.scan_events TO tessera_reader;
If your cluster restricts IAM role use (after REVOKE ASSUMEROLE … FROM PUBLIC), also run GRANT ASSUMEROLE ON 'arn:aws:iam::123456789012:role/redshift-tessera-unload' TO tessera_reader FOR UNLOAD;.
The unload IAM role policy (attached to the cluster):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": "arn:aws:s3:::acme-exports/tessera-unloads/*"
}, {
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::acme-exports"
}]
}
The policy for the key pair Tessera reads the unloaded objects with — it lists, reads and deletes only Tessera's own unload prefix:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::acme-exports",
"Condition": { "StringLike": { "s3:prefix": ["tessera-unloads/tessera/*"] } }
}, {
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::acme-exports/tessera-unloads/tessera/*"
}]
}
With SSE-KMS on the bucket, the role also needs kms:GenerateDataKey and
Tessera's key pair kms:Decrypt on that key.
Create the source
{
"name": "scan-events",
"kind": "redshift",
"config": {
"host": "acme-cluster.abc123.us-east-1.redshift.amazonaws.com",
"port": 5439,
"database": "ops",
"schema": "ops",
"table": "scan_events",
"unload": {
"s3_bucket": "acme-exports",
"s3_prefix": "tessera-unloads",
"region": "us-east-1",
"iam_role_arn": "arn:aws:iam::123456789012:role/redshift-tessera-unload"
}
},
"credentials": {
"username": "tessera_reader",
"password": "...",
"unload_access_key_id": "AKIA...",
"unload_secret_access_key": "..."
}
}
The unload_* key pair is what Tessera reads the unloaded objects with;
omit it to use the platform's ambient AWS identity (the same mechanics the
S3 bucket connector uses). The config object validates against the
connector's registered schema:
{
"host": "acme-cluster.abc123.us-east-1.redshift.amazonaws.com",
"database": "ops",
"schema": "ops",
"table": "scan_events",
"unload": {
"s3_bucket": "acme-exports",
"s3_prefix": "tessera-unloads",
"region": "us-east-1",
"iam_role_arn": "arn:aws:iam::123456789012:role/redshift-tessera-unload"
}
}
Test the connection
POST /sources/{id}/connection-diagnostics runs three checks in order, each tagged
with the diagnostic stage it verifies:
| Check | Stage | Verifies |
|---|---|---|
authenticate | authentication | the database credentials connect and run a probe |
table_readable | schema | the table exists and its schema is readable |
unload_probe | authorization | a full round-trip: UNLOAD ('SELECT 1') to the configured prefix, read back through Tessera's S3 path, then deleted — proving both the IAM role and the read credentials |
A failed check carries a typed failure cause and a detail naming the fix
for the phase that failed: the cluster's UNLOAD (IAM role and its
s3:PutObject), Tessera's read (s3:ListBucket, s3:GetObject) or the
cleanup (s3:DeleteObject); later checks report not_attempted. Details
never repeat Redshift's or AWS's message or any credential. The response's
checked_at is the server time of the test, which is also recorded in the
audit log as source.test_connection. POST /sources/{id}/test-connection
runs the same checks and returns only their names, statuses and details.
Rotate the credential
The source holds the database user's password and, optionally, the
unload_* access key pair that Tessera reads the unloaded objects with. Both
are long-lived; Tessera does not accept a session token for the key pair. To
rotate, create the new password or key, then PATCH /sources/{id} with
credentials. The body replaces the stored credentials entirely, so repeat
every field you still use, and send the two halves of the key pair together.
Confirm POST /sources/{id}/connection-diagnostics passes, then retire the
old credential. The IAM role in unload.iam_role_arn is the role Redshift
assumes for UNLOAD; it is part of the source config, not a credential
Tessera holds.
Type map
Column types map onto canonical attribute types exactly as follows; mapping
a column whose type is unsupported fails PUT /sources/{id}/mapping
naming the column and its type. Redshift has no array type — no column maps
to array.
| Redshift type | Canonical type | Notes |
|---|---|---|
smallint | integer | Exact integers (INT2). |
integer | integer | Exact integers (INT, INT4). |
bigint | integer | Exact 64-bit integers (INT8). |
decimal | number | NUMERIC — converted to float64 (beyond-precision values convert lossily). An entity or event id column keeps its exact decimal value. |
real | number | IEEE-754 floats (FLOAT4). |
double precision | number | IEEE-754 float64 (FLOAT, FLOAT8), as-is. |
boolean | boolean | Booleans, as-is (BOOL). |
char | string | Fixed-length strings, as returned by Redshift (CHARACTER, NCHAR, BPCHAR). |
varchar | string | UTF-8 strings (CHARACTER VARYING, NVARCHAR, TEXT), ≤ 4096 chars per 002 §2. |
timestamptz | timestamp | TIMESTAMP WITH TIME ZONE — converted to RFC 3339 UTC. |
timestamp | timestamp | TIMESTAMP WITHOUT TIME ZONE — naive wall-clock time: the mapping's IANA timezone is required; without it, rows carrying a value are rejected. |
date | timestamp | Midnight UTC of the date (YYYY-MM-DDT00:00:00Z). |
super | unsupported | Cannot be mapped (flatten upstream); PUT /mapping fails naming the column. |
varbyte | unsupported | Cannot be mapped; PUT /mapping fails naming the column. |
geometry | unsupported | Cannot be mapped; PUT /mapping fails naming the column. |
geography | unsupported | Cannot be mapped; PUT /mapping fails naming the column. |
hllsketch | unsupported | Cannot be mapped; PUT /mapping fails naming the column. |
interval | unsupported | Cannot be mapped; PUT /mapping fails naming the column. |
DATE maps to midnight UTC by convention; if your dates carry local-day
semantics, expose a naive TIMESTAMP column instead and set the mapping
timezone.
Worked example
A logistics table ops.scan_events unloaded nightly maps as: entity
{constant: "shipment", id_column: "shipment_id"}, event type constant
shipment.scanned, timestamp scanned_at, attributes hub_code,
weight_kg, temp_c, cursor scanned_at.