├── FUNCTIONS ├── delete_old_log_rows.sql ├── disable.sql ├── disable_process.sql ├── dispatch.sql ├── enable.sql ├── enable_process.sql ├── generate_register_commands.sql ├── is_valid_function.sql ├── log_error.sql ├── log_table_access.sql ├── new_connection_pool.sql ├── pg_stat_activity_portable.sql ├── register.sql ├── reset_runattime.sql ├── run.sql ├── schedule.sql └── terminate_all_backends.sql ├── LICENSE ├── README.md ├── TABLES ├── connectionpools.sql ├── errorlog.sql ├── jobs.sql ├── log.sql └── processes.sql ├── TYPES └── batchjobstate.sql ├── VIEWS ├── verrorlog.sql ├── vjobs.sql ├── vlog.sql └── vprocesses.sql ├── cron.sql ├── install.sql ├── pgcronjob ├── screenshot.png └── uninstall.sql /FUNCTIONS/delete_old_log_rows.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Delete_Old_Log_Rows(_ProcessID integer) RETURNS BatchJobState 2 | LANGUAGE plpgsql SECURITY DEFINER 3 | SET search_path TO public, pg_temp 4 | AS $_$ 5 | DECLARE 6 | _LogProcessID integer; 7 | _DeleteThreshold CONSTANT integer := 11000; 8 | _RowsToKeep CONSTANT integer := 10000; 9 | _OldestFinishedAtToKeep timestamptz; 10 | BEGIN 11 | IF (_DeleteThreshold > _RowsToKeep) IS NOT TRUE THEN 12 | RAISE EXCEPTION 'ERROR_WTF Bad config, _DeleteThreshold % must be greater than _RowsToKeep %', _DeleteThreshold, _RowsToKeep; 13 | END IF; 14 | -- The function will start to delete rows where there are at least _DeleteThreshold cron.Log rows for the ProcessID 15 | -- and will then delete all but the last _RowsToKeep rows. 16 | -- _DeleteThreshold should be greater than _RowsToKeep to avoid the function from running AGAIN, AGAIN, ... AGAIN, 17 | -- the difference between them is how many log rows that needs to be generated before the function deletes again for the ProcessID. 18 | FOR _LogProcessID IN 19 | SELECT ProcessID FROM cron.Processes ORDER BY ProcessID 20 | LOOP 21 | IF EXISTS ( 22 | SELECT 1 FROM cron.Log 23 | WHERE ProcessID = _LogProcessID 24 | ORDER BY FinishedAt DESC 25 | LIMIT 1 26 | OFFSET _DeleteThreshold 27 | ) THEN 28 | SELECT FinishedAt 29 | INTO STRICT _OldestFinishedAtToKeep 30 | FROM cron.Log 31 | WHERE ProcessID = _LogProcessID 32 | ORDER BY FinishedAt DESC 33 | LIMIT 1 34 | OFFSET _RowsToKeep; 35 | RAISE NOTICE 'Deleting cron.Log rows for ProcessID % where FinishedAt < _OldestFinishedAtToKeep %', _LogProcessID, _OldestFinishedAtToKeep; 36 | DELETE FROM cron.Log WHERE ProcessID = _LogProcessID AND FinishedAt < _OldestFinishedAtToKeep; 37 | RETURN 'AGAIN'; 38 | END IF; 39 | END LOOP; 40 | 41 | RETURN 'DONE'; 42 | END; 43 | $_$; 44 | 45 | ALTER FUNCTION cron.Delete_Old_Log_Rows(_ProcessID integer) OWNER TO gluepay; 46 | 47 | REVOKE ALL ON FUNCTION cron.Delete_Old_Log_Rows(_ProcessID integer) FROM PUBLIC; 48 | GRANT ALL ON FUNCTION cron.Delete_Old_Log_Rows(_ProcessID integer) TO gluepay; 49 | GRANT ALL ON FUNCTION cron.Delete_Old_Log_Rows(_ProcessID integer) TO pgcronjob; 50 | 51 | /* 52 | SELECT cron.Register('cron.Delete_Old_Log_Rows(integer)', 53 | _Concurrent := FALSE, 54 | _RetryOnError := '1 day'::interval, 55 | _IntervalAGAIN := '1 second'::interval, 56 | _IntervalDONE := '1 day'::interval, 57 | _ConnectionPool := 'Non-Time-Critical Jobs' 58 | ); 59 | */ 60 | -------------------------------------------------------------------------------- /FUNCTIONS/disable.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Disable(_Function regprocedure) 2 | RETURNS integer 3 | LANGUAGE plpgsql 4 | SET search_path TO public, pg_temp 5 | AS $FUNC$ 6 | DECLARE 7 | _OK boolean; 8 | _JobID integer; 9 | _Enabled boolean; 10 | BEGIN 11 | IF cron.Is_Valid_Function(_Function) IS NOT TRUE THEN 12 | RAISE EXCEPTION 'Function % is not a valid cron function.', _Function 13 | USING HINT = 'It must return BATCHJOBSTATE and the pgcronjob user must have been explicitly granted EXECUTE on the function.'; 14 | END IF; 15 | 16 | IF (SELECT COUNT(*) FROM cron.Jobs WHERE Function = _Function::text) > 1 THEN 17 | RAISE EXCEPTION 'Function % has multiple JobIDs registered, you will have to disable it manually by setting cron.Jobs.Enabled=FALSE for some or all rows', _Function; 18 | END IF; 19 | 20 | SELECT 21 | JobID, 22 | Enabled 23 | INTO 24 | _JobID, 25 | _Enabled 26 | FROM cron.Jobs 27 | WHERE Function = _Function::text; 28 | IF NOT FOUND THEN 29 | RAISE EXCEPTION 'Function % is a valid cron function but has not yet been registered as JobID', _Function; 30 | ELSE 31 | IF _Enabled IS TRUE THEN 32 | UPDATE cron.Jobs SET Enabled = FALSE WHERE JobID = _JobID AND Enabled IS TRUE RETURNING TRUE INTO STRICT _OK; 33 | ELSIF _Enabled IS FALSE THEN 34 | RAISE NOTICE 'Function % with JobID % has already been disabled', _Function, _JobID; 35 | ELSE 36 | RAISE EXCEPTION 'How did we end up here?! Function %, JobID %, Enabled %', _Function, _JobID, _Enabled; 37 | END IF; 38 | END IF; 39 | 40 | RETURN _JobID; 41 | END; 42 | $FUNC$; 43 | 44 | ALTER FUNCTION cron.Disable(_Function regprocedure) OWNER TO pgcronjob; 45 | -------------------------------------------------------------------------------- /FUNCTIONS/disable_process.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Disable_Process(_ProcessID integer) 2 | RETURNS boolean 3 | LANGUAGE plpgsql 4 | SET search_path TO public, pg_temp 5 | AS $FUNC$ 6 | DECLARE 7 | _OK boolean; 8 | BEGIN 9 | UPDATE cron.Processes SET Enabled = FALSE WHERE ProcessID = _ProcessID RETURNING TRUE INTO STRICT _OK; 10 | RETURN TRUE; 11 | END; 12 | $FUNC$; 13 | 14 | ALTER FUNCTION cron.Disable_Process(_ProcessID integer) OWNER TO pgcronjob; 15 | -------------------------------------------------------------------------------- /FUNCTIONS/dispatch.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Dispatch(OUT RunProcessID integer, OUT RunInSeconds numeric, OUT MaxProcesses integer, OUT ConnectionPoolID integer, OUT RetryOnError numeric) 2 | RETURNS RECORD 3 | LANGUAGE plpgsql 4 | SET search_path TO public, pg_temp 5 | AS $FUNC$ 6 | DECLARE 7 | _OK boolean; 8 | _JobID integer; 9 | _RunAtTime timestamptz; 10 | BEGIN 11 | 12 | IF NOT pg_try_advisory_xact_lock('cron.Dispatch()'::regprocedure::int, 0) THEN 13 | RAISE EXCEPTION 'Aborting cron.Dispatch() because of a concurrent execution'; 14 | END IF; 15 | 16 | SELECT 17 | P.ProcessID, 18 | CASE 19 | WHEN J.RunAfterTimestamp > now() THEN J.RunAfterTimestamp 20 | WHEN J.RunAfterTime > now()::time THEN now()::date + J.RunAfterTime 21 | WHEN P.LastRunFinishedAt IS NULL THEN now() 22 | ELSE P.LastRunFinishedAt + CASE WHEN P.BatchJobState = 'DONE' THEN J.IntervalDONE ELSE J.IntervalAGAIN END 23 | END, 24 | CP.MaxProcesses, 25 | J.ConnectionPoolID, 26 | extract(epoch from J.RetryOnError) 27 | INTO 28 | RunProcessID, 29 | _RunAtTime, 30 | MaxProcesses, 31 | ConnectionPoolID, 32 | RetryOnError 33 | FROM cron.Jobs AS J 34 | INNER JOIN cron.Processes AS P ON (P.JobID = J.JobID) 35 | LEFT JOIN cron.ConnectionPools AS CP ON (CP.ConnectionPoolID = J.ConnectionPoolID) 36 | WHERE P.RunAtTime IS NULL 37 | AND J.Enabled 38 | AND P.Enabled 39 | AND (P.BatchJobState IS DISTINCT FROM 'DONE' OR J.IntervalDONE IS NOT NULL) 40 | AND (now() > J.RunUntilTimestamp OR now()::time > J.RunUntilTime) IS NOT TRUE 41 | ORDER BY 2 NULLS LAST, 1 42 | LIMIT 1 43 | FOR UPDATE OF P; 44 | IF NOT FOUND THEN 45 | RETURN; 46 | END IF; 47 | 48 | RunInSeconds := extract(epoch from _RunAtTime - now()); 49 | 50 | UPDATE cron.Processes SET RunAtTime = _RunAtTime WHERE ProcessID = RunProcessID AND RunAtTime IS NULL RETURNING TRUE INTO STRICT _OK; 51 | RAISE DEBUG '% pg_backend_pid % : spawn new process -> [JobID % RunAtTime % RunProcessID % RunInSeconds % MaxProcesses % ConnectionPoolID %]', clock_timestamp()::timestamp(3), pg_backend_pid(), _JobID, _RunAtTime, RunProcessID, RunInSeconds, MaxProcesses, ConnectionPoolID; 52 | RETURN; 53 | 54 | END; 55 | $FUNC$; 56 | 57 | ALTER FUNCTION cron.Dispatch() OWNER TO pgcronjob; 58 | -------------------------------------------------------------------------------- /FUNCTIONS/enable.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Enable(_Function regprocedure) 2 | RETURNS integer 3 | LANGUAGE plpgsql 4 | SET search_path TO public, pg_temp 5 | AS $FUNC$ 6 | DECLARE 7 | _OK boolean; 8 | _JobID integer; 9 | _Enabled boolean; 10 | BEGIN 11 | IF cron.Is_Valid_Function(_Function) IS NOT TRUE THEN 12 | RAISE EXCEPTION 'Function % is not a valid cron function.', _Function 13 | USING HINT = 'It must return BATCHJOBSTATE and the pgcronjob user must have been explicitly granted EXECUTE on the function.'; 14 | END IF; 15 | 16 | IF (SELECT COUNT(*) FROM cron.Jobs WHERE Function = _Function::text) > 1 THEN 17 | RAISE EXCEPTION 'Function % has multiple JobIDs registered, you will have to enable it manually by setting cron.Jobs.Enabled=TRUE for some or all rows', _Function; 18 | END IF; 19 | 20 | SELECT 21 | JobID, 22 | Enabled 23 | INTO 24 | _JobID, 25 | _Enabled 26 | FROM cron.Jobs 27 | WHERE Function = _Function::text; 28 | IF NOT FOUND THEN 29 | RAISE EXCEPTION 'Function % is a valid cron function but has not yet been registered as JobID', _Function; 30 | ELSE 31 | IF _Enabled IS FALSE THEN 32 | UPDATE cron.Jobs SET Enabled = TRUE WHERE JobID = _JobID AND Enabled IS FALSE RETURNING TRUE INTO STRICT _OK; 33 | ELSIF _Enabled IS TRUE THEN 34 | RAISE NOTICE 'Function % with JobID % has already been enabled', _Function, _JobID; 35 | ELSE 36 | RAISE EXCEPTION 'How did we end up here?! Function %, JobID %, Enabled %', _Function, _JobID, _Enabled; 37 | END IF; 38 | END IF; 39 | 40 | RETURN _JobID; 41 | END; 42 | $FUNC$; 43 | 44 | ALTER FUNCTION cron.Enable(_Function regprocedure) OWNER TO pgcronjob; 45 | -------------------------------------------------------------------------------- /FUNCTIONS/enable_process.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Enable_Process(_ProcessID integer) 2 | RETURNS boolean 3 | LANGUAGE plpgsql 4 | SET search_path TO public, pg_temp 5 | AS $FUNC$ 6 | DECLARE 7 | _OK boolean; 8 | BEGIN 9 | UPDATE cron.Processes SET Enabled = TRUE WHERE ProcessID = _ProcessID RETURNING TRUE INTO STRICT _OK; 10 | RETURN TRUE; 11 | END; 12 | $FUNC$; 13 | 14 | ALTER FUNCTION cron.Enable_Process(_ProcessID integer) OWNER TO pgcronjob; 15 | -------------------------------------------------------------------------------- /FUNCTIONS/generate_register_commands.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Generate_Register_Commands() 2 | RETURNS SETOF text 3 | LANGUAGE plpgsql 4 | SET search_path TO public, pg_temp 5 | AS $FUNC$ 6 | DECLARE 7 | BEGIN 8 | RETURN QUERY SELECT ' 9 | SELECT cron.New_Connection_Pool( 10 | _Name := ' || quote_literal(Name) || ', 11 | _MaxProcesses := ' || MaxProcesses || ' 12 | );' FROM cron.ConnectionPools ORDER BY ConnectionPoolID; 13 | RETURN QUERY SELECT ' 14 | SELECT cron.Register( 15 | _Function := ' || quote_literal(Function) || ', 16 | _Processes := ' || ( 17 | SELECT COUNT(*) FROM cron.Processes 18 | WHERE cron.Processes.JobID = cron.Jobs.JobID 19 | AND (cron.Jobs.IntervalDONE IS NOT NULL OR cron.Processes.BatchJobState IS DISTINCT FROM 'DONE') 20 | ) || ', 21 | _Concurrent := ' || upper(Concurrent::text) || ', 22 | _Enabled := ' || upper(Enabled::text) || ', 23 | _RunIfWaiting := ' || upper(RunIfWaiting::text) || ', 24 | _RetryOnError := ' || COALESCE(quote_literal(RetryOnError),'NULL') || ', 25 | _RandomInterval := ' || upper(RandomInterval::text) || ', 26 | _IntervalAGAIN := ' || quote_literal(IntervalAGAIN) || ', 27 | _IntervalDONE := ' || COALESCE(quote_literal(IntervalDONE),'NULL') || ', 28 | _RunAfterTimestamp := ' || COALESCE(quote_literal(RunAfterTimestamp),'NULL') || ', 29 | _RunUntilTimestamp := ' || COALESCE(quote_literal(RunUntilTimestamp),'NULL') || ', 30 | _RunAfterTime := ' || COALESCE(quote_literal(RunAfterTime),'NULL') || ', 31 | _RunUntilTime := ' || COALESCE(quote_literal(RunUntilTime),'NULL') || ', 32 | _ConnectionPool := ' || COALESCE((SELECT quote_literal(Name) FROM cron.ConnectionPools WHERE cron.ConnectionPools.ConnectionPoolID = cron.Jobs.ConnectionPoolID),'NULL') || ', 33 | _LogTableAccess := ' || upper(LogTableAccess::text) || ', 34 | _RequestedBy := ' || quote_literal(RequestedBy) || ', 35 | _RequestedAt := ' || quote_literal(RequestedAt) || ' 36 | );' FROM cron.Jobs 37 | WHERE cron.Jobs.IntervalDONE IS NOT NULL OR EXISTS ( 38 | SELECT 1 FROM cron.Processes WHERE cron.Processes.JobID = cron.Jobs.JobID AND cron.Processes.BatchJobState IS DISTINCT FROM 'DONE' 39 | ) 40 | ORDER BY JobID; 41 | RETURN; 42 | END; 43 | $FUNC$; 44 | 45 | ALTER FUNCTION cron.Generate_Register_Commands() OWNER TO pgcronjob; 46 | -------------------------------------------------------------------------------- /FUNCTIONS/is_valid_function.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Is_Valid_Function(_Function regprocedure) 2 | RETURNS boolean 3 | LANGUAGE sql 4 | STABLE 5 | SET search_path TO public, pg_temp 6 | AS $FUNC$ 7 | SELECT TRUE FROM pg_catalog.pg_proc 8 | -- All functions registered in Jobs must return BATCHJOBSTATE which is just an ENUM 'AGAIN' or 'DONE', 9 | -- to indicate whether or not cron.Run() should run the function again or if the job is done. 10 | -- This is to avoid accidents and to force the user to be explicit about what to do, 11 | -- instead of using a plain boolean as return value, which could be misinterpreted. 12 | WHERE oid = $1::oid 13 | AND prorettype::regtype::text ~ '(^|\.)batchjobstate$' 14 | AND EXISTS ( 15 | -- It's not enough to just register the function using cron.Register(). 16 | -- The user must also explicitly grant execute on the function to the pgcronjob role, 17 | -- using the command: GRANT EXECUTE ON FUNCTION ... TO pgcronjob. 18 | -- This is to reduce the risk of accidents from happening, in case of human errors. 19 | SELECT 1 FROM aclexplode(pg_catalog.pg_proc.proacl) 20 | WHERE aclexplode.privilege_type = 'EXECUTE' 21 | AND aclexplode.grantee = ( 22 | SELECT pg_catalog.pg_user.usesysid FROM pg_catalog.pg_user 23 | WHERE pg_catalog.pg_user.usename = 'pgcronjob' 24 | ) 25 | ) 26 | $FUNC$; 27 | 28 | ALTER FUNCTION cron.Is_Valid_Function(_Function regprocedure) OWNER TO pgcronjob; 29 | -------------------------------------------------------------------------------- /FUNCTIONS/log_error.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Log_Error(_ProcessID integer, _PgBackendPID integer, _PgErr text, _PgErrStr text, _PgState text, _PerlCallerInfo text, _RetryInSeconds numeric) 2 | RETURNS bigint 3 | LANGUAGE plpgsql 4 | SET search_path TO public, pg_temp 5 | AS $FUNC$ 6 | DECLARE 7 | _ErrorLogID bigint; 8 | BEGIN 9 | 10 | INSERT INTO cron.ErrorLog ( ProcessID, PgBackendPID, PgErr, PgErrStr, PgState, PerlCallerInfo, RetryInSeconds) 11 | VALUES (_ProcessID, _PgBackendPID, _PgErr, _PgErrStr, _PgState, _PerlCallerInfo, _RetryInSeconds) 12 | RETURNING ErrorLogID INTO STRICT _ErrorLogID; 13 | 14 | RETURN _ErrorLogID; 15 | END; 16 | $FUNC$; 17 | 18 | ALTER FUNCTION cron.Log_Error(_ProcessID integer, _PgBackendPID integer, _PgErr text, _PgErrStr text, _PgState text, _PerlCallerInfo text, _RetryInSeconds numeric) OWNER TO pgcronjob; 19 | -------------------------------------------------------------------------------- /FUNCTIONS/log_table_access.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Log_Table_Access(_ProcessID integer, _BatchJobState batchjobstate, _LastRunStartedAt timestamptz, _LastRunFinishedAt timestamptz) 2 | RETURNS bigint 3 | LANGUAGE plpgsql 4 | SET search_path TO public, pg_temp 5 | AS $FUNC$ 6 | DECLARE 7 | _LogID bigint; 8 | BEGIN 9 | 10 | INSERT INTO cron.Log ( 11 | ProcessID, 12 | BatchJobState, 13 | PgBackendPID, 14 | StartTxnAt, 15 | StartedAt, 16 | FinishedAt, 17 | seq_scan, 18 | seq_tup_read, 19 | idx_scan, 20 | idx_tup_fetch, 21 | n_tup_ins, 22 | n_tup_upd, 23 | n_tup_del, 24 | n_tup_hot_upd 25 | ) 26 | SELECT 27 | _ProcessID, 28 | _BatchJobState, 29 | pg_backend_pid(), 30 | now(), 31 | _LastRunStartedAt, 32 | _LastRunFinishedAt, 33 | COALESCE(SUM(seq_scan),0), 34 | COALESCE(SUM(seq_tup_read),0), 35 | COALESCE(SUM(idx_scan),0), 36 | COALESCE(SUM(idx_tup_fetch),0), 37 | COALESCE(SUM(n_tup_ins),0), 38 | COALESCE(SUM(n_tup_upd),0), 39 | COALESCE(SUM(n_tup_del),0), 40 | COALESCE(SUM(n_tup_hot_upd),0) 41 | FROM pg_catalog.pg_stat_xact_user_tables 42 | RETURNING LogID INTO STRICT _LogID; 43 | 44 | RETURN _LogID; 45 | END; 46 | $FUNC$; 47 | 48 | ALTER FUNCTION cron.Log_Table_Access(_ProcessID integer, _BatchJobState batchjobstate, _LastRunStartedAt timestamptz, _LastRunFinishedAt timestamptz) OWNER TO pgcronjob; 49 | -------------------------------------------------------------------------------- /FUNCTIONS/new_connection_pool.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.New_Connection_Pool(_Name text, _MaxProcesses integer) 2 | RETURNS integer 3 | LANGUAGE plpgsql 4 | SET search_path TO public, pg_temp 5 | AS $FUNC$ 6 | DECLARE 7 | _ConnectionPoolID integer; 8 | BEGIN 9 | 10 | INSERT INTO cron.ConnectionPools ( Name, MaxProcesses) 11 | VALUES (_Name,_MaxProcesses) 12 | RETURNING ConnectionPoolID INTO STRICT _ConnectionPoolID; 13 | 14 | RETURN _ConnectionPoolID; 15 | END; 16 | $FUNC$; 17 | 18 | ALTER FUNCTION cron.New_Connection_Pool(_Name text, _MaxProcesses integer) OWNER TO pgcronjob; 19 | -------------------------------------------------------------------------------- /FUNCTIONS/pg_stat_activity_portable.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION public.pg_stat_activity_portable() 2 | RETURNS TABLE ( 3 | datid oid, 4 | datname name, 5 | pid integer, 6 | usesysid oid, 7 | usename name, 8 | application_name text, 9 | client_addr inet, 10 | client_hostname text, 11 | client_port integer, 12 | backend_start timestamp with time zone, 13 | xact_start timestamp with time zone, 14 | query_start timestamp with time zone, 15 | waiting boolean, 16 | state text, 17 | query text 18 | ) 19 | AS $$ 20 | -- This function exposes the 9.2-compatible interface on versions 9.1 and later. 21 | DECLARE 22 | _VersionNum int; 23 | BEGIN 24 | 25 | _VersionNum := current_setting('server_version_num')::int; 26 | IF _VersionNum >= 90600 THEN 27 | RETURN QUERY 28 | SELECT 29 | s.datid, 30 | s.datname, 31 | s.pid, 32 | s.usesysid, 33 | s.usename, 34 | s.application_name, 35 | s.client_addr, 36 | s.client_hostname, 37 | s.client_port, 38 | s.backend_start, 39 | s.xact_start, 40 | s.query_start, 41 | s.wait_event IS NOT NULL AS waiting, 42 | s.state, 43 | s.query 44 | FROM pg_stat_activity s; 45 | ELSIF _VersionNum >= 90200 THEN 46 | RETURN QUERY 47 | SELECT 48 | s.datid, 49 | s.datname, 50 | s.pid, 51 | s.usesysid, 52 | s.usename, 53 | s.application_name, 54 | s.client_addr, 55 | s.client_hostname, 56 | s.client_port, 57 | s.backend_start, 58 | s.xact_start, 59 | s.query_start, 60 | s.waiting, 61 | s.query 62 | FROM pg_stat_activity s; 63 | ELSE 64 | RETURN QUERY 65 | SELECT 66 | s.datid, 67 | s.datname, 68 | s.procpid, 69 | s.usesysid, 70 | s.usename, 71 | s.application_name, 72 | s.client_addr, 73 | s.client_hostname, 74 | s.client_port, 75 | s.backend_start, 76 | s.xact_start, 77 | s.query_start, 78 | s.waiting, 79 | CASE WHEN s.current_query = ' in transaction' THEN text 'idle in transaction' 80 | WHEN s.current_query = '' THEN text 'idle' 81 | WHEN s.current_query = ' in transaction (aborted)' THEN text 'idle in transaction (aborted)' 82 | WHEN s.current_query = '' THEN NULL::text 83 | ELSE text 'active' 84 | END AS state, 85 | s.current_query 86 | FROM pg_stat_activity s; 87 | END IF; 88 | END 89 | $$ LANGUAGE plpgsql; 90 | 91 | ALTER FUNCTION public.pg_stat_activity_portable() OWNER TO pgterminator; 92 | -------------------------------------------------------------------------------- /FUNCTIONS/register.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Register( 2 | _Function regprocedure, 3 | _Processes integer DEFAULT 1, 4 | _Concurrent boolean DEFAULT TRUE, 5 | _Enabled boolean DEFAULT TRUE, 6 | _RunIfWaiting boolean DEFAULT FALSE, 7 | _RetryOnError interval DEFAULT NULL, 8 | _RandomInterval boolean DEFAULT FALSE, 9 | _IntervalAGAIN interval DEFAULT '100 ms'::interval, 10 | _IntervalDONE interval DEFAULT NULL, 11 | _RunAfterTimestamp timestamptz DEFAULT NULL, 12 | _RunUntilTimestamp timestamptz DEFAULT NULL, 13 | _RunAfterTime time DEFAULT NULL, 14 | _RunUntilTime time DEFAULT NULL, 15 | _ConnectionPool text DEFAULT NULL, 16 | _LogTableAccess boolean DEFAULT TRUE, 17 | _RequestedBy text DEFAULT session_user, 18 | _RequestedAt timestamptz DEFAULT now() 19 | ) 20 | RETURNS integer 21 | LANGUAGE plpgsql 22 | SET search_path TO public, pg_temp 23 | AS $FUNC$ 24 | DECLARE 25 | _OK boolean; 26 | _JobID integer; 27 | _IdenticalConfiguration boolean; 28 | _ConnectionPoolID integer; 29 | _CycleFirstProcessID integer; 30 | BEGIN 31 | IF cron.Is_Valid_Function(_Function) IS NOT TRUE THEN 32 | RAISE EXCEPTION 'Function % is not a valid CronJob function.', _Function 33 | USING HINT = 'It must return BATCHJOBSTATE and the pgcronjob user must have been explicitly granted EXECUTE on the function.'; 34 | END IF; 35 | 36 | IF _ConnectionPool IS NOT NULL THEN 37 | SELECT ConnectionPoolID, CycleFirstProcessID INTO STRICT _ConnectionPoolID, _CycleFirstProcessID FROM cron.ConnectionPools WHERE Name = _ConnectionPool; 38 | END IF; 39 | 40 | INSERT INTO cron.Jobs ( Function, Processes, Concurrent, Enabled, RunIfWaiting, RetryOnError, RandomInterval, IntervalAGAIN, IntervalDONE, RunAfterTimestamp, RunUntilTimestamp, RunAfterTime, RunUntilTime, ConnectionPoolID, LogTableAccess, RequestedBy, RequestedAt) 41 | VALUES (_Function::text,_Processes,_Concurrent,_Enabled,_RunIfWaiting,_RetryOnError,_RandomInterval,_IntervalAGAIN,_IntervalDONE,_RunAfterTimestamp,_RunUntilTimestamp,_RunAfterTime,_RunUntilTime,_ConnectionPoolID,_LogTableAccess,_RequestedBy,_RequestedAt) 42 | RETURNING JobID INTO STRICT _JobID; 43 | 44 | INSERT INTO cron.Processes (JobID) SELECT _JobID FROM generate_series(1,_Processes); 45 | 46 | IF _ConnectionPool IS NOT NULL AND _CycleFirstProcessID IS NULL THEN 47 | UPDATE cron.ConnectionPools SET 48 | CycleFirstProcessID = (SELECT MIN(ProcessID) FROM cron.Processes WHERE JobID = _JobID) 49 | WHERE ConnectionPoolID = _ConnectionPoolID 50 | AND CycleFirstProcessID IS NULL 51 | RETURNING TRUE INTO STRICT _OK; 52 | END IF; 53 | 54 | RETURN _JobID; 55 | END; 56 | $FUNC$; 57 | 58 | ALTER FUNCTION cron.Register( 59 | _Function regprocedure, 60 | _Processes integer, 61 | _Concurrent boolean, 62 | _Enabled boolean, 63 | _RunIfWaiting boolean, 64 | _RetryOnError interval, 65 | _RandomInterval boolean, 66 | _IntervalAGAIN interval, 67 | _IntervalDONE interval, 68 | _RunAfterTimestamp timestamptz, 69 | _RunUntilTimestamp timestamptz, 70 | _RunAfterTime time, 71 | _RunUntilTime time, 72 | _ConnectionPool text, 73 | _LogTableAccess boolean, 74 | _RequestedBy text, 75 | _RequestedAt timestamptz 76 | ) OWNER TO pgcronjob; 77 | -------------------------------------------------------------------------------- /FUNCTIONS/reset_runattime.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Reset_RunAtTime() 2 | RETURNS boolean 3 | LANGUAGE plpgsql 4 | SET search_path TO public, pg_temp 5 | AS $FUNC$ 6 | DECLARE 7 | BEGIN 8 | UPDATE cron.Processes SET RunAtTime = NULL WHERE RunAtTime IS NOT NULL; 9 | RETURN TRUE; 10 | END; 11 | $FUNC$; 12 | 13 | ALTER FUNCTION cron.Reset_RunAtTime() OWNER TO pgcronjob; 14 | -------------------------------------------------------------------------------- /FUNCTIONS/run.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Run(OUT RunInSeconds numeric, _ProcessID integer) 2 | RETURNS numeric 3 | LANGUAGE plpgsql 4 | SET search_path TO public, pg_temp 5 | AS $FUNC$ 6 | DECLARE 7 | _OK boolean; 8 | _JobID integer; 9 | _Function regprocedure; 10 | _RunAtTime timestamptz; 11 | _BatchJobState batchjobstate; 12 | _Concurrent boolean; 13 | _IntervalAGAIN interval; 14 | _IntervalDONE interval; 15 | _RandomInterval boolean; 16 | _RunIfWaiting boolean; 17 | _RunAfterTimestamp timestamptz; 18 | _RunUntilTimestamp timestamptz; 19 | _RunAfterTime interval; 20 | _RunUntilTime interval; 21 | _LastRunStartedAt timestamptz; 22 | _LastRunFinishedAt timestamptz; 23 | _SQL text; 24 | _ConnectionPoolID integer; 25 | _CycleFirstProcessID integer; 26 | _Enabled boolean; 27 | _LogTableAccess boolean; 28 | BEGIN 29 | 30 | IF _ProcessID IS NULL THEN 31 | RAISE EXCEPTION 'Input param ProcessID cannot be NULL'; 32 | END IF; 33 | 34 | SELECT 35 | J.JobID, 36 | J.Enabled AND P.Enabled, 37 | J.Function::regprocedure, 38 | J.Concurrent, 39 | J.RunIfWaiting, 40 | J.RunAfterTimestamp, 41 | J.RunUntilTimestamp, 42 | J.RunAfterTime, 43 | J.RunUntilTime, 44 | J.IntervalAGAIN, 45 | J.IntervalDONE, 46 | J.RandomInterval, 47 | J.ConnectionPoolID, 48 | J.LogTableAccess, 49 | CP.CycleFirstProcessID 50 | INTO STRICT 51 | _JobID, 52 | _Enabled, 53 | _Function, 54 | _Concurrent, 55 | _RunIfWaiting, 56 | _RunAfterTimestamp, 57 | _RunUntilTimestamp, 58 | _RunAfterTime, 59 | _RunUntilTime, 60 | _IntervalAGAIN, 61 | _IntervalDONE, 62 | _RandomInterval, 63 | _ConnectionPoolID, 64 | _LogTableAccess, 65 | _CycleFirstProcessID 66 | FROM cron.Jobs AS J 67 | INNER JOIN cron.Processes AS P ON (P.JobID = J.JobID) 68 | LEFT JOIN cron.ConnectionPools AS CP ON (CP.ConnectionPoolID = J.ConnectionPoolID) 69 | WHERE P.ProcessID = _ProcessID 70 | FOR UPDATE OF P; 71 | 72 | IF _Enabled IS NOT TRUE THEN 73 | RunInSeconds := NULL; 74 | RETURN; 75 | END IF; 76 | 77 | IF _RunAfterTimestamp > now() 78 | OR _RunUntilTimestamp < now() 79 | OR _RunAfterTime > now()::time 80 | OR _RunUntilTime < now()::time 81 | THEN 82 | UPDATE cron.Processes SET 83 | BatchJobState = 'DONE', 84 | RunAtTime = NULL 85 | WHERE ProcessID = _ProcessID 86 | RETURNING TRUE INTO STRICT _OK; 87 | RunInSeconds := NULL; 88 | RETURN; 89 | END IF; 90 | 91 | IF _RandomInterval IS TRUE THEN 92 | _IntervalAGAIN := _IntervalAGAIN * random(); 93 | _IntervalDONE := _IntervalDONE * random(); 94 | END IF; 95 | 96 | IF NOT _RunIfWaiting THEN 97 | IF EXISTS (SELECT 1 FROM pg_stat_activity_portable() WHERE waiting) THEN 98 | RAISE DEBUG '% ProcessID % pg_backend_pid % : other processes are waiting, aborting', clock_timestamp()::timestamp(3), _ProcessID, pg_backend_pid(); 99 | _RunAtTime := now() + _IntervalAGAIN; 100 | UPDATE cron.Processes SET RunAtTime = _RunAtTime WHERE ProcessID = _ProcessID RETURNING TRUE INTO STRICT _OK; 101 | RunInSeconds := extract(epoch from _RunAtTime-now()); 102 | RETURN; 103 | END IF; 104 | END IF; 105 | 106 | IF _CycleFirstProcessID = _ProcessID THEN 107 | UPDATE cron.ConnectionPools SET 108 | LastCycleAt = ThisCycleAt, 109 | ThisCycleAt = clock_timestamp() 110 | WHERE ConnectionPoolID = _ConnectionPoolID 111 | RETURNING TRUE INTO STRICT _OK; 112 | END IF; 113 | 114 | UPDATE cron.Processes SET 115 | FirstRunStartedAt = COALESCE(FirstRunStartedAt,clock_timestamp()), 116 | LastRunStartedAt = clock_timestamp(), 117 | Calls = Calls + 1, 118 | PgBackendPID = pg_backend_pid() 119 | WHERE ProcessID = _ProcessID RETURNING LastRunStartedAt INTO STRICT _LastRunStartedAt; 120 | 121 | IF NOT _Concurrent THEN 122 | IF NOT pg_try_advisory_xact_lock(_Function::int, 0) THEN 123 | RAISE EXCEPTION 'Aborting % because of a concurrent execution', _Function; 124 | END IF; 125 | END IF; 126 | 127 | _SQL := 'SELECT '||format(replace(_Function::text,'(integer)','(%s)'),_ProcessID); 128 | RAISE DEBUG 'Starting cron job % process % %', _JobID, _ProcessID, _SQL; 129 | PERFORM set_config('application_name', _Function::text,TRUE); 130 | EXECUTE _SQL USING _ProcessID INTO STRICT _BatchJobState; 131 | RAISE DEBUG 'Finished cron job % process % % -> %', _JobID, _ProcessID, _SQL, _BatchJobState; 132 | 133 | IF _BatchJobState = 'AGAIN' THEN 134 | _RunAtTime := now() + _IntervalAGAIN; 135 | ELSIF _BatchJobState = 'DONE' THEN 136 | _RunAtTime := now() + _IntervalDONE; 137 | ELSE 138 | RAISE EXCEPTION 'Cron function % did not return a valid BatchJobState: %', _Function, _BatchJobState; 139 | END IF; 140 | RunInSeconds := extract(epoch from _RunAtTime-now()); 141 | 142 | UPDATE cron.Processes SET 143 | FirstRunFinishedAt = COALESCE(FirstRunFinishedAt,clock_timestamp()), 144 | LastRunFinishedAt = clock_timestamp(), 145 | BatchJobState = _BatchJobState, 146 | RunAtTime = _RunAtTime 147 | WHERE ProcessID = _ProcessID 148 | RETURNING 149 | LastRunFinishedAt 150 | INTO STRICT 151 | _LastRunFinishedAt; 152 | 153 | IF _LogTableAccess IS TRUE THEN 154 | PERFORM cron.Log_Table_Access(_ProcessID, _BatchJobState, _LastRunStartedAt, _LastRunFinishedAt); 155 | END IF; 156 | 157 | RAISE DEBUG '% ProcessID % pg_backend_pid % : % [JobID % Function % RunInSeconds %]', clock_timestamp()::timestamp(3), _ProcessID, pg_backend_pid(), _BatchJobState, _JobID, _Function, RunInSeconds; 158 | RETURN; 159 | END; 160 | $FUNC$; 161 | 162 | ALTER FUNCTION cron.Run(_ProcessID integer) OWNER TO pgcronjob; 163 | -------------------------------------------------------------------------------- /FUNCTIONS/schedule.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Schedule(_Function regprocedure, _ProcessID integer DEFAULT NULL) 2 | RETURNS boolean 3 | LANGUAGE plpgsql 4 | SET search_path TO public, pg_temp 5 | AS $FUNC$ 6 | DECLARE 7 | _OK boolean; 8 | BEGIN 9 | 10 | IF _ProcessID IS NULL THEN 11 | SELECT cron.Processes.ProcessID INTO _ProcessID FROM cron.Jobs 12 | INNER JOIN cron.Processes ON (cron.Processes.JobID = cron.Jobs.JobID) 13 | WHERE cron.Jobs.Function = _Function::text 14 | LIMIT 1; 15 | END IF; 16 | 17 | PERFORM pg_notify('cron.Dispatch()',_ProcessID::text); 18 | 19 | RETURN TRUE; 20 | END; 21 | $FUNC$; 22 | 23 | ALTER FUNCTION cron.Schedule(_Function regprocedure, _ProcessID integer) OWNER TO pgcronjob; 24 | -------------------------------------------------------------------------------- /FUNCTIONS/terminate_all_backends.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE FUNCTION cron.Terminate_All_Backends() 2 | RETURNS boolean 3 | LANGUAGE plpgsql 4 | SECURITY DEFINER 5 | SET search_path TO public, pg_temp 6 | AS $FUNC$ 7 | DECLARE 8 | BEGIN 9 | IF (SELECT setting::integer FROM pg_catalog.pg_settings WHERE pg_catalog.pg_settings.name = 'server_version_num') < 90600 THEN 10 | PERFORM pg_terminate_backend(procpid) FROM pg_stat_activity WHERE usename = 'pgcronjob' AND procpid <> pg_backend_pid(); 11 | ELSE 12 | PERFORM pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'pgcronjob' AND pid <> pg_backend_pid(); 13 | END IF; 14 | IF FOUND THEN 15 | RETURN FALSE; -- there were still alive PIDs 16 | ELSE 17 | RETURN TRUE; -- all PIDs had already been terminated 18 | END IF; 19 | END; 20 | $FUNC$; 21 | 22 | ALTER FUNCTION cron.Terminate_All_Backends() OWNER TO sudo; 23 | 24 | REVOKE ALL ON FUNCTION cron.Terminate_All_Backends() FROM PUBLIC; 25 | GRANT ALL ON FUNCTION cron.Terminate_All_Backends() TO pgcronjob; 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 trustly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pgcronjob 2 | 3 | Run PostgreSQL user-defined database functions in a cron like fashion. 4 | 5 | ## SCREENSHOT 6 | 7 | ![screenshot](https://raw.githubusercontent.com/trustly/pgcronjob/master/screenshot.png) 8 | 9 | ## DEMO 10 | 11 | Demo of the example in install.sql: (https://asciinema.org/a/bwdlg8tqabais0p4g8wt2b0sx) 12 | 13 | ## DESCRIPTION 14 | 15 | To run your functions using pgcronjob, your function must return the pgcronjob-defined ENUM type BatchJobState, which has two values, 'AGAIN' or 'DONE'. 16 | This is how your function tells pgcronjob if it wants to be run AGAIN or if the work has completed and we are DONE. 17 | A boolean return value would have worked as well, but boolean values can easily be misinterpeted while the words AGAIN and DONE are much more precise, 18 | so hopefully this will help avoid one or two accidents here and there. 19 | 20 | ## SYNOPSIS 21 | 22 | Run until an error is encountered (DEFAULT): 23 | ``` 24 | SELECT cron.Register('cron.Example_Random_Error(integer)', _RetryOnError := NULL); 25 | ``` 26 | 27 | Try again after 1 second if an error is encountered: 28 | ``` 29 | SELECT cron.Register('cron.Example_Random_Error(integer)', _RetryOnError := '1 second'::interval); 30 | ``` 31 | 32 | Allow concurrent execution (DEFAULT): 33 | ``` 34 | SELECT cron.Register('cron.Example_Random_Sleep(integer)', _Concurrent := TRUE); 35 | ``` 36 | 37 | Detect and prevent concurrent: 38 | ``` 39 | SELECT cron.Register('cron.Example_Random_Sleep(integer)', _Concurrent := FALSE); 40 | ``` 41 | 42 | Wait 100 ms between each execution (DEFAULT): 43 | ``` 44 | SELECT cron.Register('cron.Example_No_Sleep(integer)', _IntervalAGAIN := '100 ms'::interval); 45 | ``` 46 | 47 | No waiting, execute again immediately: 48 | ``` 49 | SELECT cron.Register('cron.Example_No_Sleep(integer)', _IntervalAGAIN := '0'::interval); 50 | ``` 51 | 52 | Run cron job in one single process (DEFAULT): 53 | ``` 54 | SELECT cron.Register('cron.Example_No_Sleep(integer)', _Processes := 1); 55 | ``` 56 | 57 | Run cron job concurrently in two separate processes (pg backends): 58 | ``` 59 | SELECT cron.Register('cron.Example_No_Sleep(integer)', _Processes := 2); 60 | ``` 61 | 62 | Don't limit how many cron job functions that can be running in parallell (DEFAULT): 63 | ``` 64 | SELECT cron.Register('cron.Example_No_Sleep(integer)', _ConnectionPool := NULL); 65 | ``` 66 | 67 | Create a connection pool to limit the number of concurrently running processes: 68 | ``` 69 | SELECT cron.New_Connection_Pool(_Name := 'My test pool', _MaxProcesses := 2); 70 | ``` 71 | 72 | Create a connection pool to limit the number of concurrently running processes: 73 | ``` 74 | SELECT cron.Register('cron.Example_No_Sleep(integer)', _ConnectionPool := 'My test pool'); 75 | ``` 76 | 77 | Run until cron job returns DONE, then never run again (DEFAULT): 78 | ``` 79 | SELECT cron.Register('cron.Example_No_Sleep(integer)', _IntervalDONE := NULL); 80 | ``` 81 | 82 | Run until cron job returns DONE, then run again after 60 seconds: 83 | ``` 84 | SELECT cron.Register('cron.Example_No_Sleep(integer)', _IntervalDONE := '60 seconds'::interval); 85 | ``` 86 | 87 | The two examples below uses pg_catalog.pg_stat_activity.waiting or pg_catalog.pg_stat_activity.wait_event depending on the PostgreSQL version. 88 | 89 | Don't run cron job if any other PostgreSQL backend processes are waiting (DEFAULT): 90 | ``` 91 | SELECT cron.Register('cron.Example_Update_Same_Row(integer)', _RunIfWaiting := FALSE); 92 | ``` 93 | 94 | Run cron job even if there are other PostgreSQL backend processes waiting: 95 | ``` 96 | SELECT cron.Register('cron.Example_Update_Same_Row(integer)', _RunIfWaiting := TRUE); 97 | ``` 98 | Run at specific timestamp and then forever: 99 | ``` 100 | SELECT cron.Register('cron.Example_Random_Sleep(integer)', _RunIfWaiting := TRUE, _RunAfterTimestamp := now()+'10 seconds'::interval); 101 | ``` 102 | 103 | Run immediately from now until a specific timestamp in the future: 104 | ``` 105 | SELECT cron.Register('cron.Example_Random_Sleep(integer)', _RunIfWaiting := TRUE, _RunUntilTimestamp := now()+'15 seconds'::interval); 106 | ``` 107 | 108 | Run between two specific timestamps: 109 | ``` 110 | SELECT cron.Register('cron.Example_Random_Sleep(integer)', _RunIfWaiting := TRUE, _RunAfterTimestamp := now()+'20 seconds'::interval, _RunUntilTimestamp := now()+'25 seconds'::interval); 111 | ``` 112 | 113 | Run after a specific time of day and then forever: 114 | ``` 115 | SELECT cron.Register('cron.Example_Random_Sleep(integer)', _RunIfWaiting := TRUE, _RunAfterTime := now()::time+'30 seconds'::interval); 116 | ``` 117 | 118 | Run immediately from now until a specific time of day: 119 | ``` 120 | SELECT cron.Register('cron.Example_Random_Sleep(integer)', _RunIfWaiting := TRUE, _RunUntilTime := now()::time+'35 seconds'::interval); 121 | ``` 122 | 123 | Run between two specific time of day values: 124 | ``` 125 | SELECT cron.Register('cron.Example_Random_Sleep(integer)', _RunIfWaiting := TRUE, _RunAfterTime := now()::time+'40 seconds'::interval, _RunUntilTime := now()::time+'45 seconds'::interval); 126 | ``` 127 | -------------------------------------------------------------------------------- /TABLES/connectionpools.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE cron.ConnectionPools ( 2 | ConnectionPoolID serial NOT NULL, 3 | Name text NOT NULL, 4 | MaxProcesses integer NOT NULL, 5 | LastCycleAt timestamptz, 6 | ThisCycleAt timestamptz, 7 | CycleFirstProcessID integer, 8 | PRIMARY KEY (ConnectionPoolID), 9 | UNIQUE(Name), 10 | CHECK(MaxProcesses > 0) 11 | ); 12 | 13 | ALTER TABLE cron.ConnectionPools OWNER TO pgcronjob; 14 | -------------------------------------------------------------------------------- /TABLES/errorlog.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE cron.ErrorLog ( 2 | ErrorLogID bigserial NOT NULL, 3 | ProcessID integer NOT NULL REFERENCES cron.Processes(ProcessID), 4 | PgBackendPID integer NOT NULL, 5 | PgErr text, 6 | PgErrStr text, 7 | PgState text, 8 | PerlCallerInfo text, 9 | RetryInSeconds numeric, 10 | Datestamp timestamptz NOT NULL DEFAULT now(), 11 | PRIMARY KEY (ErrorLogID) 12 | ); 13 | 14 | CREATE INDEX ON cron.ErrorLog(ProcessID); 15 | 16 | ALTER TABLE cron.ErrorLog OWNER TO pgcronjob; 17 | -------------------------------------------------------------------------------- /TABLES/jobs.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE cron.Jobs ( 2 | JobID serial NOT NULL, 3 | Function text NOT NULL, 4 | Processes integer NOT NULL DEFAULT 1, 5 | Concurrent boolean NOT NULL DEFAULT TRUE, -- if set to FALSE, we will protect against concurrent execution using pg_try_advisory_xact_lock() 6 | Enabled boolean NOT NULL DEFAULT TRUE, 7 | RunIfWaiting boolean NOT NULL DEFAULT FALSE, 8 | RetryOnError interval DEFAULT NULL, -- time to sleep after an error has occurred, or NULL to never retry 9 | RandomInterval boolean NOT NULL DEFAULT FALSE, 10 | IntervalAGAIN interval NOT NULL DEFAULT '100 ms'::interval, -- time to sleep between each db txn commit to spread the load 11 | IntervalDONE interval DEFAULT NULL, -- time to sleep after a cron job has completed and has no more work to do for now, NULL means never run again 12 | RunAfterTimestamp timestamptz DEFAULT NULL, 13 | RunUntilTimestamp timestamptz DEFAULT NULL, 14 | RunAfterTime time DEFAULT NULL, 15 | RunUntilTime time DEFAULT NULL, 16 | ConnectionPoolID integer DEFAULT NULL REFERENCES cron.ConnectionPools(ConnectionPoolID), 17 | LogTableAccess boolean NOT NULL DEFAULT TRUE, 18 | RequestedBy text NOT NULL DEFAULT session_user, 19 | RequestedAt timestamptz NOT NULL DEFAULT now(), 20 | PRIMARY KEY (JobID) 21 | ); 22 | 23 | ALTER TABLE cron.Jobs OWNER TO pgcronjob; 24 | -------------------------------------------------------------------------------- /TABLES/log.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE cron.Log ( 2 | LogID bigserial NOT NULL, 3 | ProcessID integer NOT NULL REFERENCES cron.Processes(ProcessID), 4 | BatchJobState batchjobstate, 5 | PgBackendPID integer NOT NULL, 6 | StartTxnAt timestamptz NOT NULL, 7 | StartedAt timestamptz NOT NULL, 8 | FinishedAt timestamptz NOT NULL, 9 | seq_scan bigint NOT NULL, -- seq_scan 10 | seq_tup_read bigint NOT NULL, -- seq_tup_read 11 | idx_scan bigint NOT NULL, -- idx_scan 12 | idx_tup_fetch bigint NOT NULL, -- idx_tup_fetch 13 | n_tup_ins bigint NOT NULL, -- n_tup_ins 14 | n_tup_upd bigint NOT NULL, -- n_tup_upd 15 | n_tup_del bigint NOT NULL, -- n_tup_del 16 | n_tup_hot_upd bigint NOT NULL, -- n_tup_hot_upd 17 | PRIMARY KEY (LogID) 18 | ); 19 | 20 | CREATE INDEX ON cron.Log(ProcessID); 21 | 22 | ALTER TABLE cron.Log OWNER TO pgcronjob; 23 | -------------------------------------------------------------------------------- /TABLES/processes.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE cron.Processes ( 2 | ProcessID serial NOT NULL, 3 | JobID integer NOT NULL REFERENCES cron.Jobs(JobID), 4 | Calls bigint NOT NULL DEFAULT 0, 5 | Enabled boolean NOT NULL DEFAULT TRUE, 6 | RunAtTime timestamptz, 7 | FirstRunStartedAt timestamptz, 8 | FirstRunFinishedAt timestamptz, 9 | LastRunStartedAt timestamptz, 10 | LastRunFinishedAt timestamptz, 11 | BatchJobState batchjobstate, 12 | PgBackendPID integer, 13 | StateData hstore, 14 | PRIMARY KEY (ProcessID) 15 | ); 16 | 17 | ALTER TABLE cron.Processes OWNER TO pgcronjob; 18 | -------------------------------------------------------------------------------- /TYPES/batchjobstate.sql: -------------------------------------------------------------------------------- 1 | CREATE TYPE public.batchjobstate AS ENUM ( 2 | 'AGAIN', 3 | 'DONE' 4 | ); 5 | -------------------------------------------------------------------------------- /VIEWS/verrorlog.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE VIEW cron.vErrorLog AS 2 | SELECT 3 | cron.ErrorLog.ErrorLogID, 4 | cron.ErrorLog.ProcessID, 5 | cron.Jobs.Function, 6 | cron.ErrorLog.PgBackendPID, 7 | cron.ErrorLog.PgErr, 8 | cron.ErrorLog.PgErrStr, 9 | cron.ErrorLog.PgState, 10 | cron.ErrorLog.PerlCallerInfo, 11 | cron.ErrorLog.RetryInSeconds, 12 | cron.ErrorLog.Datestamp 13 | FROM cron.ErrorLog 14 | INNER JOIN cron.Processes ON cron.Processes.ProcessID = cron.ErrorLog.ProcessID 15 | INNER JOIN cron.Jobs ON cron.Jobs.JobID = cron.Processes.JobID 16 | ORDER BY cron.ErrorLog.ErrorLogID DESC; 17 | 18 | ALTER TABLE cron.vErrorLog OWNER TO pgcronjob; 19 | -------------------------------------------------------------------------------- /VIEWS/vjobs.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE VIEW cron.vJobs AS 2 | SELECT 3 | JobID, 4 | Function, 5 | Processes, 6 | CASE Concurrent WHEN TRUE THEN 'CONCURRENT' ELSE 'RUN_ALONE' END AS Concurrent, 7 | CASE Enabled WHEN TRUE THEN 'ENABLED' ELSE 'DISABLED' END AS Enabled, 8 | CASE RunIfWaiting WHEN TRUE THEN 'RUN_IF_WAITING' ELSE 'ABORT_IF_WAITING' END AS RunIfWaiting, 9 | CASE WHEN RetryOnError IS NOT NULL THEN 'RETRY_ON_ERROR' ELSE 'STOP_ON_ERROR' END AS RetryOnError, 10 | CASE RandomInterval WHEN TRUE THEN 'RANDOM_INTERVAL' ELSE 'EXACT_INTERVAL' END AS RandomInterval, 11 | IntervalAGAIN, 12 | IntervalDONE, 13 | RunAfterTimestamp::timestamp(0), 14 | RunUntilTimestamp::timestamp(0), 15 | RunAfterTime::time(0), 16 | RunUntilTime::time(0), 17 | RequestedBy, 18 | RequestedAt::timestamptz(0) 19 | FROM cron.Jobs 20 | ORDER BY JobID; 21 | 22 | ALTER TABLE cron.vJobs OWNER TO pgcronjob; 23 | -------------------------------------------------------------------------------- /VIEWS/vlog.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE VIEW cron.vLog AS 2 | SELECT 3 | LogID, 4 | ProcessID, 5 | BatchJobState, 6 | PgBackendPID, 7 | StartTxnAt::time(0), 8 | (FinishedAt-StartedAt)::interval(3) AS Duration, 9 | seq_scan, 10 | seq_tup_read, 11 | idx_scan, 12 | idx_tup_fetch, 13 | n_tup_ins, 14 | n_tup_upd, 15 | n_tup_del 16 | FROM cron.Log; 17 | 18 | ALTER TABLE cron.vLog OWNER TO pgcronjob; 19 | 20 | -------------------------------------------------------------------------------- /VIEWS/vprocesses.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE VIEW cron.vProcesses AS 2 | SELECT 3 | Jobs.JobID, 4 | Jobs.Function, 5 | Processes.ProcessID, 6 | ConnectionPools.Name || '(' || Jobs.ConnectionPoolID || ')' AS ConnectionPool, 7 | ConnectionPools.MaxProcesses, 8 | CASE 9 | WHEN Processes.RunAtTime <= now() THEN 'RUNNING' 10 | WHEN Processes.RunAtTime > now() THEN 'QUEUED' 11 | WHEN Processes.RunAtTime IS NULL THEN 'STOPPED' 12 | END 13 | AS Status, 14 | extract(epoch from Processes.RunAtTime - now())::numeric(12,2) AS RunInSeconds, 15 | Processes.Calls, 16 | Processes.BatchJobState, 17 | CASE WHEN EXISTS (SELECT 1 FROM public.pg_stat_activity_portable() WHERE pid = Processes.PgBackendPID) THEN 'OPEN' ELSE 'CLOSED' END AS Connection, 18 | COALESCE(pg_stat_activity.pid,Processes.PgBackendPID) AS procpid, 19 | CASE pg_stat_activity.waiting WHEN TRUE THEN 'WAITING' END AS waiting, 20 | pg_stat_activity.query, 21 | (now()-pg_stat_activity.query_start)::interval(0) AS duration, 22 | (Processes.LastRunFinishedAt-Processes.FirstRunStartedAt)::interval(0) AS TotalDuration, 23 | (now()-cron.Processes.LastRunFinishedAt)::interval(0) AS LastRun, 24 | cron.Processes.StateData 25 | FROM cron.Processes 26 | INNER JOIN cron.Jobs ON (Jobs.JobID = Processes.JobID) 27 | LEFT JOIN cron.ConnectionPools ON (ConnectionPools.ConnectionPoolID = cron.Jobs.ConnectionPoolID) 28 | LEFT JOIN public.pg_stat_activity_portable() AS pg_stat_activity ON (pg_stat_activity.query = format('SELECT RunInSeconds FROM cron.Run(_ProcessID := %s)',Processes.ProcessID)) 29 | ORDER BY Jobs.JobID, Jobs.Function, Processes.ProcessID; 30 | 31 | ALTER TABLE cron.vProcesses OWNER TO pgcronjob; 32 | -------------------------------------------------------------------------------- /cron.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA cron; 2 | 3 | ALTER SCHEMA cron OWNER TO pgcronjob; 4 | REVOKE ALL ON SCHEMA cron FROM PUBLIC; 5 | GRANT ALL ON SCHEMA cron TO pgcronjob; 6 | 7 | \ir TABLES/connectionpools.sql 8 | \ir TABLES/jobs.sql 9 | \ir TABLES/processes.sql 10 | \ir TABLES/log.sql 11 | \ir TABLES/errorlog.sql 12 | \ir FUNCTIONS/log_error.sql 13 | \ir FUNCTIONS/log_table_access.sql 14 | \ir FUNCTIONS/is_valid_function.sql 15 | \ir FUNCTIONS/register.sql 16 | \ir FUNCTIONS/disable.sql 17 | \ir FUNCTIONS/disable_process.sql 18 | \ir FUNCTIONS/enable.sql 19 | \ir FUNCTIONS/enable_process.sql 20 | \ir FUNCTIONS/run.sql 21 | \ir FUNCTIONS/dispatch.sql 22 | \ir FUNCTIONS/terminate_all_backends.sql 23 | \ir FUNCTIONS/new_connection_pool.sql 24 | \ir FUNCTIONS/reset_runattime.sql 25 | \ir FUNCTIONS/schedule.sql 26 | \ir FUNCTIONS/generate_register_commands.sql 27 | \ir FUNCTIONS/delete_old_log_rows.sql 28 | \ir VIEWS/vjobs.sql 29 | \ir VIEWS/vprocesses.sql 30 | \ir VIEWS/vlog.sql 31 | -------------------------------------------------------------------------------- /install.sql: -------------------------------------------------------------------------------- 1 | ROLLBACK; 2 | \set AUTOCOMMIT OFF 3 | CREATE EXTENSION hstore; 4 | BEGIN; 5 | CREATE TYPE public.batchjobstate AS ENUM ( 6 | 'AGAIN', 7 | 'DONE' 8 | ); 9 | COMMIT; 10 | BEGIN; 11 | DROP SCHEMA cron CASCADE; 12 | COMMIT; 13 | BEGIN; 14 | \ir FUNCTIONS/pg_stat_activity_portable.sql 15 | \ir cron.sql 16 | COMMIT; 17 | -------------------------------------------------------------------------------- /pgcronjob: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | use strict; 3 | use warnings; 4 | 5 | use DBI; 6 | use DBD::Pg; 7 | use Time::HiRes qw(time sleep); 8 | use Data::Dumper; 9 | use DateTime; 10 | use IO::Select; 11 | 12 | $| = 1; 13 | 14 | my @DispatchConnect = ("dbi:Pg:", '', '', {pg_enable_utf8 => 1, sslmode => 'require', RaiseError => 1, PrintError => 0, AutoCommit => 1}); 15 | my @RunConnect = ("dbi:Pg:", '', '', {pg_enable_utf8 => 1, sslmode => 'require', RaiseError => 0, PrintError => 0, AutoCommit => 1}); 16 | my @LISTENConnect = ("dbi:Pg:", '', '', {pg_enable_utf8 => 1, sslmode => 'require', RaiseError => 1, PrintError => 0, AutoCommit => 1}); 17 | my $LISTENName = 'cron.Dispatch()'; 18 | 19 | sub SQL_Run { 20 | my $ProcessID = shift; 21 | die "Invalid ProcessID: $ProcessID" unless $ProcessID =~ m/^\d+$/; 22 | return "SELECT RunInSeconds FROM cron.Run(_ProcessID := $ProcessID)"; 23 | } 24 | 25 | sub tprint { 26 | my $msg = shift; 27 | print DateTime->now(time_zone => 'local')->datetime() . ' ' . $msg . "\n"; 28 | } 29 | 30 | my $Processes = {}; 31 | my $Select = IO::Select->new(); 32 | 33 | my $StartupTime = time(); 34 | my $DispatchDatabaseHandle = DBI->connect(@DispatchConnect) or die "Unable to connect"; 35 | my $ConnectTime = time() - $StartupTime; 36 | my $Terminate_All_Backends = $DispatchDatabaseHandle->prepare('SELECT cron.Terminate_All_Backends()'); 37 | my $Reset_RunAtTime = $DispatchDatabaseHandle->prepare('SELECT cron.Reset_RunAtTime()'); 38 | my $Dispatch = $DispatchDatabaseHandle->prepare('SELECT RunProcessID, RunInSeconds, MaxProcesses, ConnectionPoolID, RetryOnError FROM cron.Dispatch()'); 39 | my $Log_Error = $DispatchDatabaseHandle->prepare('SELECT cron.Log_Error(_ProcessID := $1, _PgBackendPID := $2, _PgErr := $3, _PgErrStr := $4, _PgState := $5, _PerlCallerInfo := $6, _RetryInSeconds := $7)'); 40 | my $Disable_Process = $DispatchDatabaseHandle->prepare('SELECT cron.Disable_Process(_ProcessID := $1)'); 41 | $Select->add($DispatchDatabaseHandle->{pg_socket}); 42 | 43 | unless ($DispatchDatabaseHandle->{pg_user} eq 'pgcronjob') { 44 | die "Not connected as the 'pgcronjob' database user!"; 45 | } 46 | 47 | for (;;) { 48 | $Terminate_All_Backends->execute(); 49 | my ($OK) = $Terminate_All_Backends->fetchrow_array(); 50 | if ($OK == 1) { 51 | # all cron.Run() processes have now been terminated 52 | last; 53 | } 54 | tprint("Waiting for old cron processes to be terminated"); 55 | sleep(1); # wait for processes to be terminated 56 | } 57 | $Reset_RunAtTime->execute(); 58 | 59 | my $LISTENDatabaseHandle = DBI->connect(@LISTENConnect) or die "Unable to connect LISTEN database handle"; 60 | $LISTENDatabaseHandle->do(qq{LISTEN "$LISTENName"}) or die "Unable to LISTEN: " . $LISTENDatabaseHandle->errstr; 61 | $Select->add($LISTENDatabaseHandle->{pg_socket}); 62 | 63 | tprint("PgCronJob is now running"); 64 | 65 | my $DispatchTime = time(); 66 | my $NumProcesses = {}; 67 | 68 | sub FinishDisconnect { 69 | my $ProcessID = shift; 70 | my $Process = $Processes->{$ProcessID}; 71 | if (!defined $Process) { 72 | return 1; # OK, no such process 73 | } 74 | if (!defined $Process->{DatabaseHandle}) { 75 | return 1; # OK, no such database handle 76 | } 77 | my $RunDatabaseHandle = \$Process->{DatabaseHandle}; 78 | my $Run = \$Process->{Run}; 79 | $$Run->finish; 80 | $$Run = undef; 81 | delete $Process->{Run}; 82 | $$RunDatabaseHandle->disconnect; 83 | $$RunDatabaseHandle = undef; 84 | delete $Process->{DatabaseHandle}; 85 | delete $Process->{PgBackendPID}; 86 | $Select->remove($Process->{PgSocketFD}); 87 | delete $Process->{PgSocketFD}; 88 | return 1; #OK, finished and disconnected 89 | } 90 | 91 | # CheckErr checks if a non-true $ReturnValue was due to a database error or not. 92 | sub CheckErr { 93 | my ($ReturnValue, $ProcessID) = @_; 94 | my ($Package, $FileName, $Line) = caller(); 95 | if ($ReturnValue) { 96 | # OK since $ReturnValue evaluated to true 97 | return 1; 98 | } 99 | 100 | my $Process = $Processes->{$ProcessID}; 101 | if (!defined $Process || !defined $Process->{DatabaseHandle}) { 102 | return 0; # error, no such process or database handle 103 | } 104 | 105 | my $RunDatabaseHandle = \$Process->{DatabaseHandle}; 106 | my $Run = \$Process->{Run}; 107 | my $RunAtTime = \$Process->{RunAtTime}; 108 | my $RetryOnError = \$Process->{RetryOnError}; 109 | 110 | if (!defined($$RunDatabaseHandle->err) || $$RunDatabaseHandle->err == 0) { 111 | # OK since it was not a database error, 112 | # so the non-true $ReturnValue must have been 113 | # due to no rows returned or some other non-error situation 114 | return 1; 115 | } 116 | 117 | my $PgErr = $Process->{DatabaseHandle}->err; 118 | my $PgErrStr = $Process->{DatabaseHandle}->errstr; 119 | my $PgState = $Process->{DatabaseHandle}->state; 120 | 121 | $Log_Error->execute( 122 | $ProcessID, 123 | $Process->{DatabaseHandle}->{pg_pid}, 124 | $PgErr, 125 | $PgErrStr, 126 | $PgState, 127 | "$FileName line $Line", 128 | $$RetryOnError 129 | ); 130 | 131 | FinishDisconnect($ProcessID); 132 | 133 | if ($PgState eq '40P01') { 134 | # deadlock detected, always retry since a deadlock is normal 135 | $$RunAtTime = time() + 1; 136 | } elsif (defined $$RetryOnError) { 137 | $$RunAtTime = time() + $$RetryOnError; 138 | } else { 139 | delete $Processes->{$ProcessID}; 140 | $Disable_Process->execute($ProcessID); 141 | } 142 | 143 | return 0; 144 | } 145 | 146 | sub DumpState { 147 | tprint("ConnectTime: $ConnectTime\n" 148 | . "DispatchTime: $DispatchTime\n" 149 | . "NumProcesses:\n" 150 | . Dumper($NumProcesses) 151 | . "Processes:\n" 152 | . Dumper($Processes) 153 | ); 154 | } 155 | 156 | $SIG{HUP} = \&DumpState; 157 | 158 | for (;;) { 159 | if (time() > $DispatchTime) { 160 | $Dispatch->execute(); 161 | my ($RunProcessID, $RunInSeconds, $MaxProcesses, $ConnectionPoolID, $RetryOnError) = $Dispatch->fetchrow_array(); 162 | if (defined $RunProcessID) { 163 | $Processes->{$RunProcessID} = { 164 | RunAtTime => time() + $RunInSeconds, 165 | MaxProcesses => $MaxProcesses, 166 | ConnectionPoolID => $ConnectionPoolID, 167 | RetryOnError => $RetryOnError 168 | }; 169 | } else { 170 | $DispatchTime = time()+1; 171 | } 172 | 173 | } 174 | my $WakeUpTime = $DispatchTime; 175 | my $CurrentTime = time(); 176 | foreach my $ProcessID (sort {$Processes->{$a}->{RunAtTime} <=> $Processes->{$b}->{RunAtTime}} keys %{$Processes}) { 177 | my $Process = $Processes->{$ProcessID}; 178 | my $RunAtTime = \$Process->{RunAtTime}; 179 | my $RunASAP = \$Process->{RunASAP}; 180 | my $MaxProcesses = \$Process->{MaxProcesses}; 181 | my $ConnectionPoolID = \$Process->{ConnectionPoolID}; 182 | my $RetryOnError = \$Process->{RetryOnError}; 183 | my $RunDatabaseHandle = \$Process->{DatabaseHandle}; 184 | my $Run = \$Process->{Run}; 185 | if (defined $$ConnectionPoolID && !defined $NumProcesses->{$$ConnectionPoolID}) { 186 | $NumProcesses->{$$ConnectionPoolID} = 0; 187 | } 188 | if ($$RunAtTime > 0) { 189 | if (defined $$MaxProcesses && $$MaxProcesses ne '' && $NumProcesses->{$$ConnectionPoolID} >= $$MaxProcesses) { 190 | # Can't run this process anywhere; don't adjust WakeUpTime 191 | next; 192 | } 193 | if ($$RunAtTime < $WakeUpTime) { 194 | $WakeUpTime = $$RunAtTime; 195 | } 196 | if ($$RunAtTime > $CurrentTime) { 197 | next; 198 | } 199 | unless (defined $$RunDatabaseHandle) { 200 | $$RunDatabaseHandle = DBI->connect(@RunConnect) or die "Unable to connect"; 201 | $Process->{PgBackendPID} = ${$RunDatabaseHandle}->{pg_pid}; 202 | $Process->{PgSocketFD} = $$RunDatabaseHandle->{pg_socket}; 203 | $Select->add($Process->{PgSocketFD}); 204 | $$Run = ${$RunDatabaseHandle}->prepare(SQL_Run($ProcessID), {pg_async => PG_ASYNC}) or die "Unable to prepare"; 205 | } 206 | CheckErr($$Run->execute(), $ProcessID) or die "Unexpected execute error"; 207 | $$RunAtTime = 0; 208 | if (defined $$ConnectionPoolID) { 209 | $NumProcesses->{$$ConnectionPoolID}++; 210 | } 211 | } elsif ($$Run->pg_ready) { 212 | if (defined $$ConnectionPoolID) { 213 | $NumProcesses->{$$ConnectionPoolID}--; 214 | } 215 | my $Rows = $$Run->pg_result; 216 | CheckErr($Rows, $ProcessID) or next; 217 | if ($Rows > 1) { 218 | die "Unexpected numbers of rows: $Rows"; 219 | } 220 | my ($RunInSeconds) = $$Run->fetchrow_array(); 221 | CheckErr($RunInSeconds, $ProcessID) or next; 222 | if ($$Run->fetchrow_array()) { 223 | die "Unexpected extra row returned"; 224 | } 225 | if (!defined $RunInSeconds || $RunInSeconds > $ConnectTime) { 226 | FinishDisconnect($ProcessID); 227 | } 228 | if (defined $RunInSeconds) { 229 | # We want to actually assign a time here in order to let 230 | # the main loop prioritize the tasks correctly. 231 | $$RunAtTime = $CurrentTime + ($$RunASAP ? 0 : $RunInSeconds); 232 | if ($$RunAtTime < $WakeUpTime) { 233 | $WakeUpTime = $$RunAtTime; 234 | } 235 | $$RunASAP = undef; 236 | } else { 237 | delete $Processes->{$ProcessID}; 238 | } 239 | } 240 | } 241 | while (my $NOTIFY = $LISTENDatabaseHandle->pg_notifies) { 242 | my ($NOTIFYName, $NOTIFYPID, $NOTIFYPayload) = @$NOTIFY; 243 | unless ($NOTIFYName eq $LISTENName && $NOTIFYPayload =~ m/^\d+$/) { 244 | die "Unexpected notification $NOTIFYName $NOTIFYPayload"; 245 | } 246 | my $ProcessID = $NOTIFYPayload; 247 | if (!defined $Processes->{$ProcessID}) { 248 | # This could happen because notifications can be delayed 249 | # for an arbitrary amount of time. 250 | next; 251 | } 252 | if ($Processes->{$ProcessID}->{RunAtTime} > 0) { 253 | $Processes->{$ProcessID}->{RunAtTime} = $CurrentTime; 254 | $WakeUpTime = $CurrentTime; 255 | } else { 256 | $Processes->{$ProcessID}->{RunASAP} = 1; 257 | $WakeUpTime = 0; 258 | } 259 | } 260 | $CurrentTime = time(); 261 | my $MaxSleep = $WakeUpTime - $CurrentTime; 262 | if ($MaxSleep <= 0) { 263 | next; 264 | } elsif ($MaxSleep > 0.250) { 265 | $MaxSleep = 0.250; 266 | } 267 | $Select->can_read($MaxSleep); 268 | } 269 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trustly/pgcronjob/9b5a699d2b1e731b0c04f6b3ce968f01c09876b9/screenshot.png -------------------------------------------------------------------------------- /uninstall.sql: -------------------------------------------------------------------------------- 1 | ROLLBACK; 2 | BEGIN; 3 | DROP SCHEMA cron CASCADE; 4 | DROP TYPE public.batchjobstate; 5 | COMMIT; 6 | --------------------------------------------------------------------------------