Monday, March 20, 2017

Triggers, Jobs, Events and Queues - Part 1

A question in the OTN SQLand PL/SQL Forum got me to thinking about database triggers and the correct way to implement some requirements that lead people to misuse them.  This relates back to my earlier post on Updatable Views - Code Schema with Updatable Views.  I said:
"I must emphasize that INSTEAD OF triggers operate within the scope of the current transaction.  Any change they make to the underlying data is part of the same transaction, and all changes are either entirely committed, or entirely rolled back.  This can work to your advantage – take the example of the funds transfer that I mentioned before.  This is two INSERTs and either both are committed, or both are rolled back.  But there are things that a trigger could do that are not part of the transaction, such as calls to UTL_FILE to write a file outside of the database.  These happen even if the transaction is rolled back.  Not only that, but there are cases when the code in a trigger might actually be run more than once.  If you want to call one of the built-in packages with a name starting with “UTL”, you should probably queue an event to do it, rather than doing it directly."

Send an E-Mail on an Event

So here is a case where you might be tempted to use a trigger:  In the HR sample schema that comes with all Oracle databases, there is a table, EMPLOYEES.  Each Employee has a foreign key, DEPARTMENT_ID that points to DEPARTMENTS.  Each Department has a foreign key, MANAGER_ID, which points to the EMPLOYEES row for the Department Manager.  What we want to do is send an e-mail to the department manager whenever a new Employee is added to that Department, or an existing Employee’s DEPARTMENT_ID is changed to point to a new Department.
You say, "Fine, I’ve got a SEND_MAIL procedure that calls the UTL_SMTP built-in package to send e-mails.  I’ll just write a trigger to call it when inserting a new EMPLOYEES row with a non-null DEPARTMENT_ID, or updating it with the new DEPARTMENT_ID not equal to the old one."

CREATE OR REPLACE TRIGGER employees_manager_change_email
  AFTER INSERT OR UPDATE OF department_id
  ON employees
  FOR EACH ROW
DECLARE
  manager_first_name employees.first_name%TYPE;
  manager_last_name  employees.last_name%TYPE;
  manager_email      employees.email%TYPE;
BEGIN
  IF :NEW.department_id IS NOT NULL AND
     (INSERTING OR
      (UPDATING AND :NEW.department_id <> NVL(:OLD.department_id,0))) THEN
    SELECT first_name, last_name, email
      INTO manager_first_name, manager_last_name, manager_email
      FROM employees
     WHERE employee_id = (SELECT manager_id
                            FROM departments
                           WHERE department_id = :NEW.department_id);
    SEND_MAIL (p_from    => 'HR',
               P_to      => manager_email,
               P_subject => 'New Employee in your Department',
               P_text    => 'Please welcome '||:NEW.first_name ||
                            ' '||:NEW.last_name || ' to Department '||
                            TO_CHAR(:NEW.department_id)||' as a '||
                            :NEW.job_id ||'.');
  END IF;
END;

Ah, but it won’t work – you’ll get a mutating table exception because you can't select from the EMPLOYEES table at the same time that you are updating it.  Some people would then take the offending code and put it in a procedure and make it AUTONOMOUS transaction.  That way, since the SELECT from EMPLOYEES is in a separate transaction from the insert or update, it works.
I'm certainly in favor of making this a separate procedure, but making it autonomous is a bad idea.  Suppose the change to an Employee's department isn't committed?  The user changed his/her mind, and decided not to save the change.  The manager would get the e-mail anyway, because the e-mail transaction is no longer dependent on the insert or update transaction.  No, we need to make sure that the e-mail only gets sent if the change is committed.  Can we make the change raise some sort of notification that an e-mail should be sent, but as part of the current transaction, so it ONLY happens on COMMIT?

The Procedure

So, first, let's make this a procedure to separate the code for sending the e-mail from the trigger code.  We are still going to use a trigger to call it because we want it to send the e-mail automatically on the event, but this is not going to be an AUTONOMOUS TRANSACTION, so we can't just call it.  We'll need to pass it all the information it needs from the EMPLOYEES row that is being changed by the transaction.  Although I'm going to show this as a stand-alone procedure, my usual practice is to put all my procedures and functions in packages.
CREATE OR REPLACE PROCEDURE employee_change_manager_email (
  p_department_id IN employees.department_id%TYPE,
  p_first_name    IN employees.first_name%TYPE,
  p_last_name     IN employees.last_name%TYPE,
  p_job_id        IN employees.job_id%TYPE
  ) IS
  manager_first_name employees.first_name%TYPE;
  manager_last_name  employees.last_name%TYPE;
  manager_email      employees.email%TYPE;
BEGIN
  SELECT first_name, last_name, email
    INTO manager_first_name, manager_last_name, manager_email
    FROM employees
   WHERE employee_id = (SELECT manager_id
                          FROM departments
                         WHERE department_id = p_department_id);
  SEND_MAIL (p_from    => 'HR',
             p_to      => manager_email,
             p_subject => 'New Employee in your Department',
             p_text    => 'Please welcome '||p_first_name ||
                          ' '||p_last_name || ' to Department '||
                          TO_CHAR(p_department_id)||' as a '||
                          p_job_id ||'.');
END employee_change_manager_email;

But how to call it?  I can't just call it from the trigger, because it would still raise a mutating table error.  If I make it AUTONOMOUS, it gets called whether the transaction is committed or not.  So I need to call it in some way that depends on the transaction, but is still in some way autonomous.  Fortunately, there are two possibilities in Oracle.  We can submit a job with DBMS_JOB, or we can queue it as an event with Oracle Advanced Queuing (AQ).  DBMS_JOB is simpler, so I'll save Oracle AQ for another post.

Method 1: DBMS_JOB

We mostly think of the DBMS_JOB package as a way to schedule jobs and have them execute at particular times.  And in that role, it has mostly been supplanted by the DBMS_SCHEDULER package.  However, it is also possible to use DBMS_JOB to schedule a job to run immediately.  And a job runs in its own transaction context, independent of the transaction that scheduled it.  Best of all, the DBMS_JOB.SUBMIT procedure requires a COMMIT, which means that if you run it from a trigger, the job does not start running until the triggering transaction is committed.  DBMS_SCHEDULER does not need a commit, so it won't do for this task.  If the triggering transaction is rolled back, the job will not run.  This is exactly what we want for this requirement.  So here is our new version of the trigger:
CREATE OR REPLACE TRIGGER employees_manager_change_email
  AFTER INSERT OR UPDATE OF department_id
  ON employees
  FOR EACH ROW
DECLARE
  job_number NUMBER;
BEGIN
  IF :NEW.department_id IS NOT NULL AND
     (INSERTING OR
      (UPDATING AND :NEW.department_id <> NVL(:OLD.department_id,0))) THEN
    DBMS_JOB.SUBMIT (job  => job_number,
                     what => 'employee_change_manager_email (
  p_department_id => '||:new.department_id||',
  p_first_name    => '''||:new.first_name||''',
  p_last_name     => '''||:new.last_name||''',
  p_job_id        => '''||:new.job_id||''');'
                     );
  END IF;
END;

This is a little hard to read because the call to the procedure to send the mail is submitted as a string, and we need to concatenate the parameters into the string.  The trigger runs DBMS_JOB.SUBMIT to create and submit a job, and the missing "next_date" and "interval" parameters default to running the job immediately and only one time.  But the job doesn't actually get submitted until the COMMIT for the INSERT or UPDATE of EMPLOYEES.  When the job runs, the procedure runs in a separate transaction, so there is no mutating table.
In another post, I'll give the Oracle AQ method.

No comments:

Post a Comment