Cloudtasker

repository·master·Indexed 41 days ago

https://github.com/keypup-io/cloudtasker

A Ruby background job processing library that uses Google Cloud Tasks as a backend, specifically designed for serverless environments like Cloud Run. It supports ActiveJob (since v0.11.0), local development via Redis, and provides methods for asynchronous, delayed, and immediate job execution including perform_async, perform_in, and perform_now (v0.15.0+).

Tokens
24.7K
Snippets
96
Records
112
Agent score
51%

What's inside Cloudtasker

  1. Handle job failures and retries

    master

    Cloudtasker communicates job status to Google Cloud Tasks via HTTP status codes. If a job returns an error, Cloud Tasks will trigger a retry based on your queue configuration.

    HTTP Status Codes

    • 204: Success.
    • 205: Job is dead (removed from queue).
    • 404: Incorrect worker class specified.
    • 422: Error occurred during worker execution (perform method).

    Worker Callbacks

    Workers can implement specific callbacks to handle lifecycle events:

    • on_error(error): Called when a runtime error occurs during perform.
    • on_dead(error): Called when a job has exhausted all retries and is being removed from the queue.
    class HandleErrorWorker
      include Cloudtasker::Worker
    
      def perform
        raise(ArgumentError)
      end
    
      def on_error(error)
        logger.error("The following error happened: #{error}")
      end
    
      def on_dead(error)
        logger.error("The job died with the following error: #{error}")
      end
    end
  2. Manage Lock TTL to prevent deadlocks

    master

    To prevent deadlocks caused by crashes (where a lock key is never cleaned up), Cloudtasker sets an automatic expiration (TTL) for lock keys in Redis. The lock key is a hash of the class name and job arguments.

    By default, the lock expires at job schedule time + 10 minutes. This 10-minute window is designed to account for typical queue lag and job processing time.

    Recommendation: Adapt the lock_ttl (the duration after scheduling when the lock expires) based on your expected queue lag and job duration. You can configure this globally or per worker.

    Global Configuration

    Cloudtasker::UniqueJob.configure do |config|
      config.lock_ttl = 3 * 60 # 3 minutes
    end

    Worker-level Configuration

    Use the lock_ttl option within cloudtasker_options to specify the duration in seconds after the job is scheduled.

    # Worker-level - fast queue
    class RealtimeWorkerOnFastQueue
      include Cloudtasker::Worker
      # Lock removed 30 seconds after schedule time
      cloudtasker_options lock: :until_executing, lock_ttl: 30
    
      def perform(arg1, arg2)
        # ...
      end
    end
    
    # Worker-level - slow queue
    class NonCriticalWorkerOnSlowQueue
      include Cloudtasker::Worker
      # Lock removed 24 hours after schedule time
      cloudtasker_options lock: :until_executing, lock_ttl: 3600 * 24
    
      def perform(arg1, arg2)
        # ...
      end
    end
  3. Workaround for GCP Cloud Tasks scheduling limitations

    master

    GCP Cloud Tasks does not allow tasks to be scheduled more than 30 days in the future. If you need to run jobs on a monthly or quarterly basis, schedule the job to run daily and implement logic within the worker to check if the current date matches your target interval.

    Monthly Job Example (Runs every day at 8am, but only executes logic on the 1st):

    # Cron schedule: 0 8 * * *
    class MyMonthlyWorker
      include Cloudtasker::Worker
    
      def perform(*args)
        return unless Time.current.day == 1
        # ... job logic
      end
    end

    Quarterly Job Example (Runs every day at 8am, but only executes logic on the first month of a quarter):

    # Cron schedule: 0 8 * * *
    class MyQuarterlyWorker
      include Cloudtasker::Worker
    
      def perform(*args)
        return unless Time.current.month == 1 # Jan, Apr, Jul, Oct
        return unless Time.current.day == 1
        # ... job logic
      end
    end
  4. Understand the difference between job_id and task_id

    master

    Cloudtasker uses two distinct identifiers to help you trace jobs through logs and Google Cloud Tasks:

    FieldDefinition
    job_idGenerated by Cloudtasker. Identifies the job across its entire lifecycle, including retries and reschedules.
    task_idGenerated by Google Cloud Tasks. Identifies a specific job instance. It is persistent across retries but NOT across reschedules.

    Workflow for debugging:

    1. Find a specific task in the GCP Console using its Task name (task_id).
    2. Search your logs for that task_id to find the associated job_id.
    3. Use the job_id to search for all logs related to that job's entire history (including previous attempts/reschedules).
  5. How Batch Jobs work

    master

    Cloudtasker Batch Jobs allow you to add sub-jobs to regular jobs, enabling you to enqueue a list of jobs and track their overall progression as a group (a "batch").

    Key behaviors:

    • Inheritance: Jobs added to a batch inherit the queue of the parent by default. You can override this using batch.add_to_queue.
    • Completion: A batch is considered complete when all children have either successfully completed or died (exhausted all retries).
    • Retries: If a child job fails, it will be retried based on your max_retries configuration. The batch remains in a pending state while retries are occurring. To prevent batches from hanging, consider reducing retries using cloudtasker_options max_retries: N on your workers.
  6. Size queue concurrency to prevent resource exhaustion

    master

    When setting max_concurrent_dispatches in Cloud Tasks, you must ensure the total concurrency does not exceed your application's available resources (threads or database connections).

    Server-based applications

    Calculate your total available threads (e.g., number_of_instances * RAILS_MAX_THREADS). Ensure the sum of the concurrency of all your queues (e.g., default + critical) is less than your total threads to leave capacity for web users. Always ensure total concurrency does not exceed your database connection pool size.

    Serverless applications

    In serverless environments, scaling is driven by traffic (including Cloud Task requests). Since capacity is theoretically unlimited, your primary limiting factor is usually the database connection pool size. Use this limit as the capping parameter for your queue concurrency calculations.

  7. How Cloudtasker Storable Jobs work

    master

    The Storable Jobs extension (available since v0.14.0) allows you to 'park' jobs in a specific storage lane (a store_name) and 'pull' them into the active queue later when specific programmatic conditions are met.

    This is a more efficient alternative to using guard logic inside a perform method (which re-enqueues jobs repeatedly and pollutes logs). Instead, you store the job arguments in Redis and only trigger the actual enqueuing when you are ready to process them.

  8. Propagate queues to child workers

    master

    Since v0.15.0, you can use propagate_queue: true in cloudtasker_options to ensure that any jobs enqueued by a worker inherit the queue used by the parent job, rather than falling back to the child worker's default queue.

    Behavior Rules:

    1. With propagate_queue: true: If a parent job runs on queue_a, any child jobs enqueued via .perform_async or .perform_later will also run on queue_a (even if the child class defines a different default queue).
    2. Explicit Override: If you explicitly pass a queue: option during .schedule, it overrides the propagation behavior.
    3. Without Propagation: If propagate_queue is not set to true, child jobs will use the queue defined in their own class cloudtasker_options or the default queue.
    # app/workers/parent_worker.rb
    class ParentWorker
      include Cloudtasker::Worker
      # propagate_queue: true makes children use the parent's runtime queue
      cloudtasker_options queue: :level1, propagate_queue: true
    
      def perform(some_arg)
        # Runs on 'level1' (parent's queue)
        ChildWorker.perform_async(some_arg)
    
        # Runs on 'level3' (explicit override wins)
        ChildWorker.schedule(queue: 'level3', args: [some_arg])
      end
    end
  9. Best practices for building workers

    master

    When creating workers with Cloudtasker::Worker, follow these guidelines to ensure reliable job execution and serialization:

    1. Use primitive arguments

    Cloudtasker serializes all arguments as JSON. Do not pass user-defined class instances (e.g., ActiveRecord models) as arguments, as they will fail to deserialize. Instead, pass primitive types like integers, strings, or hashes and use them to look up records inside the perform method.

    2. Assume hash arguments are stringified

    Because of JSON serialization, any hashes passed to perform_* methods will arrive at the worker with string keys, even if you passed symbol keys during enqueuing.

    3. Be careful with default arguments

    Default arguments in the perform method signature are not considered job arguments. This means they are ignored by contextual logging and extensions like unique-job (which uses arguments to determine job uniqueness). If you need an argument to affect uniqueness or logging, pass it explicitly during enqueuing.

    # DO THIS: Pass primitives
    class UserEmailWorker
      include Cloudtasker::Worker
    
      def perform(user_id)
        User.find_by(id: user_id)&.send_email
      end
    end
    
    # DON'T DO THIS: Passing objects
    class UserEmailWorker
      include Cloudtasker::Worker
    
      def perform(user)
        user.reload.send_email
      end
    end
  10. Configure cron jobs with Puma Cluster-mode

    master

    When using Puma in cluster-mode with preload_app!, scheduling cron jobs (which triggers gRPC calls) should be delayed until after the Puma process has booted to avoid issues with forking and gRPC. Use the on_booted block in config/puma.rb to load your schedules.

    # config/puma.rb
    
    workers ENV.fetch("WEB_CONCURRENCY") { 2 }
    preload_app!
    
    on_booted do
      schedule_file = "config/cloudtasker_cron.yml"
      if File.exist?(schedule_file) && !Rails.env.test?
        Cloudtasker::Cron::Schedule.load_from_hash!(YAML.load_file(schedule_file))
      end
    end
  11. Work locally with the Cloudtasker local server

    master

    To avoid needing a public domain for local development, you can use the Cloudtasker local server. This is a Ruby daemon that mimics Google Cloud Tasks by reading jobs from Redis and sending them to your application via HTTP POST requests.

    Setup

    1. Set config.mode = :development in your Cloudtasker initializer.
    2. Start the server using bundle exec cloudtasker or via a Procfile using foreman.

    Example Procfile

    web: bundle exec rails s
    worker: bundle exec cloudtasker
    # config/initializers/cloudtasker.rb
    Cloudtasker.configure do |config|
      # Use Redis-based local processing
      config.mode = :development
    end
  12. Work locally using ngrok

    master

    To test your application end-to-end with actual Google Cloud Tasks, use ngrok to create a public tunnel to your local machine.

    Setup

    1. Start an ngrok tunnel: ngrok http 3000.
    2. Configure Cloudtasker in your initializer to use :production mode, specify your GCP credentials, and set config.processor_host to your ngrok URL.
    3. Start your Rails server.
    # config/initializers/cloudtasker.rb
    Cloudtasker.configure do |config|
      config.gcp_location_id = 'us-central1'
      config.gcp_project_id = 'my-gcp-project'
      config.gcp_queue_prefix = 'my-app'
    
      # Use your ngrok domain as the processor host
      config.processor_host = 'https://your-tunnel-id.ngrok.io'
    
      # Force Cloudtasker to use Google Cloud Tasks in development
      config.mode = :production
    end