A nasty problem has been haunting me for a while now in my codebase: Every time I run my full utPLSQL test suite, one specific test-package fails every test with ORA-04061 - Existing state of Package has been invalidated. But when I run this specific test package, everything works fine.

Today I followed the rabbit hole and to get a better understanding of what’s going on I tried to extract what I learned into a simple example in my favourite universe.

The situation

Let’s assume we have a table of popular Sith and instead of allowing direct manipulation of the table with have a package sith_manager that handles adding new popular Sith.

create table popular_sith (
  id integer primary key not null,
  name varchar2(200)
);

create sequence popular_sith_seq start with 1;

create or replace package sith_manager as
  procedure add_popular_sith( i_name varchar2 );
end;
/

create or replace package body sith_manager as

  /* To better reveal our intention and to keep
     things that might change separated from the
     implementation, we use a constant for the default
     sith name
  */
  gc_default_name constant varchar2(200) := 'Darth'||' Ora';

  procedure add_popular_sith( i_name varchar2 )
  as
  begin
    insert into popular_sith (id, name )
      values (
        popular_sith_seq.nextval,
        nvl(i_name, gc_default_name)
      );
  end;
end;
/

To validate that in case of passing NULL the default Sith-Name is used, we create a Unit-Test.

There is one difficulty, though: We don’t know what ID our test-entry will get because the add_popular_sith-function uses an underlying sequence. For name is not unique, how can we know which entry to test?

(For some folks pointed it out: I am very aware that this solution is not well-testable and that there are several better ways to deal with the problem. This example only exists to superficially create the scenario I want to demonstrate.
There will be a follow-up blog post that shows alternative, better solutions to solve this problem.)

So let’s control the sequence and make sure it will give a specific ID when we run our test.
For Unit-Tests should only be installed on dev and test environments, that’s not as problematic as it would be in a production environment. However, there are better ways to deal with this kind of situation which I will cover in a future blog post.

create or replace package ut_sith_manager as
  -- %suite(Sith Manager)
  -- %suitepath(darkSide.management)

  procedure control_sequence;

  -- %test(ADD_POPULAR_SITH uses default name on NULL param)
  -- %beforetest(control_sequence)
  procedure add_sith_default_on_null_param;

  -- %afterall
  procedure reset_sequence;
end;
/

create or replace package body ut_sith_manager as

  /* To control which ID our test-entry will be get,
     we control the underlying sequence
   */
  procedure control_sequence as
    pragma autonomous_transaction;
  begin
    /* I am aware that this is not a good way to reset
       sequences. But the whole purpose of this example
       *is* to invalidate the dependent object.
       For a better approach google
       "oracle reset sequence tom kyte"
     */
    execute immediate 'drop sequence popular_sith_seq';
    execute immediate 'create sequence popular_sith_seq' || 
      ' minvalue -100 start with -100';
  end;

  /* After the test is done, we want to set the
     sequence back to its highest value
   */
  procedure reset_sequence as
    pragma autonomous_transaction;
    l_max_id integer;
  begin
    select nvl(max(id),0)+1 into l_max_id from popular_sith;
    execute immediate 'drop sequence popular_sith_seq';
    execute immediate 'create sequence popular_sith_seq'||
      ' start with '||l_max_id;
  end;

  procedure add_sith_default_on_null_param
  as
    l_actual_row popular_sith%rowtype;
  begin
    -- Act
    sith_manager.add_popular_sith(null);

    -- Assert
    select * into l_actual_row
      from popular_sith
      where id = -100;
    ut.expect(l_actual_row.name).to_equal('Darth Ora');
  end;
end;
/
> utplsql run user/pw@localhost -cq -p="ut_sith_manager"
darkside
  management
    Sith Manager
      ADD_POPULAR_SITH uses default name on NULL param [,045 sec]
 
Finished in ,061706 seconds
1 tests, 0 failed, 0 errored, 0 disabled, 0 warning(s)

So far, so good. Everything works fine.

The Problem

However, if we query for invalid objects after running the test, we will see the following entry:

select 
  object_name, 
  status, 
  last_ddl_time 
from user_objects 
where status <> 'VALID';
OBJECT_NAMESTATUSLAST_DDL_TIME
SITH_MANAGERINVALID2020-04-08 20:53:19

Our sith_manager package has been invalidated because of the drop and recreation of the sequence it is referencing. Not a big problem, though, because it’s automatically recompiled when we use it the next time.

Let’s add a sith_generator to create a lot of popular sith then:

create or replace package sith_generator as
  procedure generate_popular_sith(
    i_name varchar2,
    i_count integer
  );
end;
/

