Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion fr-batch-service/docs/ADR/001-dynamic-job-loading-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,13 @@ CREATE TABLE job_executions_audit (
);
```

`job_executions_audit` has a single writer: `JobExecutionAuditListener`, which
`PluginRegistryService` appends to every job (static and dynamic) right before
`JobRegistry.register(job)`, so every launch path, sync or async, is audited. `beforeJob` inserts
the row with the start time; `afterJob` adds the finish time, duration, and the outcome as the raw
`BatchStatus` name (`COMPLETED`, `FAILED`, `STOPPED`, ...). Jobs without a `job_definitions` row
are not audited, and audit failures never fail the job.

### 3. Dynamic ClassLoader Strategy

**Parent-Last Delegation Model:**
Expand Down Expand Up @@ -1235,7 +1242,8 @@ results. Fixed in `DynamicJobLoaderService.unloadJob()` with try-catch guard. Se
unit tests
- [x] **Audit service + JobExecutionListener** — `AuditEventType` enum (8 lifecycle events),
`AuditEvent` record, `AuditService` with SLF4J structured JSON logging + MDC correlation IDs;
`JobExecutionAuditListener` populates `job_executions_audit` on job start/end; `createdBy`
`JobExecutionAuditListener` populates `job_executions_audit` on job start/end (attached to every
job at registration since #111, the table's only writer); `createdBy`
populated via `SecurityContext` resolver with `"system"` fallback; wired into `JarUploadService`,
`DynamicJobLoaderService`, `JobManagementController`
- [x] **8 Snyk CVEs resolved** — `spring-boot-starter-security` fixed `CVE-2024-38807` (DoS in
Expand Down
10 changes: 10 additions & 0 deletions fr-batch-service/docs/job-plugins/developer-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,16 @@ curl -u admin:admin123 \
| `execution_attempt_number` | Yes | Unique attempt number |
| `description` | No | Non-identifying descriptor |

**Execution audit history**: every launch path above (single or all, sync or async) writes exactly
one `job_executions_audit` row per job execution. The only writer is `JobExecutionAuditListener`,
which the platform attaches to every plugin job at registration time (alongside any listeners the
plugin attached itself). The row records the start time when the job starts, then the finish time,
duration in milliseconds, and the outcome, which is the execution's Spring Batch `BatchStatus` name
(`COMPLETED`, `FAILED`, `STOPPED`, ...). Jobs without a `job_definitions` row (for example,
classpath-only jobs) are not audited. Audit write failures are logged and never fail the job. A
plugin whose `configureJob(...)` returns a `Job` that is not a Spring Batch `AbstractJob` still
registers, but its executions are not audited and a `WARN` is logged.

______________________________________________________________________

## JAR Signing Configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,10 @@

import com.fronzec.api.BatchJobPlugin;
import com.fronzec.frbatchservice.batchjobs.plugins.PluginRegistryService;
import com.fronzec.frbatchservice.batchjobs.plugins.entity.JobDefinitionEntity;
import com.fronzec.frbatchservice.batchjobs.plugins.entity.JobExecutionAuditEntity;
import com.fronzec.frbatchservice.batchjobs.plugins.metrics.PluginMetrics;
import com.fronzec.frbatchservice.batchjobs.plugins.repository.JobDefinitionRepository;
import com.fronzec.frbatchservice.batchjobs.plugins.repository.JobExecutionAuditRepository;
import com.fronzec.frbatchservice.utils.JsonUtils;
import com.fronzec.frbatchservice.web.SingleJobDataRequest;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
Expand Down Expand Up @@ -44,26 +38,20 @@ public class JobsManagerService {
private final PluginRegistryService pluginRegistryService;
private final JobRepository jobRepository;
private final PluginMetrics pluginMetrics;
private final JobDefinitionRepository jobDefinitionRepository;
private final JobExecutionAuditRepository auditRepository;

public JobsManagerService(
JobOperator asyncJobLauncher,
JobOperator syncJobLauncher,
JobRegistry jobRegistry,
PluginRegistryService pluginRegistryService,
JobRepository jobRepository,
PluginMetrics pluginMetrics,
JobDefinitionRepository jobDefinitionRepository,
JobExecutionAuditRepository auditRepository) {
PluginMetrics pluginMetrics) {
this.asyncJobLauncher = asyncJobLauncher;
this.syncJobLauncher = syncJobLauncher;
this.jobRegistry = jobRegistry;
this.pluginRegistryService = pluginRegistryService;
this.jobRepository = jobRepository;
this.pluginMetrics = pluginMetrics;
this.jobDefinitionRepository = jobDefinitionRepository;
this.auditRepository = auditRepository;
}

public HashMap<String, String> launchAllNonAutoDetectedJobsAsync(
Expand Down Expand Up @@ -243,7 +231,6 @@ public Map<String, String> syncRunJobWithParams(SingleJobDataRequest request) {
"parameters",
JsonUtils.parseObject2Json(jobParametersBuilder.toJobParameters()));
launchedJobMetadata.put("result", run.getExitStatus().toString());
saveAudit(run, request.getJobBeanName());
} catch (JobExecutionAlreadyRunningException e) {
logger.error("already running job with the same params", e);
launchedJobMetadata.put("error", e.getMessage());
Expand Down Expand Up @@ -304,25 +291,4 @@ public HashMap<String, Map<String, String>> getRunning() {
});
return running;
}

private void saveAudit(JobExecution execution, String jobName) {
try {
JobDefinitionEntity def = jobDefinitionRepository.findByJobName(jobName).orElse(null);
if (def == null) return;
JobExecutionAuditEntity entity = new JobExecutionAuditEntity();
entity.setJobDefinitionId(def.getId());
entity.setJobExecutionId(execution.getId());
entity.setJobVersion(def.getVersion());
long durationMs = Duration.between(execution.getStartTime(),
execution.getEndTime() != null ? execution.getEndTime() : LocalDateTime.now())
.toMillis();
entity.setExecutionMetadata(
"{\"outcome\":\"" + execution.getExitStatus().getExitCode() + "\","
+ "\"durationMs\":" + durationMs + "}");
auditRepository.save(entity);
logger.debug("Audit saved for job '{}': {}", jobName, execution.getExitStatus().getExitCode());
} catch (Exception e) {
logger.warn("Failed to save audit for '{}': {}", jobName, e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.fronzec.api.BatchJobPlugin;
import com.fronzec.frbatchservice.batchjobs.plugins.entity.JobDefinitionEntity;
import com.fronzec.frbatchservice.batchjobs.plugins.listener.JobExecutionAuditListener;
import com.fronzec.frbatchservice.batchjobs.plugins.repository.JobDefinitionRepository;
import jakarta.annotation.PostConstruct;
import java.util.Collection;
Expand All @@ -16,6 +17,7 @@
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.configuration.DuplicateJobException;
import org.springframework.batch.core.configuration.JobRegistry;
import org.springframework.batch.core.job.AbstractJob;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.context.ApplicationContext;
Expand All @@ -28,6 +30,9 @@
*
* <p>Registration runs in {@link PostConstruct}, which Spring guarantees completes before any
* consumer bean that constructor-injects this service is made available to the HTTP layer.
*
* <p>Every job, static or dynamic, receives the {@link JobExecutionAuditListener} right before it
* enters the {@link JobRegistry}; that listener is the single writer of execution audit history.
*/
@Service
public class PluginRegistryService {
Expand All @@ -40,6 +45,7 @@ public class PluginRegistryService {
private final PlatformTransactionManager transactionManager;
private final ApplicationContext applicationContext;
private final JobDefinitionRepository jobDefinitionRepository;
private final JobExecutionAuditListener auditListener;

/** Built during {@link PostConstruct} and dynamic registration; mutable. */
private final Map<String, BatchJobPlugin> pluginsByJobName = new LinkedHashMap<>();
Expand All @@ -51,13 +57,15 @@ public PluginRegistryService(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
ApplicationContext applicationContext,
JobDefinitionRepository jobDefinitionRepository) {
JobDefinitionRepository jobDefinitionRepository,
JobExecutionAuditListener auditListener) {
this.plugins = plugins;
this.jobRegistry = jobRegistry;
this.jobRepository = jobRepository;
this.transactionManager = transactionManager;
this.applicationContext = applicationContext;
this.jobDefinitionRepository = jobDefinitionRepository;
this.auditListener = auditListener;
}

@PostConstruct
Expand Down Expand Up @@ -101,7 +109,8 @@ public void registerAll() {
e);
}

// 3. Register into the shared JobRegistry
// 3. Attach the execution audit listener, then register into the shared JobRegistry
attachAuditListener(job);
try {
jobRegistry.register(job);
} catch (DuplicateJobException e) {
Expand Down Expand Up @@ -204,6 +213,7 @@ public void registerDynamicPlugin(BatchJobPlugin plugin) {
e);
}

attachAuditListener(job);
try {
jobRegistry.register(job);
} catch (DuplicateJobException e) {
Expand All @@ -221,6 +231,31 @@ public void registerDynamicPlugin(BatchJobPlugin plugin) {
}
}

/**
* Appends the shared {@link JobExecutionAuditListener} to the job so every launch path writes
* its execution audit row through one writer.
*
* <p>Uses {@link AbstractJob#registerJobExecutionListener}, which appends and keeps the
* listeners the plugin attached itself (never {@code setJobExecutionListeners}, which replaces
* them). Spring Batch ignores a listener that is already registered on the same job instance,
* so re-registering a job cannot attach the audit listener twice. A {@link Job} that is not an
* {@link AbstractJob} cannot accept listeners: it is still registered, but its executions are
* not audited.
*
* @param job the job produced by the plugin's {@code configureJob(...)}
*/
private void attachAuditListener(Job job) {
if (job instanceof AbstractJob abstractJob) {
abstractJob.registerJobExecutionListener(auditListener);
} else {
log.warn(
"Job '{}' ({}) is not an AbstractJob; registering it without the execution audit"
+ " listener, so its executions will not be audited",
job.getName(),
job.getClass().getName());
}
}

/**
* Unregisters a dynamically-registered plugin from the internal map and the
* shared {@link JobRegistry}.
Expand Down
Loading
Loading