Skip to content

Commit 0dbff15

Browse files
committed
Add lineage detection primitives: signature extractor, store, detector
Three orthogonal interfaces, each with one production impl. Designed so the hash algorithm, the storage backend, and the matching policy can each be swapped independently without changes elsewhere. LineageSignatureExtractor — given a Path, returns Set<LineageSignature>. File-based (not stream-based) so a future PDF-aware extractor can open the same file with jpdfium / PDFBox and pull /ID[0] or a content-stream hash alongside the byte hash. Multiple extractors compose at the detector layer via Spring auto-wiring; the detector unions their results. Default impl: ByteHashSignatureExtractor — SHA-256 of file bytes via a 64 KiB-buffered DigestInputStream. Hardware-accelerated by the JVM. JobLineageStore — record(jobId, signatures, kind) / findOpenJobForSignatures(userId, candidates, window) / pruneOlderThan. Knows nothing about storage technology. Production impl JpaJobLineageStore backs onto job_artifact_hash via a single joined query. A future RedisJobLineageStore is a drop-in. HashLineageDetector — high-level API. Delegates extraction to the configured extractors and storage to the store. Reads payg.lineage.workflow-window (default PT5M) from config. Future hook: when wallet_policy.auto_group_strategy lookup lands, the detect() method short-circuits to empty when the team has opted out. Schema: content_hash widened from CHAR(64) to VARCHAR(128) so non-SHA-256 signatures fit. Storage form is "type:value" — sha256:hexdigest, pdf-id:uuid, etc. coexist on the same column. Tests: InMemoryJobLineageStore + FakeFileSignatureExtractor under src/test exercise the detector end-to-end without a database. Covers same-user, within-window, status=OPEN, multi-signature, most-recent-wins, record+detect round-trip, and extractor-failure isolation. Stacked PR: branched off payg-i2-document-classifier because the JPA store + JobArtifactHash entity depend on PR #6460's data model. Once that merges, this PR rebases to target main.
1 parent 83ea07e commit 0dbff15

13 files changed

