ddl-manager
Version:
store postgres procedures and triggers in files
409 lines (337 loc) • 14.9 kB
text/typescript
import { CacheColumnGraph } from "../Comparator/graph/CacheColumnGraph";
import { IDatabaseDriver } from "../database/interface";
import { CacheScanner, IFindBrokenColumnsParams, IColumnScanResult } from "./CacheScanner";
import { UpdateMigrator } from "../Migrator/UpdateMigrator";
import { Database } from "../database/schema/Database";
import { CacheUpdate } from "../Comparator/graph/CacheUpdate";
import { wrapText } from "../database/postgres/wrapText";
import { FilesState } from "../fs/FilesState";
import { flatMap, uniq } from "lodash";
import { TableID } from "../database/schema/TableID";
import { FileParser } from "../parser";
import { DatabaseTrigger } from "../database/schema/DatabaseTrigger";
import { strict } from "assert";
import { Migration } from "../Migrator/Migration";
export type IAuditParams = IFindBrokenColumnsParams;
export class CacheAuditor {
constructor(
private driver: IDatabaseDriver,
private database: Database,
private fs: FilesState,
private graph: CacheColumnGraph,
private scanner: CacheScanner
) {}
async audit(params: IAuditParams = {}) {
await this.createTables();
await this.removeUnnecessaryLoggers();
await this.scanner.scan({
...params,
onScanColumn: async (event) => {
await this.onScanColumn(params, event).catch(error =>
console.log("failed handler onScanColumn", error)
);
if ( params.onScanColumn ) {
await params.onScanColumn(event);
}
}
});
}
private async createTables() {
await this.createReportTable();
await this.createChangesTable();
}
private async createReportTable() {
await this.driver.query(`
create table if not exists ddl_manager_audit_report (
id serial primary key
);
alter table ddl_manager_audit_report
add column if not exists scan_date timestamp without time zone,
alter column scan_date set not null,
add column if not exists cache_column text,
alter column cache_column set not null,
add column if not exists cache_row json,
alter column cache_row set not null,
add column if not exists source_rows json,
add column if not exists actual_value json,
add column if not exists expected_value json;
create index if not exists ddl_manager_audit_report_column_idx
on ddl_manager_audit_report
using btree (cache_column);
create index if not exists ddl_manager_audit_report_cache_row_idx
on ddl_manager_audit_report
using btree (((cache_row->'id')::text::bigint));
`);
}
private async createChangesTable() {
await this.driver.query(`
create table if not exists ddl_manager_audit_column_changes (
id bigserial primary key
);
alter table ddl_manager_audit_column_changes
add column if not exists changed_table text,
alter column changed_table set not null,
add column if not exists changed_row_id bigint,
alter column changed_row_id set not null,
add column if not exists changed_old_row jsonb,
add column if not exists changed_new_row jsonb,
add column if not exists changes_type text,
alter column changes_type set not null,
add column if not exists transaction_date timestamp without time zone,
alter column transaction_date set not null,
add column if not exists transaction_id bigint,
add column if not exists process_id integer,
add column if not exists active_processes_ids integer[],
add column if not exists changes_date timestamp without time zone,
alter column changes_date set not null,
add column if not exists entry_query text not null,
alter column entry_query set not null,
add column if not exists pg_trigger_depth integer,
add column if not exists callstack text[],
add column if not exists triggers json[];
create index if not exists ddl_manager_audit_column_changes_row_idx
on ddl_manager_audit_column_changes
using btree (changed_table, changed_row_id);
create index if not exists ddl_manager_audit_column_changes_old_row_idx
on ddl_manager_audit_column_changes
using gin (changed_old_row jsonb_path_ops);
create index if not exists ddl_manager_audit_column_changes_new_row_idx
on ddl_manager_audit_column_changes
using gin (changed_new_row jsonb_path_ops);
`);
}
private async onScanColumn(
params: IAuditParams,
event: IColumnScanResult
) {
const wrongExample = event.wrongExample;
if ( !wrongExample ) {
return;
}
await this.saveReport(event);
await this.fixData(params, event);
await this.logChanges(event);
}
private async saveReport(event: IColumnScanResult) {
const wrongExample = event.wrongExample;
strict.ok(wrongExample, "required wrongExample");
await this.driver.query(`
insert into ddl_manager_audit_report (
scan_date,
cache_column,
cache_row,
source_rows,
actual_value,
expected_value
) values (
now()::timestamp with time zone at time zone 'UTC',
${wrapText(event.column)},
${wrapText(JSON.stringify(wrongExample.cacheRow))}::json,
${wrapText(JSON.stringify(wrongExample.sourceRows ?? null))}::json,
${wrapText(JSON.stringify(wrongExample.actual))}::json,
${wrapText(JSON.stringify(wrongExample.expected))}::json
)
`);
}
private async fixData(
params: IAuditParams,
event: IColumnScanResult
) {
const cacheColumn = this.findCacheColumn(event);
const updates = CacheUpdate.fromManyTables([cacheColumn]);
const migration = Migration.empty();
migration.create({ updates });
if ( params.timeout ) {
migration.setTimeoutPerUpdate(params.timeout);
migration.setTimeoutBetweenUpdates(3 * 1000);
}
const migrator = new UpdateMigrator(
this.driver,
migration,
this.database,
[]
);
await migrator.create();
}
private async logChanges(event: IColumnScanResult) {
const cacheColumn = this.findCacheColumn(event);
const cache = required(this.fs.getCache( cacheColumn.cache.signature ));
if ( cache.hasForeignTablesDeps() ) {
const fromTable = cache.select.getFromTableId();
await this.buildLoggerFor(fromTable);
if ( !fromTable.equal(cache.for.table) ) {
await this.buildLoggerFor(cache.for.table);
}
}
else {
await this.buildLoggerFor(cache.for.table);
}
}
private async buildLoggerFor(table: TableID) {
const depsCacheColumns = this.graph.findCacheColumnsDependentOn(table);
const tableColumns = flatMap(depsCacheColumns,
column => column.select.getAllColumnReferences()
)
.filter(columnRef => columnRef.isFromTable(table))
.map(column => column.name)
.sort();
const depsCacheColumnsOnTriggerTable = depsCacheColumns.filter(someCacheColumn =>
someCacheColumn.for.table.equal(table)
).map(column => column.name);
tableColumns.push(...depsCacheColumnsOnTriggerTable);
tableColumns.push("id");
await this.buildLoggerOn(table);
}
private async buildLoggerOn(triggerTable: TableID) {
const triggerName = `ddl_audit_changes_${triggerTable.schema}_${triggerTable.name}`;
await this.driver.query(`
create or replace function ${triggerName}()
returns trigger as $body$
declare callstack text;
declare this_row record;
begin
this_row = case when TG_OP = 'DELETE' then old else new end;
begin
raise exception 'callstack';
exception when others then
get stacked diagnostics
callstack = PG_EXCEPTION_CONTEXT;
end;
insert into ddl_manager_audit_column_changes (
changed_table,
changed_row_id,
changed_old_row,
changed_new_row,
changes_type,
changes_date,
transaction_date,
transaction_id,
process_id,
active_processes_ids,
entry_query,
pg_trigger_depth,
callstack,
triggers
) values (
'${triggerTable}',
this_row.id,
case when TG_OP in ('UPDATE', 'DELETE') then
row_to_json(old)::jsonb
end,
case when TG_OP in ('UPDATE', 'INSERT') then
row_to_json(new)::jsonb
end,
TG_OP,
clock_timestamp()::timestamp with time zone at time zone 'UTC',
transaction_timestamp()::timestamp with time zone at time zone 'UTC',
txid_current(),
pg_backend_pid(),
(select array_agg(pid) from pg_stat_activity),
current_query(),
pg_trigger_depth(),
(
select
array_agg(
(regexp_match(line, '(function|функция) ([\\w+.]+)'))[2] || ':' ||
(regexp_match(line, '(line|строка) (\\d+)'))[2]
)
from unnest(
regexp_split_to_array(callstack, '[\\n\\r]+')
) as line
where
line !~* '${triggerName}'
and
line ~* '(function|функция) '
),
(
select
array_agg( json_build_object(
'name', pg_trigger.tgname,
'enabled', pg_trigger.tgenabled != 'D',
'comment',pg_catalog.obj_description( pg_trigger.oid )
) )
from pg_trigger
left join pg_class as table_name on
table_name.oid = pg_trigger.tgrelid
left join pg_namespace as schema_name on
schema_name.oid = table_name.relnamespace
where
pg_trigger.tgisinternal = false and
schema_name.nspname = '${triggerTable.schema}' and
table_name.relname = '${triggerTable.name}'
)
);
return this_row;
end
$body$ language plpgsql;
drop trigger if exists ___aaa___${triggerName} on ${triggerTable};
create trigger ___aaa___${triggerName}
after insert or update or delete
on ${triggerTable}
for each row
execute procedure ${triggerName}();
comment on trigger ___aaa___${triggerName} on ${triggerTable}
is 'ddl-manager-audit';
`);
}
private findCacheColumn(event: IColumnScanResult) {
const [schemaName, tableName, columnName] = event.column.split(".");
const tableId = schemaName + "." + tableName;
const cacheColumn = this.graph.getColumn(tableId, columnName);
strict.ok(cacheColumn, `unknown column: ${event.column}`);
return cacheColumn;
}
private async removeUnnecessaryLoggers() {
const {rows: triggers} = await this.driver.query(`
select
pg_get_triggerdef( pg_trigger.oid ) as ddl
from pg_trigger
where
pg_trigger.tgisinternal = false and
pg_catalog.obj_description( pg_trigger.oid ) = 'ddl-manager-audit'
`);
for (const {ddl} of triggers) {
const parsed = FileParser.parse(ddl);
const [trigger] = parsed.triggers;
await this.removeLoggerTriggerIfUnnecessary(trigger);
}
}
private async removeLoggerTriggerIfUnnecessary(trigger: DatabaseTrigger) {
const needDrop = await this.isUnnecessaryLoggerTrigger(trigger);
if ( needDrop ) {
await this.driver.dropTrigger(trigger);
}
}
private async isUnnecessaryLoggerTrigger(trigger: DatabaseTrigger) {
const existentCacheColumns = this.graph.findCacheColumnsDependentOn(trigger.table);
if ( existentCacheColumns.length === 0 ) {
return true;
}
const cacheColumns = existentCacheColumns
.map(column => column.getId());
const {rows: reports} = await this.driver.query(`
select * from ddl_manager_audit_report as last_report
where
last_report.cache_column in (${
cacheColumns.map(column => wrapText(column))
.join(", ")
})
order by last_report.scan_date desc
limit 1
`);
const lastReport = reports[0];
if ( !lastReport ) {
return true;
}
const MONTH = 30 * 24 * 60 * 60 * 1000;
const isOld = Date.now() - lastReport.scan_date > 3 * MONTH;
return isOld
}
}
function required<T>(value: T): NonNullable<T> {
strict.ok(value, "required value");
return value as any;
}
// TODO: cache columns can be changed (change cache file)
// TODO: test timeout
// TODO: log all changes columns inside incident (can be helpful to find source of bug)