#!/usr/bin/perl # # DW::TaskQueue # # Library for queueing and executing jobs. # # Authors: # Mark Smith # # Copyright (c) 2019-2020 by Dreamwidth Studios, LLC. # # This program is free software; you may redistribute it and/or modify it under # the same terms as Perl tself. For a copy of the license, please reference # 'perldoc perlartistic' or 'perldoc perlgpl'. # package DW::TaskQueue; use strict; use v5.10; use Log::Log4perl; my $log = Log::Log4perl->get_logger(__PACKAGE__); use DW::TaskQueue::Dedup; use DW::TaskQueue::SQS; use DW::TaskQueue::LocalDisk; my $_queue; sub get { my $class = $_[0]; return $_queue if defined $_queue; $_queue = DW::TaskQueue::LocalDisk->init(); # Determine what kind of queue object to build, depending on if we're # running locally or not if (exists $LJ::SQS{endpoint}) { return $_queue = DW::TaskQueue::SQS->init(%LJ::SQS); } # If we're a dev server, allow the local mode (not allowed in production, # it's really crappy) if ($LJ::IS_DEV_SERVER == 0) { return $_queue = DW::TaskQueue::LocalDisk->init(); } $log->logcroak('Unable to instantiate any DW::TaskQueue modules.'); } #sub get { #$_queue = DW::TaskQueue::LocalDisk->init(); #my $class = $_[0]; #return $_queue if defined $_queue; # Determine what kind of queue object to build, depending on if we're # running locally or not #$_queue = DW::TaskQueue::LocalDisk->init(); # Determine what kind of queue object to build, depending on if we're # running locally or not #if ( exists $LJ::SQS{region} ) { # return $_queue = DW::TaskQueue::SQS->init(%LJ::SQS); #} # if ($LJ::IS_DEV_SERVER = 0) { # return $_queue = DW::TaskQueue::LocalDisk->init(); # } # $log->logcroak('Unable to instantiate any DW::TaskQueue modules.'); #} sub send { my ( $class, @args ) = @_; $class->get->send(@args); } sub receive { my ( $class, @args ) = @_; $class->get->receive(@args); } sub completed { my ( $class, @args ) = @_; $class->get->completed(@args); } sub dispatch { my ( $self, @tasks ) = @_; return undef unless @tasks; $self = $self->get unless ref $self; # This is a shim function that inspects the tasks being sent and dispatches # them to the appropriate task queueing system. my ( @schwartz_jobs, @tsq_tasks ); foreach my $task (@tasks) { if ( $task->isa('TheSchwartz::Job') ) { push @schwartz_jobs, $task; } elsif ( $task->isa('DW::Task') ) { # Check dedup before enqueuing if ( my $uniqkey = $task->uniqkey ) { my $queue_name = ref $task; my $ttl = $task->dedup_ttl || 3600; unless ( DW::TaskQueue::Dedup->claim_unique( $queue_name, $uniqkey, $ttl ) ) { $log->debug( 'Skipping duplicate task: ' . ref($task) . " key=$uniqkey" ); next; } } push @tsq_tasks, $task; } elsif ( $task->isa('LJ::Event') ) { push @tsq_tasks, $task->fire_task; } else { $log->error( 'Unknown job/task type, dropping: ' . ref($task) ); } } my $rv = 1; # Dispatch to Schwartz if (@schwartz_jobs) { if ( my $sclient = LJ::theschwartz() ) { $log->debug( 'Inserting ' . scalar(@schwartz_jobs) . ' jobs into TheSchwartz.' ); $rv &&= $sclient->insert_jobs(@schwartz_jobs); } else { $log->warn( 'Failed to retrieve TheSchwartz client, dropping ' . scalar(@schwartz_jobs) . ' jobs.' ); } } # Dispatch to TaskQueue, grouping by task type since send() routes # the entire batch to the queue of the first task if (@tsq_tasks) { my %by_type; push @{ $by_type{ ref $_ } }, $_ for @tsq_tasks; for my $type ( keys %by_type ) { my $batch = $by_type{$type}; $log->debug( 'Inserting ' . scalar(@$batch) . " $type tasks into TaskQueue." ); $rv &&= $self->send(@$batch); } } # Returns the "worse" of the return values. If either are falsey, we will # return a false value. return $rv; } sub start_work { my ( $self, $class, %opts ) = @_; $opts{message_timeout_secs} ||= 0; $self = $self->get unless ref $self; eval "use $class;"; $log->logcroak("Failed to load task class $class: $@") if $@; my $start_time = time(); my $messages_done = 0; $log->info( sprintf( '[%s %0.3fs] Worker starting', $class, 0.0 ) ); while (1) { my $recv_start_time = time(); if ( $opts{exit_after_secs} && ( $recv_start_time - $start_time > $opts{exit_after_secs} ) ) { $log->info( sprintf( '[%s] Exiting after %d seconds of work, as requested.', $class, $opts{exit_after_secs} ) ); return; } if ( $opts{exit_after_messages} && ( $messages_done >= $opts{exit_after_messages} ) ) { $log->info( sprintf( '[%s] Exiting after %d messages done, as requested.', $class, $opts{exit_after_messages} ) ); return; } my $messages = $self->receive( $class, 10 ); my $recv_time = time() - $recv_start_time; unless ( @{ $messages || [] } ) { $log->debug( sprintf( '[%s %0.3fs] Receive finished, empty', $class, $recv_time ) ); next; } $log->debug( sprintf( '[%s %0.3fs] Receive finished, %d messages', $class, $recv_time, scalar(@$messages) ) ); my ( @completed, @failed ); my ( $work_start_time, $work_end_time ); foreach my $message_pair (@$messages) { my ( $handle, $message ) = @$message_pair; # Record earliest start time of any coroutine my $local_start_time = time(); $work_start_time = $local_start_time if $local_start_time < $work_start_time || !defined $work_start_time; my ( $res, $abort ); eval { local $SIG{ALRM} = sub { $log->error( sprintf( '[%s] Operation timed out after %d seconds. Exiting worker. Message: %s', $class, $opts{message_timeout_secs}, $handle ) ); $abort = 1; }; alarm $opts{message_timeout_secs}; $res = $message->work($handle); }; alarm 0; die if $@; # Reraise if the work call died. # Clear out MDC so we don't continue to log with whatever the worker might # have put into context Log::Log4perl::MDC->remove; return if $abort; $messages_done++; # Record latest end time of any coroutine my $local_end_time = time(); $work_end_time = $local_end_time if $local_end_time > $work_end_time || !defined $work_end_time; if ( $res == DW::Task::COMPLETED ) { push @completed, $handle; # Release dedup key on successful completion if ( my $uniqkey = $message->uniqkey ) { DW::TaskQueue::Dedup->release_unique( ref($message), $uniqkey ); } } else { # If we've exceeded max retries, give up and mark complete # instead of letting SQS send it to the DLQ if ( $opts{max_retries} && $message->receive_count >= $opts{max_retries} ) { $log->warn( sprintf( '[%s] Message "%s" failed after %d attempts, giving up', $class, $handle, $message->receive_count ) ); push @completed, $handle; } else { $log->warn( sprintf( '[%s] Message "%s" failed', $class, $handle ) ); push @failed, $handle; } # Release dedup key on failure so the task can be # re-dispatched by the next scheduler run if ( my $uniqkey = $message->uniqkey ) { DW::TaskQueue::Dedup->release_unique( ref($message), $uniqkey ); } } } $log->debug( sprintf( '[%s %0.3fs] Processed %d messages (%d failed)', $class, $work_end_time - $work_start_time, scalar(@$messages), scalar(@failed) ) ); next unless @completed; my $complete_start_time = time(); $self->completed( $class, @completed ); my $complete_time = time() - $complete_start_time; $log->debug( sprintf( '[%s %0.3fs] Marked %d messages complete', $class, $complete_time, scalar(@completed) ) ); } } 1;