Skip to content

Commit e6974d5

Browse files
authored
PAYG: hash-lineage detection primitives (modular extractor / store / detector) (Stirling-Tools#6464)
## What this is Three orthogonal interfaces — each with one production impl — for detecting whether an incoming tool call should join an existing process via content-hash lineage. Groundwork for PR-I7a: nothing in this PR calls the detector yet; the ingress/egress filter that wires it into every controller lands separately. Built to be modular along three axes. Swapping any of them should not require changes elsewhere: | Axis | Interface | V1 impl | Plausible future impl | |---|---|---|---| | Hash algorithm | `LineageSignatureExtractor` | `ByteHashSignatureExtractor` (SHA-256) | `PdfMetadataSignatureExtractor` (PDF `/ID`, content-stream hash) | | Storage backend | `JobLineageStore` | `JpaJobLineageStore` | `RedisJobLineageStore` | | Matching policy | `HashLineageDetector` | `DefaultHashLineageDetector` | strategy-driven variant (any-match-joins for multi-input — lives in JobService) | ## Interfaces ### `LineageSignatureExtractor` — what counts as a fingerprint ```java public interface LineageSignatureExtractor { Set<LineageSignature> extract(Path file) throws IOException; String name(); } ``` File-based (not stream-based) so a future PDF-aware extractor can open the same file via jpdfium / PDFBox and pull `/ID[0]` or a content-stream hash. Multiple extractors compose at the detector layer — Spring auto-wires all `LineageSignatureExtractor` beans, the detector unions their results. Production impl: **`ByteHashSignatureExtractor`** — SHA-256 over the file via 64 KiB-buffered `DigestInputStream`. Hardware-accelerated by the JVM (Intel SHA-NI, ARM SHA). ### `JobLineageStore` — where signatures live ```java public interface JobLineageStore { void record(UUID jobId, Set<LineageSignature> signatures, ArtifactKind kind); Optional<LineageMatch> findOpenJobForSignatures(Long userId, Set<LineageSignature> candidates, Duration window); int pruneOlderThan(Instant cutoff); } ``` Knows nothing about storage technology. Production impl **`JpaJobLineageStore`** runs a single joined query against `job_artifact_hash` ⋈ `processing_job` — status + window filtering happen at the database. The query is bounded by `Limit.of(1)` on the hot path so a job set sharing a popular signature doesn't materialise unwanted rows. A future `RedisJobLineageStore` (or write-through hybrid) is a drop-in. ### `HashLineageDetector` — the high-level API ```java public interface HashLineageDetector { Optional<LineageMatch> detect(Long userId, Path inputFile) throws IOException; void record(UUID jobId, Path file, ArtifactKind kind) throws IOException; } ``` **`DefaultHashLineageDetector`** delegates extraction to every registered `LineageSignatureExtractor`, storage to the configured `JobLineageStore`, and reads `payg.lineage.workflow-window` (default `PT5M`) from config. When a single extractor throws (e.g. a future PDF-aware extractor against a malformed PDF), the other extractors still contribute — failures don't block the byte-hash from landing. ## Profile gating All three `@Component` beans (`JpaJobLineageStore`, `ByteHashSignatureExtractor`, `DefaultHashLineageDetector`) are `@Profile("saas")` — consistent with every other `:saas` bean. Without this guard the JPA store would fail to wire against its profile-gated repository in non-saas profiles that pull `:saas` onto the classpath. ## Tests Run entirely in-memory; no database required. - **`LineageSignatureTest`** — storage-key encoding round-trips, rejects malformed `"type:value"` keys. - **`ByteHashSignatureExtractorTest`** — identical bytes → identical sigs; empty file hashes to the well-known SHA-256-of-empty constant; 10 MiB file streams without OOM. - **`DefaultHashLineageDetectorTest`** — same-user / within-window / status=OPEN filtering, multi-signature matching (one extractor sees `pdf-id` and matches even when bytes differ), most-recent-job-wins, record+detect round-trip, extractor-throwing-doesn't-break-others. **`InMemoryJobLineageStore`** (in test sources) implements the same `JobLineageStore` interface as the JPA impl, plus a `registerJob` hook for tests to model job state. Same contract — proves the abstraction is portable. When the Redis impl lands it gets the same contract tests. ## What's not in this PR (deliberate) - The tool ingress/egress filter that wires the detector into every controller — separate, focused review. - `JobChargeService.openProcess()` — uses the detector, part of the charging machinery, separate PR. - Prune scheduler that calls `pruneOlderThan` — small follow-up alongside the `shedlock` foundational table. - PDF-aware extractor (`PdfMetadataSignatureExtractor`) — to be added when we measure how often byte-hash-only misses real workflows. - Multi-input "any-match-joins" lineage policy — that's a `JobService` decision (PR-I7), not a primitive. ## Self-review pass applied An independent code-review on this PR caught: - **HIGH:** Missing `@Profile("saas")` on the three `@Component` beans → fixed. - **MEDIUM:** `pruneOlderThan` missing `@Transactional` (its `@Modifying` query would have thrown `InvalidDataAccessApiUsageException`) → fixed. - **MEDIUM:** `findOpenJobsForSignatures` fetching the whole match set just to `get(0)` → now takes `Limit`, JPA store passes `Limit.of(1)` on the hot path. - **LOW:** `InMemoryJobLineageStore` used both `synchronized` methods and `ConcurrentHashMap` → dropped the redundant `ConcurrentHashMap`. Deferred: project-wide UTC unification (`LocalDateTime.now()` system-zone is the established convention; flipping one file mid-stack caused a real test failure — proper fix needs its own audit). ## Rollback Straight `git revert`. No callers yet; deleting these classes wouldn't break anything. --- ## Checklist - [x] Tests pass: `ENABLE_SAAS=true ./gradlew :saas:test` - [x] No new warnings - [x] Self-review performed (HIGH + MEDIUM findings addressed)
1 parent 256d1a8 commit e6974d5

13 files changed

Lines changed: 965 additions & 6 deletions
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package stirling.software.saas.payg.lineage;
2+
3+
import java.io.IOException;
4+
import java.io.InputStream;
5+
import java.nio.file.Files;
6+
import java.nio.file.Path;
7+
import java.security.DigestInputStream;
8+
import java.security.MessageDigest;
9+
import java.security.NoSuchAlgorithmException;
10+
import java.util.HexFormat;
11+
import java.util.Set;
12+
13+
import org.springframework.context.annotation.Profile;
14+
import org.springframework.stereotype.Component;
15+
16+
/**
17+
* SHA-256 of the file's bytes. The simplest universally-applicable signature — works for every
18+
* content type, doesn't parse, doesn't allocate proportional to file size (fixed 64 KiB read
19+
* buffer), hardware-accelerated by the JVM on modern hardware (Intel SHA-NI, ARM SHA extensions).
20+
*
21+
* <p>Always returns exactly one {@link LineageSignature} of type {@code "sha256"}. A future {@code
22+
* PdfMetadataSignatureExtractor} would be a separate bean and add its own signature type — composed
23+
* at the detector layer, no interaction needed here.
24+
*/
25+
@Component
26+
@Profile("saas")
27+
public class ByteHashSignatureExtractor implements LineageSignatureExtractor {
28+
29+
private static final String ALGORITHM = "SHA-256";
30+
private static final String SIGNATURE_TYPE = "sha256";
31+
private static final int BUFFER_SIZE = 64 * 1024;
32+
33+
@Override
34+
public Set<LineageSignature> extract(Path file) throws IOException {
35+
MessageDigest digest = newDigest();
36+
try (InputStream raw = Files.newInputStream(file);
37+
DigestInputStream in = new DigestInputStream(raw, digest)) {
38+
byte[] buf = new byte[BUFFER_SIZE];
39+
// Drain through the digest stream; we only care about side effects on the digest.
40+
while (in.read(buf) != -1) {
41+
// no-op
42+
}
43+
}
44+
String hex = HexFormat.of().formatHex(digest.digest());
45+
return Set.of(new LineageSignature(SIGNATURE_TYPE, hex));
46+
}
47+
48+
@Override
49+
public String name() {
50+
return SIGNATURE_TYPE;
51+
}
52+
53+
private static MessageDigest newDigest() {
54+
try {
55+
return MessageDigest.getInstance(ALGORITHM);
56+
} catch (NoSuchAlgorithmException e) {
57+
// SHA-256 is mandated by every JDK; unreachable in practice.
58+
throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e);
59+
}
60+
}
61+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package stirling.software.saas.payg.lineage;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Path;
5+
import java.time.Duration;
6+
import java.util.HashSet;
7+
import java.util.List;
8+
import java.util.Objects;
9+
import java.util.Optional;
10+
import java.util.Set;
11+
import java.util.UUID;
12+
13+
import org.springframework.beans.factory.annotation.Value;
14+
import org.springframework.context.annotation.Profile;
15+
import org.springframework.stereotype.Component;
16+
17+
import lombok.extern.slf4j.Slf4j;
18+
19+
import stirling.software.saas.payg.model.ArtifactKind;
20+
21+
/**
22+
* Default detector. Composes signatures from every registered {@link LineageSignatureExtractor}
23+
* (currently just SHA-256 byte hash; future PDF-aware extractors are drop-in additions) and
24+
* delegates lookup + persistence to a {@link JobLineageStore}.
25+
*/
26+
@Slf4j
27+
@Component
28+
@Profile("saas")
29+
public class DefaultHashLineageDetector implements HashLineageDetector {
30+
31+
private final List<LineageSignatureExtractor> extractors;
32+
private final JobLineageStore store;
33+
private final Duration workflowWindow;
34+
35+
public DefaultHashLineageDetector(
36+
List<LineageSignatureExtractor> extractors,
37+
JobLineageStore store,
38+
@Value("${payg.lineage.workflow-window:PT5M}") Duration workflowWindow) {
39+
Objects.requireNonNull(extractors, "extractors");
40+
Objects.requireNonNull(store, "store");
41+
Objects.requireNonNull(workflowWindow, "workflowWindow");
42+
if (extractors.isEmpty()) {
43+
throw new IllegalStateException(
44+
"DefaultHashLineageDetector requires at least one LineageSignatureExtractor"
45+
+ " bean — none registered.");
46+
}
47+
if (workflowWindow.isNegative() || workflowWindow.isZero()) {
48+
// A non-positive window would mean "since = now + |window|", so the > comparison
49+
// only matches jobs in the future — i.e. nothing matches, silently. Fail loud
50+
// instead.
51+
throw new IllegalArgumentException(
52+
"payg.lineage.workflow-window must be positive, got " + workflowWindow);
53+
}
54+
this.extractors = List.copyOf(extractors);
55+
this.store = store;
56+
this.workflowWindow = workflowWindow;
57+
}
58+
59+
@Override
60+
public Optional<LineageMatch> detect(Long userId, Path inputFile) throws IOException {
61+
Objects.requireNonNull(userId, "userId");
62+
Objects.requireNonNull(inputFile, "inputFile");
63+
64+
Set<LineageSignature> signatures = extractAll(inputFile);
65+
if (signatures.isEmpty()) {
66+
// No extractor produced anything for this content. Treat as no-match.
67+
log.debug("No signatures extracted from {}; lineage check returns empty.", inputFile);
68+
return Optional.empty();
69+
}
70+
71+
return store.findOpenJobForSignatures(userId, signatures, workflowWindow);
72+
}
73+
74+
@Override
75+
public void record(UUID jobId, Path file, ArtifactKind kind) throws IOException {
76+
Objects.requireNonNull(jobId, "jobId");
77+
Objects.requireNonNull(file, "file");
78+
Objects.requireNonNull(kind, "kind");
79+
80+
Set<LineageSignature> signatures = extractAll(file);
81+
if (signatures.isEmpty()) {
82+
log.debug(
83+
"No signatures extracted from {} for job {} ({}); nothing recorded.",
84+
file,
85+
jobId,
86+
kind);
87+
return;
88+
}
89+
store.record(jobId, signatures, kind);
90+
}
91+
92+
private Set<LineageSignature> extractAll(Path file) {
93+
Set<LineageSignature> union = new HashSet<>();
94+
for (LineageSignatureExtractor extractor : extractors) {
95+
try {
96+
union.addAll(extractor.extract(file));
97+
} catch (IOException e) {
98+
// A single extractor failing on file IO / format parse (e.g. PDF-aware extractor
99+
// on a malformed PDF) must not block other extractors from contributing. Log and
100+
// continue. RuntimeExceptions deliberately propagate — they signal bugs we want
101+
// to surface, not "expected" extractor-doesn't-fit-this-content failures.
102+
log.debug(
103+
"Extractor '{}' failed on {} ({}); continuing with other extractors.",
104+
extractor.name(),
105+
file,
106+
e.getMessage());
107+
}
108+
}
109+
return union;
110+
}
111+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package stirling.software.saas.payg.lineage;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Path;
5+
import java.util.Optional;
6+
import java.util.UUID;
7+
8+
import stirling.software.saas.payg.model.ArtifactKind;
9+
10+
/**
11+
* High-level lineage API. Decides whether a tool call should join an existing open process by
12+
* comparing the incoming file's signatures against signatures previously recorded for that user's
13+
* open processes.
14+
*
15+
* <p>Two operations:
16+
*
17+
* <ul>
18+
* <li>{@link #detect} — pre-execution. Caller asks "for this user about to run a tool on this
19+
* file, is there an open process to join?"
20+
* <li>{@link #record} — post-execution (for outputs) or post-charge (for inputs). Caller records
21+
* the file's signatures against the job so subsequent tool calls can lineage-match on it.
22+
* </ul>
23+
*
24+
* <p>Both methods take a {@link Path}; the caller is expected to have already materialised the
25+
* upload / response body to a managed temp file (via {@code TempFileManager}). The detector
26+
* delegates signature extraction to one or more {@link LineageSignatureExtractor}s and storage to a
27+
* {@link JobLineageStore}, both of which are swappable.
28+
*/
29+
public interface HashLineageDetector {
30+
31+
/** Looks up an open process for this user whose recorded signatures match the input file. */
32+
Optional<LineageMatch> detect(Long userId, Path inputFile) throws IOException;
33+
34+
/** Records the file's signatures against the given job as either INPUT or OUTPUT. */
35+
void record(UUID jobId, Path file, ArtifactKind kind) throws IOException;
36+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package stirling.software.saas.payg.lineage;
2+
3+
import java.time.Duration;
4+
import java.time.Instant;
5+
import java.util.Optional;
6+
import java.util.Set;
7+
import java.util.UUID;
8+
9+
import stirling.software.saas.payg.model.ArtifactKind;
10+
11+
/**
12+
* Persistence boundary for lineage data. Two operations: record signatures against a job, and look
13+
* up the open job (if any) that previously recorded a matching signature for the same user.
14+
*
15+
* <p>This interface deliberately knows nothing about the storage technology — the production impl
16+
* is JPA-backed against {@code job_artifact_hash}, and a future Redis-backed impl will be a
17+
* straight swap. The detector and any callers only see this interface.
18+
*
19+
* <p>Signature values are stored using {@link LineageSignature#asStorageKey()}, so multiple
20+
* signature types (SHA-256, PDF {@code /ID}, etc.) coexist on the same table without a separate
21+
* column.
22+
*/
23+
public interface JobLineageStore {
24+
25+
/** Records every signature in {@code signatures} against the given job + artifact kind. */
26+
void record(UUID jobId, Set<LineageSignature> signatures, ArtifactKind kind);
27+
28+
/**
29+
* Finds the most-recently-active open job (owned by {@code userId}, whose {@code last_step_at}
30+
* is within {@code workflowWindow}) whose recorded artifacts include any of the supplied
31+
* candidate signatures. Returns the first match; ordering across multiple matches is by job
32+
* activity recency.
33+
*/
34+
Optional<LineageMatch> findOpenJobForSignatures(
35+
Long userId, Set<LineageSignature> candidates, Duration workflowWindow);
36+
37+
/** Deletes records created before {@code cutoff}. Returns the number of rows removed. */
38+
int pruneOlderThan(Instant cutoff);
39+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package stirling.software.saas.payg.lineage;
2+
3+
import java.time.Duration;
4+
import java.time.Instant;
5+
import java.time.LocalDateTime;
6+
import java.time.ZoneId;
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.Objects;
10+
import java.util.Optional;
11+
import java.util.Set;
12+
import java.util.UUID;
13+
14+
import org.springframework.context.annotation.Profile;
15+
import org.springframework.data.domain.Limit;
16+
import org.springframework.stereotype.Component;
17+
import org.springframework.transaction.annotation.Transactional;
18+
19+
import lombok.RequiredArgsConstructor;
20+
21+
import stirling.software.saas.payg.job.JobArtifactHash;
22+
import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId;
23+
import stirling.software.saas.payg.model.ArtifactKind;
24+
import stirling.software.saas.payg.model.JobStatus;
25+
import stirling.software.saas.payg.repository.JobArtifactHashRepository;
26+
27+
/**
28+
* JPA-backed {@link JobLineageStore} against the {@code job_artifact_hash} table. The lookup runs
29+
* as a single joined query against {@code processing_job} so status + window filtering happens at
30+
* the database, not in-process.
31+
*
32+
* <p>Signatures are persisted using {@link LineageSignature#asStorageKey()} ({@code "type:value"})
33+
* so multiple signature types coexist on the same column without a schema change.
34+
*/
35+
@Component
36+
@Profile("saas")
37+
@RequiredArgsConstructor
38+
public class JpaJobLineageStore implements JobLineageStore {
39+
40+
private final JobArtifactHashRepository hashRepository;
41+
42+
@Override
43+
@Transactional
44+
public void record(UUID jobId, Set<LineageSignature> signatures, ArtifactKind kind) {
45+
Objects.requireNonNull(jobId, "jobId");
46+
Objects.requireNonNull(signatures, "signatures");
47+
Objects.requireNonNull(kind, "kind");
48+
if (signatures.isEmpty()) {
49+
return;
50+
}
51+
// saveAll + @Transactional → one transaction, all-or-nothing. Without this, a multi-
52+
// signature record() could leave partial state if a save mid-way fails.
53+
List<JobArtifactHash> rows = new ArrayList<>(signatures.size());
54+
for (LineageSignature signature : signatures) {
55+
JobArtifactHash row = new JobArtifactHash();
56+
row.setId(new JobArtifactHashId(jobId, signature.asStorageKey(), kind));
57+
rows.add(row);
58+
}
59+
hashRepository.saveAll(rows);
60+
}
61+
62+
@Override
63+
public Optional<LineageMatch> findOpenJobForSignatures(
64+
Long userId, Set<LineageSignature> candidates, Duration workflowWindow) {
65+
Objects.requireNonNull(userId, "userId");
66+
Objects.requireNonNull(candidates, "candidates");
67+
Objects.requireNonNull(workflowWindow, "workflowWindow");
68+
if (candidates.isEmpty()) {
69+
return Optional.empty();
70+
}
71+
72+
List<String> storageKeys = candidates.stream().map(LineageSignature::asStorageKey).toList();
73+
LocalDateTime since = LocalDateTime.now().minus(workflowWindow);
74+
75+
List<LineageMatch> matches =
76+
hashRepository.findOpenJobsForSignatures(
77+
userId, JobStatus.OPEN, since, storageKeys, Limit.of(1));
78+
return matches.isEmpty() ? Optional.empty() : Optional.of(matches.get(0));
79+
}
80+
81+
@Override
82+
@Transactional
83+
public int pruneOlderThan(Instant cutoff) {
84+
Objects.requireNonNull(cutoff, "cutoff");
85+
return hashRepository.deleteOlderThan(
86+
LocalDateTime.ofInstant(cutoff, ZoneId.systemDefault()));
87+
}
88+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package stirling.software.saas.payg.lineage;
2+
3+
import java.time.LocalDateTime;
4+
import java.util.UUID;
5+
6+
import stirling.software.saas.payg.model.ArtifactKind;
7+
8+
/**
9+
* Result of a successful lineage lookup: the open job a tool call should join, plus the {@link
10+
* ArtifactKind} of the recorded artifact that produced the match (was the matched hash the prior
11+
* call's input or its output?) and the job's {@code last_step_at} so the caller can decide whether
12+
* to extend the workflow window.
13+
*
14+
* <p>If a single job has multiple recorded signatures that all match the candidate set (e.g. both
15+
* its INPUT and OUTPUT hashes align with what the caller offered), {@code matchedKind} reflects one
16+
* of those matches — ordering across same-job-different-kind rows is unspecified. Callers needing
17+
* to enumerate every matched kind should query {@link JobLineageStore} directly.
18+
*/
19+
public record LineageMatch(UUID jobId, ArtifactKind matchedKind, LocalDateTime jobLastStepAt) {}

0 commit comments

Comments
 (0)