Skip to content

Part 5: Background Workers

Add a scheduled background worker using Spring Task and Redis for asynchronous processing alongside your web services.

What you'll learn

  • Running a portless worker service in AppMecca
  • Using Spring's @Scheduled tasks with conditional activation
  • Enabling AppMecca's managed Redis cache
  • Managing worker lifecycle with service commands

What you'll need

  • A full-stack deployment from Part 4

Add Redis dependencies

// backend/build.gradle (add to existing dependencies)
dependencies {
    // ... existing deps
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

Create a task publisher

Define a TodoTask record and a publisher that pushes tasks to a Redis list:

// backend/src/main/java/com/example/todo/worker/TaskPublisher.java
package com.example.todo.worker;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class TaskPublisher {

    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;

    public TaskPublisher(StringRedisTemplate redis, ObjectMapper mapper) {
        this.redis = redis;
        this.mapper = mapper;
    }

    public void publish(TodoTask task) {
        try {
            String json = mapper.writeValueAsString(task);
            redis.opsForList().rightPush("todo:tasks", json);
        } catch (Exception e) {
            throw new RuntimeException("Failed to publish task", e);
        }
    }
}

Create the scheduled processor

This component only activates when the worker.enabled property is true.

// backend/src/main/java/com/example/todo/worker/TaskProcessor.java
package com.example.todo.worker;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@ConditionalOnProperty(name = "worker.enabled", havingValue = "true")
public class TaskProcessor {

    private static final Logger log = LoggerFactory.getLogger(TaskProcessor.class);
    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;

    public TaskProcessor(StringRedisTemplate redis, ObjectMapper mapper) {
        this.redis = redis;
        this.mapper = mapper;
    }

    @Scheduled(fixedDelay = 1000)
    public void processTasks() {
        String json = redis.opsForList().leftPop("todo:tasks");
        if (json == null) return;

        try {
            TodoTask task = mapper.readValue(json, TodoTask.class);
            log.info("Processing task: type={}, todoId={}", task.type(), task.todoId());

            switch (task.type()) {
                case "notify" -> log.info("Sending notification for todo {}", task.todoId());
                case "audit"  -> log.info("Auditing change for todo {}", task.todoId());
                default       -> log.warn("Unknown task type: {}", task.type());
            }
        } catch (Exception e) {
            log.error("Failed to process task: {}", e.getMessage());
        }
    }
}

ConditionalOnProperty

The @ConditionalOnProperty annotation ensures the scheduled task processor only starts when worker.enabled=true. The backend service runs without it, and the worker container sets it via an environment variable.

Enable scheduling

// backend/src/main/java/com/example/todo/TodoApplication.java
package com.example.todo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class TodoApplication {
    public static void main(String[] args) {
        SpringApplication.run(TodoApplication.class, args);
    }
}

Configure Redis connection

# backend/src/main/resources/application.properties (add to existing)
spring.data.redis.host=${REDIS_HOST:localhost}
spring.data.redis.port=${REDIS_PORT:6379}
worker.enabled=${WORKER_ENABLED:false}

REDIS_HOST injection

AppMecca injects REDIS_HOST and REDIS_PORT automatically when cache: enabled: true is set in appmecca.yaml.

Update appmecca.yaml

Add the worker service and enable Redis:

database:
  enabled: true

redis:
  enabled: true

containers:
  - name: backend
    dockerfile: backend/Dockerfile
    context: backend/

  - name: frontend
    dockerfile: frontend/Dockerfile
    context: frontend/

deploy:
  - name: backend
    container: backend
    port: 8080
    path: /api
    health_check:
      path: /health

  - name: frontend
    container: frontend
    type: spa
    command: 'nginx -g "daemon off;"'   # serves the built bundle from a container
    spa_root: /usr/share/nginx/html
    port: 3000
    health_check:
      path: /
    environment:
      PUBLIC_API_URL: "${service:backend}"

  - name: worker
    container: backend
    command: "java -jar app.jar --worker.enabled=true"
    resources:
      memory: 512m       # JVM floor — unit suffix is mandatory

Portless services

The worker service has no port field. AppMecca deploys it with no public hostname, no route on the platform HTTPS router, and no health check. It runs as a background process that communicates through Redis and the database.

Top-level cache: is the managed Redis

The cache: enabled: true block at the top level of appmecca.yaml turns on the managed Redis cache and injects REDIS_URL into every container. It defaults to off.

Do not confuse it with the per-service deploy[].cache: block, which configures edge caching for a web service and is an entirely different feature.

JVM services need explicit memory

A command: that starts with java or names a .jar triggers a validation warning if resources.memory is below 512m — JVM runtimes routinely get killed during startup at the platform's 128 MB default. The unit suffix is required: a bare 512 means 512 bytes, and 512mb is not a recognised suffix.

Publish tasks from the controller

Update TodoController to push tasks when todos are created:

@PostMapping("/api/todos")
public Todo create(@RequestBody Todo todo) {
    Todo saved = repository.save(todo);
    taskPublisher.publish(new TodoTask("notify", saved.getId(), saved.getTitle()));
    return saved;
}

Build and deploy

mecca build trigger -g git@github.com:yourorg/todo-app.git
mecca app deploy todo-app

Monitor the worker

Read the worker's container output to see tasks being processed. The service is selected with --service:

mecca logs todo-app --type container --service worker
mecca logs todo-app --type container --service worker --since 5m

Create a todo through the frontend and watch the worker log pick it up.

Worker lifecycle commands

These take the app name first and the service name second, both as positional arguments:

mecca app restart todo-app worker --force   # Restart after config change
mecca app stop todo-app worker --force      # Stop temporarily
mecca app start todo-app worker             # Start again

Stopping vs undeploying

mecca app stop halts a single service. mecca app undeploy removes the whole deployment. Use app stop when you want to pause the worker without affecting the frontend and backend.

Command reference

Command Purpose
mecca app restart <app> <service> --force Restart a service
mecca app stop <app> <service> --force Stop a service
mecca app start <app> <service> Start a stopped service
mecca logs <app> --type container --service <svc> Read a service's output

What's next

In Part 6: Branch Deployments, you will deploy a feature branch side-by-side with your main deployment to test changes before merging.