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