create or replace package body sith_generator as
  procedure generate_popular_sith(
    i_name varchar2,
    i_count integer
  ) as
  begin
    if nvl(i_count,0) > 0 then
      for i in 1..i_count loop
        sith_manager.add_popular_sith(
          i_name || ' ' || i
        );
      end loop;
    end if;
  end;
end;
/

Again, we can not check the output directly, but at least we can control the input name and therefore write our test accordingly:

create or replace package ut_sith_generator as
  -- %suite(Sith Generator)
  -- %suitepath(darkSide)

  -- %test(GENERATE_POPULAR_SITH creates correct number of Sith with name "<GIVEN_NAME> <COUNTER>")
  procedure generate_popular_sith;
end;
/

create or replace package body ut_sith_generator as

  procedure generate_popular_sith as
    c_actual sys_refcursor;
    c_expect sys_refcursor;
  begin
    -- Act
    sith_generator.generate_popular_sith('MyTest_Popular_Sith', 3);

    -- Assert
    open c_actual for
      select name from popular_sith
        where name like 'MyTest_Popular_Sith%'
        order by name;
    open c_expect for
      select column_value name
        from table(sys.odcivarchar2list(
          'MyTest_Popular_Sith 1',
          'MyTest_Popular_Sith 2',
          'MyTest_Popular_Sith 3'
          ));
    ut.expect(c_actual).to_equal(c_expect);
  end;

end;
/
> utplsql run user/pw@localhost -cq -p="ut_sith_generator"
darkside
  Sith Generator
    GENERATE_POPULAR_SITH creates correct number of Sith with name "<GIVEN_NAME> <COUNTER>" [,018 sec]
 
Finished in ,021618 seconds
1 tests, 0 failed, 0 errored, 0 disabled, 0 warning(s)

But what happens when we call both our tests in one run, e.g. by their shared Suitepath darkside?

> utplsql run user/pw@localhost -cq -p=":darkside"
darkside
  Sith A Generator
    GENERATE_POPULAR_SITH creates correct number of Sith with name "<GIVEN_NAME> <COUNTER>" [,026 sec]
  management
    Sith-Manager
      ADD_POPULAR_SITH uses default name on NULL param [,009 sec] (FAILED - 1)
 
Failures:
 
  1) add_sith_default_on_null_param
      ORA-04061: Existing state of package body "SITHDB.SITH_MANAGER" has been invalidated
      ORA-04065: not executed, altered or dropped package body "SITHDB.SITH_MANAGER"
      ORA-06508: PL/SQL: could not find program unit being called: "SITHDB.SITH_MANAGER"
      ORA-06512: in "SITHDB.UT_SITH_MANAGER", line 36
      ORA-06512: in "SITHDB.UT_SITH_MANAGER", line 36
      ORA-06512: in line 6
Finished in ,047853 seconds
2 tests, 0 failed, 1 errored, 0 disabled, 0 warning(s)

Oh my Lord Sith – we are in a situation where both of our packages run just fine on their own, but when sith_generator is run before sith_manager in the same session, we have failing tests – one of the worst circumstances to be in (especially when your whole suite of tests needs some time to run).

But what happens? What is the problem?

There are two things in combination that cause the error:

1. sith_manager contains so-called PL/SQL package state, because it uses a global package variable: gc_default_name. The constant keyword does not prevent the PL/SQL compiler from treating the variable as global state as soon as there is any kind of computation (in our case concatenating 2 strings).

2. sith_manager is invalidated by our ut_sith_manager unit-test.

ut_sith_generator first calls sith_generator which calls and initializes sith_manager. At this point, sith_manager has active global state.
Second, ut_sith_manager invalidates sith_manager causing all active global state to be reset.
For the compiler can not know whether that’s problematic or not, it will cause the ORA-04061 to inform that there has been state that’s now gone.

The Solution

For this problem is caused by the combination of two things, we can divide possible solutions into two categories:

  • Avoid package to have global state
  • Avoid invalidation of package

In this blog post, I will concentrate on the elimination of global state. There will be a follow-up post that dives into the possibilities to prevent the packages to be invalidated.

Option 1: Use Constant Values that are recognized by the Compiler

If you are on Oracle 12.2 or higher, it will be enough to change the value of our constant to eliminate our global state:

create or replace package body sith_manager as

  gc_default_name constant varchar2(200) := 'Darth Ora';

  procedure add_popular_sith( i_name varchar2 )
  as
  begin
    insert into popular_sith (id, name )
      values (
        popular_sith_seq.nextval,
        nvl(i_name, gc_default_name)
      );
  end;
end;
/

The PL/SQL compiler might be able to correctly recognize constants as such – as long as you don’t do any kind of computation.

Option 2: PRAGMA SERIALLY_REUSABLE