Lines changed: 914 additions & 6 deletions
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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.stereotype.Component;
14+
15+
/**
16+
* SHA-256 of the file's bytes. The simplest universally-applicable signature — works for every
17+
* content type, doesn't parse, doesn't allocate proportional to file size (fixed 64 KiB read
18+
* buffer), hardware-accelerated by the JVM on modern hardware (Intel SHA-NI, ARM SHA extensions).
19+
*
20+
* <p>Always returns exactly one {@link LineageSignature} of type {@code "sha256"}. A future {@code
21+
* PdfMetadataSignatureExtractor} would be a separate bean and add its own signature type — composed
22+
* at the detector layer, no interaction needed here.
23+
*/
24+
@Component
25+
public class ByteHashSignatureExtractor implements LineageSignatureExtractor {
26+
27+
private static final String ALGORITHM = "SHA-256";
28+
private static final String SIGNATURE_TYPE = "sha256";
29+
private static final int BUFFER_SIZE = 64 * 1024;
30+
31+
@Override
32+
public Set<LineageSignature> extract(Path file) throws IOException {
33+
MessageDigest digest = newDigest();
34+
try (InputStream raw = Files.newInputStream(file);
35+
DigestInputStream in = new DigestInputStream(raw, digest)) {
36+
byte[] buf = new byte[BUFFER_SIZE];
37+
// Drain through the digest stream; we only care about side effects on the digest.
38+
while (in.read(buf) != -1) {
39+
// no-op
40+
}
41+
}
42+
String hex = HexFormat.of().formatHex(digest.digest());
43+
return Set.of(new LineageSignature(SIGNATURE_TYPE, hex));
44+
}
45+
46+
@Override
47+
public String name() {
48+
return SIGNATURE_TYPE;
49+
}
50+
51+
private static MessageDigest newDigest() {
52+
try {
53+
return MessageDigest.getInstance(ALGORITHM);
54+
} catch (NoSuchAlgorithmException e) {
55+
// SHA-256 is mandated by every JDK; unreachable in practice.
56+
throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e);
57+
}
58+
}
59+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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.stereotype.Component;
15+
16+
import lombok.extern.slf4j.Slf4j;
17+
18+
import stirling.software.saas.payg.model.ArtifactKind;
19+
20+
/**
21+
* Default detector. Composes signatures from every registered {@link LineageSignatureExtractor}
22+
* (currently just SHA-256 byte hash; future PDF-aware extractors are drop-in additions) and
23+
* delegates lookup + persistence to a {@link JobLineageStore}.
24+
*
25+
* <p>Future: when the per-team {@code wallet_policy.auto_group_strategy} lookup lands (alongside
26+
* the wallet policy service), {@link #detect} will short-circuit to {@code Optional.empty()} when
27+
* the team has explicitly opted out of auto-grouping. Not in this PR — the wiring slot is the first
28+
* line of {@code detect}.
29+
*/
30+
@Slf4j
31+
@Component
32+
public class DefaultHashLineageDetector implements HashLineageDetector {
33+
34+
private final List<LineageSignatureExtractor> extractors;
35+
private final JobLineageStore store;
36+
private final Duration workflowWindow;
37+
38+
public DefaultHashLineageDetector(
39+
List<LineageSignatureExtractor> extractors,
40+
JobLineageStore store,
41+
@Value("${payg.lineage.workflow-window:PT5M}") Duration workflowWindow) {
42+
if (Objects.requireNonNull(extractors, "extractors").isEmpty()) {
43+
throw new IllegalStateException(
44+
"DefaultHashLineageDetector requires at least one LineageSignatureExtractor"
45+
+ " bean — none registered.");
46+
}
47+
this.extractors = List.copyOf(extractors);
48+
this.store = Objects.requireNonNull(store, "store");
49+
this.workflowWindow = Objects.requireNonNull(workflowWindow, "workflowWindow");
50+
}
51+
52+
@Override
53+
public Optional<LineageMatch> detect(Long userId, Path inputFile) throws IOException {
54+
Objects.requireNonNull(userId, "userId");
55+
Objects.requireNonNull(inputFile, "inputFile");
56+
57+
Set<LineageSignature> signatures = extractAll(inputFile);
58+
if (signatures.isEmpty()) {
59+
// No extractor produced anything for this content. Treat as no-match.
60+
log.debug("No signatures extracted from {}; lineage check returns empty.", inputFile);
61+
return Optional.empty();
62+
}
63+
64+
return store.findOpenJobForSignatures(userId, signatures, workflowWindow);
65+
}
66+
67+
@Override
68+
public void record(UUID jobId, Path file, ArtifactKind kind) throws IOException {
69+
Objects.requireNonNull(jobId, "jobId");
70+
Objects.requireNonNull(file, "file");
71+
Objects.requireNonNull(kind, "kind");
72+
73+
Set<LineageSignature> signatures = extractAll(file);
74+
if (signatures.isEmpty()) {
75+
log.debug(
76+
"No signatures extracted from {} for job {} ({}); nothing recorded.",
77+
file,
78+
jobId,
79+
kind);
80+
return;
81+
}
82+
store.record(jobId, signatures, kind);
83+
}
84+
85+
private Set<LineageSignature> extractAll(Path file) throws IOException {
86+
Set<LineageSignature> union = new HashSet<>();
87+
for (LineageSignatureExtractor extractor : extractors) {
88+
try {
89+
union.addAll(extractor.extract(file));
90+
} catch (IOException e) {
91+
// A single extractor failing (e.g. PDF-aware extractor on a malformed PDF) must
92+
// not block the byte-hash extractor from contributing. Log and continue.
93+
log.debug(
94+
"Extractor '{}' failed on {} ({}); continuing with other extractors.",
95+
extractor.name(),
96+
file,
97+
e.getMessage());
98+
}
99+
}
100+
return union;
101+
}
102+
}
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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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.List;
8+
import java.util.Objects;
9+
import java.util.Optional;
10+
import java.util.Set;
11+
import java.util.UUID;
12+
import java.util.stream.Collectors;
13+
14+
import org.springframework.stereotype.Component;
15+
16+
import lombok.RequiredArgsConstructor;
17+
18+
import stirling.software.saas.payg.job.JobArtifactHash;
19+
import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId;
20+
import stirling.software.saas.payg.model.ArtifactKind;
21+
import stirling.software.saas.payg.model.JobStatus;
22+
import stirling.software.saas.payg.repository.JobArtifactHashRepository;
23+
24+
/**
25+
* JPA-backed {@link JobLineageStore} against the {@code job_artifact_hash} table. The lookup runs
26+
* as a single joined query against {@code processing_job} so status + window filtering happens at
27+
* the database, not in-process.
28+
*
29+
* <p>Signatures are persisted using {@link LineageSignature#asStorageKey()} ({@code "type:value"})
30+
* so multiple signature types coexist on the same column without a schema change.
31+
*/
32+
@Component
33+
@RequiredArgsConstructor
34+
public class JpaJobLineageStore implements JobLineageStore {
35+
36+
private final JobArtifactHashRepository hashRepository;
37+
38+
@Override
39+
public void record(UUID jobId, Set<LineageSignature> signatures, ArtifactKind kind) {
40+
Objects.requireNonNull(jobId, "jobId");
41+
Objects.requireNonNull(signatures, "signatures");
42+
Objects.requireNonNull(kind, "kind");
43+
for (LineageSignature signature : signatures) {
44+
JobArtifactHash row = new JobArtifactHash();
45+
row.setId(new JobArtifactHashId(jobId, signature.asStorageKey(), kind));
46+
hashRepository.save(row);
47+
}
48+
}
49+
50+
@Override
51+
public Optional<LineageMatch> findOpenJobForSignatures(
52+
Long userId, Set<LineageSignature> candidates, Duration workflowWindow) {
53+
Objects.requireNonNull(userId, "userId");
54+
Objects.requireNonNull(candidates, "candidates");
55+
Objects.requireNonNull(workflowWindow, "workflowWindow");
56+
if (candidates.isEmpty()) {
57+
return Optional.empty();
58+
}
59+
60+
List<String> storageKeys =
61+
candidates.stream()
62+
.map(LineageSignature::asStorageKey)
63+
.collect(Collectors.toList());
64+
LocalDateTime since = LocalDateTime.now().minus(workflowWindow);
65+
66+
List<LineageMatch> matches =
67+
hashRepository.findOpenJobsForSignatures(
68+
userId, JobStatus.OPEN, since, storageKeys);
69+
return matches.isEmpty() ? Optional.empty() : Optional.of(matches.get(0));
70+
}
71+
72+
@Override
73+
public int pruneOlderThan(Instant cutoff) {
74+
Objects.requireNonNull(cutoff, "cutoff");
75+
return hashRepository.deleteOlderThan(
76+
LocalDateTime.ofInstant(cutoff, ZoneId.systemDefault()));
77+
}
78+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
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+
public record LineageMatch(UUID jobId, ArtifactKind matchedKind, LocalDateTime jobLastStepAt) {}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package stirling.software.saas.payg.lineage;
2+
3+
import java.util.Objects;
4+
5+
/**
6+
* A single identity signal for a piece of content. The detector treats {@code (type, value)} as an
7+
* opaque equality key — two signatures match if both components are equal. Format:
8+
*
9+
* <ul>
10+
* <li>{@code type} describes the extraction strategy ({@code "sha256"}, {@code "pdf-id"}, {@code
11+
* "pdf-content-stream-hash"}, etc.).
12+
* <li>{@code value} is the strategy-specific identifier, encoded as a string short enough to fit
13+
* in {@code job_artifact_hash.content_hash} (VARCHAR(128)).
14+
* </ul>
15+
*
16+
* <p>Persisted as the concatenation {@code "{type}:{value}"} so multiple signature types can
17+
* coexist on the same {@code job_artifact_hash} table without a separate column.
18+
*/
19+
public record LineageSignature(String type, String value) {
20+
21+
public LineageSignature {
22+
Objects.requireNonNull(type, "type");
23+
Objects.requireNonNull(value, "value");
24+
if (type.isBlank()) {
25+
throw new IllegalArgumentException("signature type must not be blank");
26+
}
27+
if (type.contains(":")) {
28+
throw new IllegalArgumentException("signature type must not contain ':'");
29+
}
30+
if (value.isBlank()) {
31+
throw new IllegalArgumentException("signature value must not be blank");
32+
}
33+
}
34+
35+
/** Storage form: {@code "type:value"}. */
36+
public String asStorageKey() {
37+
return type + ":" + value;
38+
}
39+
40+
/** Parses a storage-form key back into a {@code LineageSignature}. */
41+
public static LineageSignature fromStorageKey(String key) {
42+
Objects.requireNonNull(key, "key");
43+
int colon = key.indexOf(':');
44+
if (colon <= 0 || colon == key.length() - 1) {
45+
throw new IllegalArgumentException(
46+
"Storage key must be of the form 'type:value': " + key);
47+
}
48+
return new LineageSignature(key.substring(0, colon), key.substring(colon + 1));
49+
}
50+
}

0 commit comments

Comments
 (0)