1package OpenXPKI::Server::Watchdog;
2use Moose;
3
4
5=head1 NAME
6
7The watchdog thread
8
9=head1 DESCRIPTION
10
11The watchdog is forked away on startup and takes care of paused workflows.
12The system has a default configuration but you can override it via the system
13configuration.
14
15The namespace is I<system.watchdog>. The properties are:
16
17=over
18
19=item max_fork_redo
20
21Retry this often to fork away the initial watchdog process before
22failing finally.
23default: 5
24
25=item max_exception_threshhold
26
27There are situations (database locks, no free resources) where a watchdog
28can not fork away a new worker. After I<max_exception_threshhold> errors
29occured, we kill the watchdog. B<This is a fatal error that must be handled!>
30default: 10
31
32=item interval_sleep_exception
33
34The number of seconds to sleep after the watchdog ran into an exception.
35default: 60
36
37=item interval_sleep_overload
38
39The number of seconds to sleep after the watchdog ran into an exception.
40default: 15
41
42=item max_tries_hanging_workflows
43
44Try to restarted stale workflows this often before failing them.
45default:  3
46
47=item max_instance_count
48
49Allow multiple watchdogs in parallel. This controls the number of control
50process, setting this to more than one is usually not necessary (and also
51not wise).
52
53default: 1
54
55=item max_worker_count
56
57Maximum number of workers that the watchdog can run in parallel. No new
58workflows are woke up if this limit is reached and the watchdog will
59sleep for I<interval_sleep_overload> seconds.
60
61default: 50
62
63=item interval_wait_initial
64
65Seconds to wait after server start before the watchdog starts scanning.
66default: 10;
67
68=item interval_loop_idle
69
70Seconds between two scan runs if no result was found on last run.
71default: 5
72
73=item interval_loop_run
74
75Seconds between two scan runs if a result was found on last run.
76default: 1
77
78=back
79
80=cut
81
82# Core modules
83use English;
84use Data::Dumper;
85use POSIX;
86
87# CPAN modules
88use Log::Log4perl::MDC;
89use Try::Tiny;
90
91# Project modules
92use OpenXPKI::Debug;
93use OpenXPKI::Exception;
94use OpenXPKI::Control;
95use OpenXPKI::Server;
96use OpenXPKI::Server::Session;
97use OpenXPKI::Server::Context qw( CTX );
98use OpenXPKI::DateTime;
99use OpenXPKI::Daemonize;
100
101
102our $TERMINATE = 0;
103our $RELOAD = 0;
104
105
106has max_fork_redo => (
107    is => 'rw',
108    isa => 'Int',
109    default =>  5
110);
111has max_exception_threshhold => (
112    is => 'rw',
113    isa => 'Int',
114    default =>  10
115);
116
117has interval_sleep_exception => (
118    is => 'rw',
119    isa => 'Int',
120    default =>  60
121);
122
123has interval_sleep_overload => (
124    is => 'rw',
125    isa => 'Int',
126    default =>  15
127);
128
129has max_tries_hanging_workflows => (
130    is => 'rw',
131    isa => 'Int',
132    default =>  3
133);
134
135has max_instance_count => (
136    is => 'rw',
137    isa => 'Int',
138    default =>  1
139);
140
141has max_worker_count => (
142    is => 'rw',
143    isa => 'Int',
144    default =>  50
145);
146# All timers in seconds
147has interval_wait_initial => (
148    is => 'rw',
149    isa => 'Int',
150    default =>  10
151);
152
153has interval_loop_idle => (
154    is => 'rw',
155    isa => 'Int',
156    default =>  5
157);
158
159has interval_loop_run => (
160    is => 'rw',
161    isa => 'Int',
162    default =>  1
163);
164
165has interval_session_purge => (
166    is => 'rw',
167    isa => 'Int',
168    default => 0
169);
170
171has interval_auto_archiving => (
172    is => 'rw',
173    isa => 'Int',
174    default => 0,
175);
176
177has _uid => (
178    is => 'ro',
179    isa => 'Str',
180    default => '0',
181);
182
183has _gid => (
184    is => 'ro',
185    isa => 'Str',
186    default => '0',
187);
188
189### TODO: maybe we should measure the count of exception in a certain time interval?
190has _exception_count => (
191    is => 'rw',
192    isa => 'Int',
193    init_arg => undef,
194    default => 0,
195);
196
197has _next_session_cleanup => (
198    is => 'rw',
199    isa => 'Int',
200    init_arg => undef,
201);
202
203has _next_auto_archiving => (
204    is => 'rw',
205    isa => 'Int',
206    init_arg => undef,
207);
208
209has _session_purge_handler => (
210    is => 'rw',
211    isa => 'OpenXPKI::Server::Session',
212    init_arg => undef,
213    predicate => 'do_session_purge',
214);
215
216
217
218around BUILDARGS => sub {
219
220    my $orig = shift;
221    my $class = shift;
222
223    # Holds user and group id
224    my $args = shift;
225
226    my $config = CTX('config')->get_hash('system.watchdog');
227
228    $config = {} unless($config); # Moose complains on null
229
230    # Add uid/gid
231    $config->{_uid} = $args->{user}  if( $args->{user} );
232    $config->{_gid} = $args->{group} if( $args->{group} );
233
234    # This automagically sets all entries from the config
235    # to the corresponding class attributes
236    return $class->$orig($config);
237
238};
239
240=head1 STATIC METHODS
241
242=head2 _sig_hup
243
244Signal handler for SIGHUP registered with the forked worker process.
245
246Triggered by the master process when a reload happens.
247
248=cut
249sub _sig_hup {
250    ##! 1: 'Got HUP'
251    $RELOAD = 1;
252    CTX('log')->system->info("Watchdog worker $$ got HUP signal - reloading config");
253}
254
255=head2 _sig_term
256
257Signal handler for SIGTERM registered with the forked worker process.
258
259Trigger by the master process to terminate the worker.
260
261=cut
262sub _sig_term {
263    ##! 1: 'Got TERM'
264    $TERMINATE  = 1;
265    CTX('log')->system->info("Watchdog worker $$ got TERM signal - stopping");
266}
267
268=head2 start_or_reload
269
270Static method to instantiate and start the watchdog or make it reload it's
271config.
272
273=cut
274sub start_or_reload {
275    ##! 1: 'start_or_reload'
276    my $pids = OpenXPKI::Control::get_pids();
277
278    # Start watchdog if not running
279    if (not scalar @{$pids->{watchdog}}) {
280        my $config = CTX('config');
281
282        return 0 if $config->get('system.watchdog.disabled');
283
284        my $watchdog = OpenXPKI::Server::Watchdog->new( {
285            user  => OpenXPKI::Server::__get_numerical_user_id(  $config->get('system.server.user') ),
286            group => OpenXPKI::Server::__get_numerical_group_id( $config->get('system.server.group') ),
287        } );
288
289        $watchdog->run;
290    }
291    # Signal reload
292    else {
293        kill 'HUP', @{$pids->{watchdog}};
294    }
295
296    return 1;
297}
298
299=head2 terminate
300
301Static method that looks for watchdog instances and sends them a SIGHUP signal.
302
303This will NOT kill the watchdog but tell it to gracefully stop.
304
305=cut
306sub terminate {
307    ##! 1: 'terminate'
308    my $pids = OpenXPKI::Control::get_pids();
309
310    if (scalar $pids->{watchdog}) {
311        kill 'TERM', @{$pids->{watchdog}};
312        CTX('log')->system()->info('Told watchdog to terminate');
313    }
314    else {
315        CTX('log')->system()->error('No watchdog instances to terminate');
316    }
317
318    return 1;
319}
320
321=head1 METHODS
322
323=head2 run
324
325Forks away a worker child, returns the pid of the worker
326
327=cut
328
329sub run {
330    my $self = shift;
331
332    CTX('log')->system->info('Starting watchdog');
333
334    # Check if we already have a watchdog running
335    my $result = OpenXPKI::Control::get_pids();
336    my $instance_count = scalar @{$result->{watchdog}};
337    if ($instance_count >= $self->max_instance_count()) {
338        OpenXPKI::Exception->throw(
339            message => 'I18N_OPENXPKI_WATCHDOG_RUN_TOO_MANY_INSTANCES',
340            params => {
341                'instance_running' => $instance_count,
342                'max_instance_count' =>  $self->max_instance_count()
343            },
344            log => { priority => 'error', facility => 'system' }
345        );
346    }
347
348    my $fork_helper = OpenXPKI::Daemonize->new(
349        sighup_handler  => \&OpenXPKI::Server::Watchdog::_sig_hup,
350        sigterm_handler => \&OpenXPKI::Server::Watchdog::_sig_term,
351    );
352    $fork_helper->gid($self->_gid) if $self->_gid;
353    $fork_helper->uid($self->_uid) if $self->_uid;
354
355    # FORK
356    my $pid = $fork_helper->fork_child; # parent returns PID, child returns 0
357
358    # parent process: return
359    if ($pid > 0) { return $pid }
360
361    # child process
362    ##! 16: 'child here'
363    try {
364        #
365        # init
366        #
367        # create memory-only session for workflow
368        my $session = OpenXPKI::Server::Session->new(type => "Memory")->create;
369        OpenXPKI::Server::Context::setcontext({ session => $session, force => 1 });
370        Log::Log4perl::MDC->put('sid', substr(CTX('session')->id,0,4));
371
372        $self->{dbi}                      = CTX('dbi');
373        $self->{hanging_workflows}        = {};
374        $self->{hanging_workflows_warned} = {};
375        $self->{original_pid}             = $PID;
376
377        # set process name
378        OpenXPKI::Server::__set_process_name("watchdog: init");
379
380        CTX('log')->system()->info(sprintf( 'Watchdog initialized, delays are: initial: %01d, idle: %01d, run: %01d',
381                $self->interval_wait_initial(), $self->interval_loop_idle(), $self->interval_loop_run() ));
382
383        # wait some time for server startup...
384        ##! 16: sprintf('watchdog: original PID %d, initially waiting for %d seconds', $self->{original_pid} , $self->interval_wait_initial());
385        sleep($self->interval_wait_initial());
386
387        $self->_exception_count(0);
388
389        # setup helper object for purging expired sessions
390        if ($self->interval_session_purge) {
391            $self->_next_session_cleanup( time );
392            $self->_session_purge_handler( OpenXPKI::Server::Session->new(load_config => 1) );
393            CTX('log')->system->info("Initialize session purge from watchdog with interval " . $self->interval_session_purge);
394        }
395
396        if ($self->interval_auto_archiving) {
397            $self->_next_auto_archiving( time );
398            CTX('log')->system->info("Initialize auto-archiving from watchdog with interval " . $self->interval_auto_archiving);
399        }
400
401        #
402        # main loop
403        #
404        $self->__main_loop;
405    }
406    catch {
407        # make sure the cleanup code does not die as this would escape run()
408        eval { CTX('log')->system->error($_) };
409    };
410
411    eval { $self->{dbi}->disconnect };
412    CTX('config')->cleanup();
413    ##! 1: 'End of run()'
414    exit;   # child process MUST never leave run()
415
416}
417
418=head2 __main_loop
419
420Watchdog main loop (child process).
421
422Runs until the package scope variable C<$TERMINATE> is set to C<1>.
423
424=cut
425sub __main_loop {
426    my $self = shift;
427
428    my $slots_avail_count = $self->max_worker_count();
429    while (not $TERMINATE) {
430        ##! 64: 'watchdog: do loop'
431        try {
432            $self->__reload if $RELOAD;
433            $self->__purge_expired_sessions;
434            $self->__auto_archive_workflows;
435
436            # if slots_avail_count is zero, do a recalculation
437            if (!$slots_avail_count) {
438                ##! 8: 'no slots available - doing recalculation'
439                $slots_avail_count = $self->max_worker_count();
440                my $pt = Proc::ProcessTable->new;
441                foreach my $ps (@{$pt->table}) {
442                    if ($ps->ppid == $$) {
443                        ##! 32: 'Found watchdog child '.$ps->pid.' - remaining process count: ' . $slots_avail_count
444                        last unless ($slots_avail_count--);
445                    }
446                }
447            }
448
449            ##! 32: 'available slots: ' . $slots_avail_count
450
451            # duration of pause depends on whether a workflow was found or not
452            my $sec = $self->interval_loop_idle;
453            if (!$slots_avail_count) {
454                ##! 16: 'watchdog paused - too much load'
455                $sec = $self->interval_sleep_overload;
456                OpenXPKI::Server::__set_process_name("watchdog (OVERLOAD)");
457                CTX('log')->system->warn(sprintf "Watchdog process limit (%01d) reached, will sleep for %01d seconds", $self->max_worker_count(), $sec );
458            } elsif (my $wf_id = $self->__scan_for_paused_workflows()) {
459                ##! 32: 'watchdog busy - forked child for wf ' . $wf_id
460                $sec = $self->interval_loop_run;
461                $slots_avail_count--;
462                OpenXPKI::Server::__set_process_name("watchdog (busy)");
463            } else {
464                ##! 32: 'watchdog idle'
465                OpenXPKI::Server::__set_process_name("watchdog (idle)");
466            }
467            ##! 64: sprintf('watchdog sleeps %d secs', $sec)
468
469            sleep($sec);
470            # Reset the exception counter after every successfull loop
471            $self->_exception_count(0);
472
473        }
474        catch {
475            $self->_exception_count($self->_exception_count + 1);
476
477            my $error_msg = "Watchdog fatal error: $_";
478            my $sleep = $self->interval_sleep_exception();
479
480            print STDERR $error_msg, "\n";
481
482            CTX('log')->system->error("$error_msg (having a nap for $sleep sec; ".$self->_exception_count." exceptions in a row)");
483
484            my $threshold = $self->max_exception_threshhold();
485            if ($threshold > 0 and $self->_exception_count > $threshold) {
486                my $msg = "Watchdog exception limit ($threshold) reached, exiting!";
487                print STDERR $msg, "\n";
488                OpenXPKI::Exception->throw(
489                    message => $msg,
490                    log => { priority => 'fatal', facility => 'system' },
491                );
492            }
493
494            # sleep to give the system a chance to recover
495            sleep($sleep);
496        };
497    }
498}
499
500=head2 __purge_expired_sessions
501
502Purge expired sessions from backend if enough time elapsed.
503
504=cut
505sub __purge_expired_sessions {
506    my $self = shift;
507
508    return unless $self->do_session_purge and time > $self->_next_session_cleanup;
509
510    CTX('log')->system()->debug("Init session purge from watchdog");
511    $self->_session_purge_handler->purge_expired;
512    $self->_next_session_cleanup( time + $self->interval_session_purge );
513}
514
515# Does the actual reloading during the main loop
516sub __reload {
517    my $self = shift;
518
519    $RELOAD = 0;
520
521    my $config = CTX('config');
522
523    my $new_cfg = $config->get_hash('system.watchdog');
524
525    # set the config values from new head
526    for my $key (qw(
527        max_fork_redo
528        max_exception_threshhold
529        interval_sleep_exception
530        max_tries_hanging_workflows
531        interval_wait_initial
532        interval_loop_idle
533        interval_loop_run
534        interval_session_purge
535        interval_auto_archiving
536    )) {
537        if ($new_cfg->{$key}) {
538            ##! 16: 'Update key ' . $key
539            $self->$key( $new_cfg->{$key} )
540        }
541    }
542
543    # Re-Init the Notification backend
544    OpenXPKI::Server::Context::setcontext({
545        notification => OpenXPKI::Server::Notification::Handler->new(),
546        force => 1,
547    });
548
549    CTX('log')->system()->info('Watchdog worker reloaded');
550}
551
552=head2 __scan_for_paused_workflows
553
554Do a select on the database to check for waiting or stale workflows,
555if found, the workflow is marked and reinstantiated, the id of the
556workflow is returned. Returns undef, if nothing is found.
557
558=cut
559sub __scan_for_paused_workflows {
560    my $self = shift;
561    ##! 1: 'start'
562
563    # Search table for paused workflows that are ready to wake up
564    # There is no ordering here, so we might not get the earliest hit
565    # This is useful in distributed environments to prevent locks/races
566    my $workflow = $self->{dbi}->select_one(
567        from  => 'workflow',
568        columns => [ qw(
569            workflow_id
570            workflow_type
571            workflow_session
572            pki_realm
573        ) ],
574        where => {
575            'workflow_proc_state' => 'pause',
576            'watchdog_key'        => '__CATCHME',
577            'workflow_wakeup_at'  => { '<', time() },
578        },
579    );
580
581    if ( !defined $workflow ) {
582        ##! 64: 'no paused WF found, can be idle again...'
583        return;
584    }
585
586    ##! 16: 'found paused workflow: '.Dumper($workflow)
587
588    #select again:
589    my $wf_id = $workflow->{workflow_id};
590    $self->__flag_for_wakeup( $wf_id ) or return;
591
592    ##! 16: 'WF now ready to re-instantiate '
593    CTX('log')->workflow()->info(sprintf( 'watchdog, paused wf %d now ready to re-instantiate, start fork process', $wf_id ));
594
595
596    $self->__wake_up_workflow({
597        workflow_id         => $workflow->{workflow_id},
598        workflow_type       => $workflow->{workflow_type},
599        workflow_session    => $workflow->{workflow_session},
600        pki_realm           => $workflow->{pki_realm},
601    });
602
603    return $wf_id;
604}
605
606=head2 __flag_for_wakeup( wf_id )
607
608Flag the workflow with the given ID as "being woken up" via database.
609
610To prevent a workflow from being reloaded by two watchdog instances, this
611method first writes a random marker to create "row lock" and tries to reload
612the row using this marker. If either one fails, returnes undef.
613
614=cut
615
616sub __flag_for_wakeup {
617    my ($self, $wf_id) = @_;
618
619    return unless $wf_id;    #this is real defensive programming ...;-)
620
621    #FIXME: Might add some more entropy or the server id for cluster oepration
622    my $rand_key = sprintf( '%s_%s_%s', $PID, time(), sprintf( '%02.d', rand(100) ) );
623
624    ##! 16: 'set random key '.$rand_key
625
626    CTX('log')->workflow()->debug(sprintf( 'watchdog: paused wf %d found, mark with flag "%s"', $wf_id, $rand_key ));
627
628
629    $self->{dbi}->start_txn;
630
631    # it is necessary to explicitely set WORKFLOW_LAST_UPDATE,
632    # because otherwise ON UPDATE CURRENT_TIMESTAMP will set (maybe) a non UTC timestamp
633
634    # watchdog key will be reset automatically, when the workflow is updated from within
635    # the API (via factory::save_workflow()), which happens immediately, when the action is executed
636    # (see OpenXPKI::Server::Workflow::Persister::DBI::update_workflow())
637    my $row_count;
638    eval {
639        $row_count = $self->{dbi}->update(
640            table => 'workflow',
641            set => {
642                watchdog_key => $rand_key,
643                workflow_last_update => DateTime->now->strftime( '%Y-%m-%d %H:%M:%S' ),
644            },
645            where => {
646                workflow_proc_state => 'pause',
647                watchdog_key        => '__CATCHME',
648                workflow_id         => $wf_id,
649            },
650        );
651        $self->{dbi}->commit;
652    };
653    # We use DB transaction isolation level "READ COMMITTED":
654    # So in the meantime another watchdog process might have picked up this
655    # workflow and changed the database. Two things can happen:
656    # 1. other process committed changes -> our update's where clause misses ($row_count = 0).
657    # 2. other process did not commit -> timeout exception because of DB row lock
658    if ($@ or $row_count < 1) {
659        ##! 16: sprintf('some other process took wf %s, return', $wf_id)
660        $self->{dbi}->rollback;
661        CTX('log')->system()->warn(sprintf( 'watchdog, paused wf %d: update with mark "%s" failed', $wf_id, $rand_key ));
662        return;
663    }
664
665    return $wf_id;
666}
667
668
669=head2 __wake_up_workflow
670
671Restore the session environment and execute the action, runs in eval
672block and returns the error message in case of error.
673
674=cut
675
676sub __wake_up_workflow {
677    my ($self, $args) = @_;
678
679    $self->__restore_session($args->{pki_realm}, $args->{workflow_session});
680
681    $self->{dbi}->start_txn;
682
683    ##! 1: 'call wakeup'
684    my $wf_info = CTX('api2')->wakeup_workflow(
685        id => $args->{workflow_id},
686        async => 1,
687        wait => 0,
688    );
689    ##! 32: 'wakeup returned ' . Dumper $wf_info
690
691    # commit/rollback is done inside workflow engine
692}
693
694sub __restore_session {
695    my ($self, $realm, $frozen_session) = @_;
696
697    CTX('session')->data->pki_realm($realm);     # set realm
698    CTX('session')->data->thaw($frozen_session); # set user and role
699
700    # Set MDC for logging
701    Log::Log4perl::MDC->put('user', CTX('session')->data->user);
702    Log::Log4perl::MDC->put('role', CTX('session')->data->role);
703    Log::Log4perl::MDC->put('sid', substr(CTX('session')->id,0,4));
704}
705
706=head2 __auto_archive_workflows
707
708Archive workflows whose "archive_at" date was exceeded.
709
710=cut
711
712sub __auto_archive_workflows {
713    my $self = shift;
714
715    return unless ($self->interval_auto_archiving and time > $self->_next_auto_archiving);
716
717    CTX('log')->system->debug("Init workflow auto archiving from watchdog");
718
719    # Search for paused workflows that are ready to be archived.
720    my $rows = $self->{dbi}->select_hashes(
721        from  => 'workflow',
722        columns => [ qw(
723            workflow_id
724            workflow_type
725            workflow_session
726            pki_realm
727            workflow_archive_at
728        ) ],
729        where => {
730            'workflow_proc_state' => { '!=', 'archived' },
731            'workflow_archive_at' => { '<', time() },
732        },
733    );
734
735    ##! 16: 'Archiving candidates: ' . join(', ', map { $_->{workflow_id} } @$rows)
736
737    my $id;
738    # Don't crash Watchdog only if some archiving fails
739    try {
740        for my $row (@$rows) {
741            $id = $row->{workflow_id};
742            $self->__flag_for_archiving($row->{workflow_id}, $row->{workflow_archive_at}) or next;
743            $self->__restore_session($row->{pki_realm}, $row->{workflow_session});
744
745            my $workflow = CTX('workflow_factory')->get_factory->fetch_workflow($row->{workflow_type}, $row->{workflow_id});
746            # Archive workflow: does DB update, might throw exception on wrong proc_state etc.
747            # Also sets "archive_at" to undef.
748            $workflow->set_archived;
749        }
750    }
751    catch {
752        CTX('log')->system->error(sprintf('Error archiving wf %s: %s', $id, $_));
753    };
754
755    $self->_next_auto_archiving( time + $self->interval_auto_archiving );
756}
757
758=head2 __flag_for_archiving( wf_id )
759
760Flag the workflow with the given ID as "being archived" via database to prevent
761a workflow from being archived by two watchdog instances.
762
763Flagging is done by updating DB field C<workflow_archive_at> with an
764intermediate value of C<0>. It is updated to C<null> once archiving is
765finished, so a permanent value of C<0> indicates a severe error.
766
767Returns C<1> upon success or C<undef> if workflow is/was archived by another
768process.
769
770=cut
771
772sub __flag_for_archiving {
773    my ($self, $wf_id, $expected_archive_at) = @_;
774
775    return unless ($wf_id and $expected_archive_at);
776
777    CTX('log')->workflow->debug(sprintf('watchdog: auto-archiving wf %d, setting flag', $wf_id));
778
779    $self->{dbi}->start_txn;
780
781    # Flag workflow as "being archived" by setting "workflow_archive_at" to
782    # an intermediate value of "0". It is updated to undef once archiving is
783    # finished, so when checking workflows later on a "0" indicates a
784    # severe error during archiving.
785    my $update_count;
786    try {
787        # "" it to undef later on, so a permanent value of 0 is an indicator
788        $update_count = $self->{dbi}->update(
789            table => 'workflow',
790            set => {
791                workflow_archive_at => 0,
792            },
793            where => {
794                workflow_archive_at => $expected_archive_at,
795                workflow_id         => $wf_id,
796            },
797        );
798        $self->{dbi}->commit;
799    }
800    # We use DB transaction isolation level "READ COMMITTED":
801    # So in the meantime another watchdog process might have picked up this
802    # workflow and changed the database. Two things can happen:
803    # 1. other process did not commit -> timeout exception because of DB row lock
804    catch {
805        $self->{dbi}->rollback;
806        CTX('log')->system->warn(sprintf('watchdog: auto-archiving wf %d failed (most probably other process does same job): %s', $wf_id, $_));
807        return;
808    };
809    # 2. other process committed changes -> our update's where clause misses ($update_count = 0).
810    if ($update_count < 1) {
811        CTX('log')->system->warn(sprintf('watchdog: auto-archiving wf %d failed (already archived by other process)', $wf_id));
812        return;
813    }
814
815    return 1;
816}
817
818no Moose;
819__PACKAGE__->meta->make_immutable;
820
8211;
822__END__
823