Fluxer PostgreSQL→Cassandra migration tool
Type-aware migration from Fluxer's PostgreSQL fluxer_kv backend to the native Apache Cassandra schema, plus scylla-feature image builds and a full cutover runbook. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+308
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fluxer: PostgreSQL KV table → ScyllaDB/Cassandra migration
|
||||
*
|
||||
* Prerequisites:
|
||||
* 1. ScyllaDB is running and the 'fluxer' keyspace exists
|
||||
* 2. fluxer_api has started once against ScyllaDB so all tables are created
|
||||
* 3. PostgreSQL is still up and serving the original data
|
||||
*
|
||||
* Usage (prefer an --env-file with docker; values are taken literally):
|
||||
* PG_HOST=10.1.10.36 PG_USER=fluxer PG_PASSWORD=... PG_DATABASE=fluxer \
|
||||
* SCYLLA_HOSTS=10.1.10.38:9042 SCYLLA_DC=atl \
|
||||
* CASSANDRA_USERNAME=fluxer CASSANDRA_PASSWORD=... \
|
||||
* npm run dry-run # verify without writing
|
||||
* npm run migrate # real migration
|
||||
*/
|
||||
|
||||
import pg from 'pg';
|
||||
import cassandra from 'cassandra-driver';
|
||||
|
||||
const { Client: PgClient } = pg;
|
||||
const { types: cassTypes, auth: cassAuth } = cassandra;
|
||||
|
||||
// ── Configuration ──────────────────────────────────────────────────────────
|
||||
// PostgreSQL connection comes from PG_HOST/PG_PORT/PG_USER/PG_PASSWORD/PG_DATABASE (see main()).
|
||||
const SCYLLA_HOSTS = (process.env.SCYLLA_HOSTS ?? '10.1.10.X:9042').split(','); // replace X
|
||||
const SCYLLA_DC = process.env.SCYLLA_DC ?? 'datacenter1';
|
||||
const KEYSPACE = process.env.CASSANDRA_KEYSPACE ?? 'fluxer';
|
||||
const KV_TABLE = process.env.KV_TABLE ?? 'fluxer_kv';
|
||||
const BATCH_SIZE = parseInt(process.env.BATCH_SIZE ?? '200');
|
||||
const CONCURRENCY = parseInt(process.env.CONCURRENCY ?? '16');
|
||||
|
||||
// Fluxer snowflake epoch and message bucket window (10 days in ms)
|
||||
const BUCKET_DURATION_MS = 864_000_000n;
|
||||
|
||||
function messageBucket(messageIdStr) {
|
||||
return (BigInt(messageIdStr) >> 22n) / BUCKET_DURATION_MS;
|
||||
}
|
||||
|
||||
// ── Column-type introspection ───────────────────────────────────────────────
|
||||
// The generic transform can't know a destination column's real CQL type (e.g.
|
||||
// varint vs bigint, or map<> vs a plain array), so we read the live schema from
|
||||
// system_schema.columns and coerce each value to match its target column.
|
||||
let colTypes = {}; // { tableName: { columnName: cqlType } }
|
||||
|
||||
// Fixed node (6 bytes) + clock (2 bytes) so timeuuids reconstructed from a stored
|
||||
// timestamp are DETERMINISTIC — re-running upserts the same row instead of creating
|
||||
// a duplicate. Postgres' KV row_key already guarantees a unique (pk, event_id)
|
||||
// timestamp per partition, so ticks=0 won't collide within a partition.
|
||||
const TUUID_NODE = Buffer.from('fluxer'); // exactly 6 bytes
|
||||
const TUUID_CLOCK = Buffer.from('kv'); // exactly 2 bytes
|
||||
|
||||
async function loadColumnTypes(cassClient, keyspace) {
|
||||
const res = await cassClient.execute(
|
||||
`SELECT table_name, column_name, type FROM system_schema.columns WHERE keyspace_name = ?`,
|
||||
[keyspace], { prepare: true }
|
||||
);
|
||||
const map = {};
|
||||
for (const r of res.rows) (map[r.table_name] ??= {})[r.column_name] = r.type;
|
||||
return map;
|
||||
}
|
||||
|
||||
// Coerce a transformed value to satisfy its destination CQL column type.
|
||||
function coerceForColumn(value, cqlType) {
|
||||
if (value == null || !cqlType) return value;
|
||||
// varint: the driver rejects Long (that's bigint); it accepts a string.
|
||||
if (cqlType === 'varint') return value?.toString?.() ?? String(value);
|
||||
// map<...>: source may give an array of [k,v] pairs or a plain object → Map.
|
||||
if (cqlType.startsWith('map<')) {
|
||||
if (value instanceof Map) return value;
|
||||
if (Array.isArray(value)) return new Map(value);
|
||||
if (typeof value === 'object') return new Map(Object.entries(value));
|
||||
return value;
|
||||
}
|
||||
// timeuuid: Postgres stored these as a plain timestamp (the KV model has no
|
||||
// timeuuid). Reconstruct a time-ordered UUID from the date so clustering order
|
||||
// is preserved; a string value is already a uuid and passes through.
|
||||
if (cqlType === 'timeuuid') {
|
||||
return value instanceof Date
|
||||
? cassTypes.TimeUuid.fromDate(value, 0, TUUID_NODE, TUUID_CLOCK)
|
||||
: value;
|
||||
}
|
||||
// uuid: expect a real uuid string already.
|
||||
if (cqlType === 'uuid') return value;
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Type transformation ─────────────────────────────────────────────────────
|
||||
// Fluxer's PostgreSQL KV layer encodes non-JSON-native types with a
|
||||
// __fluxer_type tag. Map them to the types cassandra-driver expects.
|
||||
function transformValue(v) {
|
||||
if (v === null || v === undefined) return v;
|
||||
if (Array.isArray(v)) return v.map(transformValue);
|
||||
if (typeof v === 'object') {
|
||||
if ('__fluxer_type' in v) {
|
||||
switch (v.__fluxer_type) {
|
||||
case 'bigint':
|
||||
// Snowflake IDs are 64-bit — use Long to avoid precision loss
|
||||
return cassTypes.Long.fromString(String(v.value));
|
||||
case 'date':
|
||||
return new Date(v.value);
|
||||
case 'buffer':
|
||||
return Buffer.from(v.value, 'base64');
|
||||
case 'set':
|
||||
return Array.isArray(v.value) ? v.value.map(transformValue) : [];
|
||||
case 'map': {
|
||||
const out = {};
|
||||
for (const [k, val] of Object.entries(v.value ?? {})) {
|
||||
out[String(transformValue(k))] = transformValue(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
default:
|
||||
return v.value ?? null;
|
||||
}
|
||||
}
|
||||
// Plain nested object — recurse
|
||||
const out = {};
|
||||
for (const [k, val] of Object.entries(v)) {
|
||||
out[k] = transformValue(val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── Concurrency helper ──────────────────────────────────────────────────────
|
||||
async function runConcurrently(items, limit, fn) {
|
||||
for (let i = 0; i < items.length; i += limit) {
|
||||
const results = await Promise.allSettled(items.slice(i, i + limit).map(fn));
|
||||
const failed = results.filter(r => r.status === 'rejected');
|
||||
if (failed.length > 0) throw failed[0].reason;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-table migration ─────────────────────────────────────────────────────
|
||||
async function migrateTable(pgClient, cassClient, tableName, dryRun) {
|
||||
const { rows: [{ count }] } = await pgClient.query(
|
||||
`SELECT COUNT(*) FROM ${KV_TABLE} WHERE table_name = $1`,
|
||||
[tableName]
|
||||
);
|
||||
const total = parseInt(count);
|
||||
|
||||
if (total === 0) {
|
||||
console.log(`[${tableName}] empty, skipping`);
|
||||
return { migrated: 0, errors: 0 };
|
||||
}
|
||||
|
||||
const seenBuckets = new Set();
|
||||
let migrated = 0;
|
||||
let errors = 0;
|
||||
process.stdout.write(`[${tableName}] 0/${total}`);
|
||||
|
||||
for (let offset = 0; offset < total; offset += BATCH_SIZE) {
|
||||
const { rows } = await pgClient.query(
|
||||
`SELECT row_data, expires_at
|
||||
FROM ${KV_TABLE}
|
||||
WHERE table_name = $1
|
||||
ORDER BY row_key
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[tableName, BATCH_SIZE, offset]
|
||||
);
|
||||
|
||||
await runConcurrently(rows, CONCURRENCY, async (row) => {
|
||||
try {
|
||||
const data = transformValue(row.row_data);
|
||||
|
||||
// Messages: derive the bucket from the snowflake message_id and also
|
||||
// populate channel_message_buckets so the Rust shard can list buckets.
|
||||
if (tableName === 'messages' && data.message_id != null) {
|
||||
const bucket = messageBucket(String(data.message_id));
|
||||
data.bucket = cassTypes.Long.fromString(String(bucket));
|
||||
|
||||
const key = `${data.channel_id}:${bucket}`;
|
||||
if (!seenBuckets.has(key)) {
|
||||
seenBuckets.add(key);
|
||||
if (!dryRun) {
|
||||
await cassClient.execute(
|
||||
`INSERT INTO ${KEYSPACE}.channel_message_buckets (channel_id, bucket) VALUES (?, ?)`,
|
||||
[data.channel_id, data.bucket],
|
||||
{ prepare: true }
|
||||
).catch(() => {}); // idempotent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cols = Object.keys(data);
|
||||
const types = colTypes[tableName] ?? {};
|
||||
const vals = cols.map(c => coerceForColumn(data[c], types[c]));
|
||||
const phs = cols.map(() => '?').join(', ');
|
||||
const ttlSec = row.expires_at
|
||||
? Math.max(1, Math.floor((new Date(row.expires_at).getTime() - Date.now()) / 1000))
|
||||
: null;
|
||||
|
||||
let query = `INSERT INTO ${KEYSPACE}.${tableName} (${cols.join(', ')}) VALUES (${phs})`;
|
||||
if (ttlSec) query += ` USING TTL ${ttlSec}`;
|
||||
|
||||
if (!dryRun) {
|
||||
await cassClient.execute(query, vals, { prepare: true });
|
||||
}
|
||||
migrated++;
|
||||
} catch (err) {
|
||||
errors++;
|
||||
// Log first few errors per table to surface schema mismatches
|
||||
if (errors <= 3) console.error(`\n [${tableName}] row error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdout.write(`\r[${tableName}] ${migrated}/${total} (${Math.round(migrated / total * 100)}%)`);
|
||||
}
|
||||
|
||||
const errStr = errors > 0 ? ` — ${errors} errors` : '';
|
||||
console.log(` ✓${errStr}`);
|
||||
return { migrated, errors };
|
||||
}
|
||||
|
||||
// Migrate in dependency order; messages last (needs bucket table populated)
|
||||
const PRIORITY_ORDER = [
|
||||
'users', 'guilds', 'roles', 'channels', 'guild_members',
|
||||
'auth_sessions', 'read_states', 'guild_bans', 'emojis', 'stickers',
|
||||
'attachments', 'webhooks', 'invites',
|
||||
];
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────────
|
||||
async function main() {
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
if (dryRun) console.log('=== DRY RUN — nothing will be written to ScyllaDB ===\n');
|
||||
|
||||
// PostgreSQL — component-based config so passwords with URL-special chars
|
||||
// (@, #, %, etc.) don't need percent-encoding; node-postgres handles them raw.
|
||||
const pgClient = new PgClient({
|
||||
host: process.env.PG_HOST ?? '10.1.10.36',
|
||||
port: parseInt(process.env.PG_PORT ?? '5432'),
|
||||
user: process.env.PG_USER ?? 'fluxer',
|
||||
password: process.env.PG_PASSWORD ?? '',
|
||||
database: process.env.PG_DATABASE ?? 'fluxer',
|
||||
});
|
||||
await pgClient.connect();
|
||||
console.log(`PostgreSQL ✓ ${process.env.PG_USER ?? 'fluxer'}@${process.env.PG_HOST ?? '10.1.10.36'}:${process.env.PG_PORT ?? '5432'}/${process.env.PG_DATABASE ?? 'fluxer'}`);
|
||||
|
||||
// ScyllaDB
|
||||
const authProvider = process.env.CASSANDRA_USERNAME
|
||||
? new cassAuth.PlainTextAuthProvider(
|
||||
process.env.CASSANDRA_USERNAME,
|
||||
process.env.CASSANDRA_PASSWORD ?? ''
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const cassClient = new cassandra.Client({
|
||||
contactPoints: SCYLLA_HOSTS,
|
||||
localDataCenter: SCYLLA_DC,
|
||||
keyspace: KEYSPACE,
|
||||
authProvider,
|
||||
socketOptions: { connectTimeout: 30_000 },
|
||||
queryOptions: { consistency: cassTypes.consistencies.localQuorum },
|
||||
});
|
||||
await cassClient.connect();
|
||||
colTypes = await loadColumnTypes(cassClient, KEYSPACE);
|
||||
console.log(`ScyllaDB ✓ ${SCYLLA_HOSTS.join(', ')} / keyspace=${KEYSPACE} / ${Object.keys(colTypes).length} tables introspected\n`);
|
||||
|
||||
// Discover tables
|
||||
const { rows: tableRows } = await pgClient.query(
|
||||
`SELECT table_name, COUNT(*) AS cnt
|
||||
FROM ${KV_TABLE}
|
||||
GROUP BY table_name
|
||||
ORDER BY table_name`
|
||||
);
|
||||
|
||||
const allTables = tableRows.map(r => r.table_name);
|
||||
const ordered = [
|
||||
...PRIORITY_ORDER.filter(t => allTables.includes(t)),
|
||||
...allTables.filter(t => !PRIORITY_ORDER.includes(t) && t !== 'messages'),
|
||||
...(allTables.includes('messages') ? ['messages'] : []),
|
||||
];
|
||||
|
||||
const width = Math.max(...tableRows.map(r => r.table_name.length));
|
||||
console.log('Tables to migrate:');
|
||||
for (const r of tableRows) {
|
||||
console.log(` ${r.table_name.padEnd(width)} ${r.cnt} rows`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
let totalMigrated = 0;
|
||||
let totalErrors = 0;
|
||||
|
||||
for (const tableName of ordered) {
|
||||
try {
|
||||
const { migrated, errors } = await migrateTable(pgClient, cassClient, tableName, dryRun);
|
||||
totalMigrated += migrated;
|
||||
totalErrors += errors;
|
||||
} catch (err) {
|
||||
console.error(`\n[${tableName}] FAILED: ${err.message}`);
|
||||
console.error(' Ensure fluxer_api has run once with FLUXER_DATABASE_BACKEND=cassandra');
|
||||
console.error(' to auto-create the Cassandra schema before running this script.\n');
|
||||
totalErrors++;
|
||||
}
|
||||
}
|
||||
|
||||
await pgClient.end();
|
||||
await cassClient.shutdown();
|
||||
|
||||
console.log('\n' + '─'.repeat(55));
|
||||
console.log(`Total migrated: ${totalMigrated} rows | Errors: ${totalErrors}`);
|
||||
if (dryRun) console.log('(dry run — no data was written)');
|
||||
if (totalErrors > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
Reference in New Issue
Block a user