By adding pragma serially_reusable to the sith_manager package, we can also avoid global state to be created:

create or replace package sith_manager as
  pragma serially_reusable;

  procedure add_popular_sith( i_name varchar2 );
end;
/

create or replace package body sith_manager as
  pragma serially_reusable;
  gc_default_name constant varchar2(200) := 'Darth'||' Ora';

  procedure add_popular_sith( i_name varchar2 )
  as
  begin
    insert into popular_sith (id, name )
      values (
        popular_sith_seq.nextval,
        nvl(i_name, gc_default_name)
      );
  end;
end;
/

What happens is that any global package state that might be created is immediately deleted after each call.

The problem is, that this renders the whole package impossible to be called from SQL:

create or replace package sith_manager as
  pragma serially_reusable;

  procedure add_popular_sith( i_name varchar2 );

  function id_by_name( i_name varchar2 )
    return varchar2;
end;
/

create or replace package body sith_manager as
  pragma serially_reusable;
  gc_default_name constant varchar2(200) := 'Darth'||' Ora';

  procedure add_popular_sith( i_name varchar2 )
  as
  begin
    insert into popular_sith (id, name )
      values (
        popular_sith_seq.nextval,
        nvl(i_name, gc_default_name)
      );
  end;

  function id_by_name( i_name varchar2 )
    return varchar2
  as
    l_result integer;
  begin
    select id into l_result
      from popular_sith
      where name = i_name;
    return l_result;
  exception when no_data_found then
    return null;
  end;
end;
/

call sith_manager.add_popular_sith('Darth Vader');
select sith_manager.id_by_name('Darth Vader') from dual;

The last select will cause ORA-06534: Cannot access Serially Reusable package "SITHDB.SITH_MANAGER".

There are also other limitations, for example it’s not possible to have pipelined table functions in such a package.

Option 3: Move Constants to a separate Package

This is not exactly “avoiding global state”, but avoiding global state in the package that will become invalidated:

By moving the constants to a separate package we make sure that any invalidation of the package that holds the implementation has no impact on the configuration stuff we hold in global state.

create or replace package sith_manager_const as
  gc_default_name constant varchar2(200) := 'Darth'||' Ora';
end;
/

create or replace package sith_manager as
  procedure add_popular_sith( i_name varchar2 );
end;
/

create or replace package body sith_manager as
  procedure add_popular_sith( i_name varchar2 )
  as
  begin
    insert into popular_sith (id, name )
      values (
        popular_sith_seq.nextval,
        nvl(i_name, sith_manager_const.gc_default_name)
      );
  end;
end;
/

While it might be inconvenient to be forced to have these constants now public, it prevents the ORA-04061 very effectively.

There can also be benefits to have shared public constants, but be mindful when introducing more coupling.

Option 4: Use Functions

Another option is to replace the package variable with a function that returns the fixed value:

create or replace package sith_manager as
  function gc_default_name
    return varchar2 deterministic;

  procedure add_popular_sith( i_name varchar2 );
end;
/

create or replace package body sith_manager as

  function gc_default_name
    return varchar2 deterministic as
  begin
    return 'Darth '||'Ora';
  end;

  procedure add_popular_sith( i_name varchar2 )
  as
  begin
    insert into popular_sith (id, name )
      values (
        popular_sith_seq.nextval,
        nvl(i_name, gc_default_name)
      );
  end;
end;
/

To use the function in SQL (even in SQL used inside the Package), we need to make it public. Alternatively we could store the function result in a local variable first, in that case we wouldn’t need to make the function public.

I am also concerned that this kind of use might obfuscate the intention of the code. Make sure to introduce strong naming conventions among your team to make it clear when a function’s only purpose is to serve as kind of constant.

(Not An) Option 5: DBMS_SESSION.RESET_PACKAGE

It might be tempting to think that using dbms_session.reset_package will solve our global state problem.

This procedure resets all package state and we can even force to reinitialize it:

dbms_session.reset_package();
dbms_session.modify_package_state( dbms_session.reinitialize );

But for utPLSQL stores a lot of temporary information in its internal package state, this will inevitably lead to an empty test output.

Conclusion

Do you have more ideas how to deal with ORA-04061 in such a setting? Then reach out to me – or wait for my follow-up post on the “Avoid invalidation of the package” options 😉

As always you can find a full example on my github repository.

Update 2020-04-08: Added disclaimer about the example purposefully not being well-testable.
Update 2020-04-08: Added Option 4: Use Functions.
Update 2020-04-08: Added (Not An) Option 5: DBMS_SESSION.RESET_PACKAGE


0 Comments

Leave a Reply

Your email address will not be published. Required fields are marked *