Skip to content

Commit 01751bf

Browse files
authored
Improve logic for tracking which files have already been processed in policies (Stirling-Tools#6903)
# Description of Changes Replaces the `.stirling/done` folder and its friends with a ledger in the DB which tracks which documents have been processed. This should scale dramatically better since it's just a few bytes being written for each PDF processed, rather than each PDF being duplicated and held in the folder forever. It's designed to work with the current folder source, but also with S3 buckets and other sources in mind - each source will define its own strategy for ensuring it knows whether the documents have had policies run on them or not, and they all get written to the same ledger.
1 parent 119eb1f commit 01751bf

41 files changed

Lines changed: 2698 additions & 155 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,7 @@ The frontend is organized with a clear separation of concerns:
453453

454454
- **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately
455455
- Translation files are located in `frontend/editor/public/locales/`
456+
- After changing any translation file, run `task pre-commit:fix`
456457

457458
## Important Notes
458459

app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
4949
import stirling.software.proprietary.policy.engine.PolicyRunner;
5050
import stirling.software.proprietary.policy.engine.PolicyValidator;
51+
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
5152
import stirling.software.proprietary.policy.model.PipelineDefinition;
5253
import stirling.software.proprietary.policy.model.Policy;
5354
import stirling.software.proprietary.policy.model.PolicyInputs;
@@ -87,6 +88,7 @@ public class PolicyController {
8788
private final PolicyManagementAuthority policyManagementAuthority;
8889
private final PolicyTriggerManager policyTriggerManager;
8990
private final PolicyOverviewService policyOverviewService;
91+
private final ProcessedLedger processedLedger;
9092
private final List<PolicyTrigger> policyTriggers;
9193
private final ApplicationProperties applicationProperties;
9294
private final TempFileManager tempFileManager;
@@ -352,6 +354,7 @@ public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
352354
boolean accessible =
353355
policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent();
354356
if (accessible && policyStore.delete(policyId)) {
357+
processedLedger.clearPolicy(policyId);
355358
// Cancel any now-orphaned folder watch promptly rather than leaving the WatchKey open
356359
// until the next reconcile sweep.
357360
policyTriggerManager.notifyPoliciesChanged();
@@ -360,6 +363,25 @@ public ResponseEntity<Void> deletePolicy(@PathVariable String policyId) {
360363
return ResponseEntity.notFound().build();
361364
}
362365

366+
@DeleteMapping("/{policyId}/processed-history")
367+
@Operation(
368+
summary = "Clear a policy's processed-file history",
369+
description =
370+
"Forgets which source files this policy has already processed, so its next"
371+
+ " sweep reprocesses everything currently in its sources. Does not"
372+
+ " touch the files themselves.")
373+
public ResponseEntity<Void> clearProcessedHistory(@PathVariable String policyId) {
374+
requirePolicyEditingAllowed();
375+
// Scope to the caller's team: a policy in another team reads as not-found.
376+
boolean accessible =
377+
policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent();
378+
if (!accessible) {
379+
return ResponseEntity.notFound().build();
380+
}
381+
processedLedger.clearPolicy(policyId);
382+
return ResponseEntity.noContent().build();
383+
}
384+
363385
@PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
364386
@Operation(
365387
summary = "Run a stored policy",

app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import stirling.software.proprietary.policy.model.PolicyInputs;
3636
import stirling.software.proprietary.policy.model.PolicyRun;
3737
import stirling.software.proprietary.policy.model.WaitState;
38+
import stirling.software.proprietary.policy.output.OutputDelivery;
3839
import stirling.software.proprietary.policy.output.PolicyOutputSink;
3940
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
4041
import stirling.software.proprietary.service.DownstreamEntitlementError;
@@ -203,7 +204,12 @@ private void runToCompletion(
203204
PolicyExecutionResult result =
204205
stepExecutor.execute(run.getDefinition(), inputs, listener);
205206
OutputSpec output = run.getDefinition().output();
206-
List<ResultFile> outputs = sinkFor(output).deliver(runId, result.files(), output);
207+
List<ResultFile> outputs =
208+
sinkFor(output)
209+
.deliver(
210+
new OutputDelivery(runId, run.getPolicyId()),
211+
result.files(),
212+
output);
207213
taskManager.setMultipleFileResults(runId, outputs);
208214
taskManager.setComplete(runId);
209215
run.complete(outputs);

app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import stirling.software.proprietary.policy.input.InputSource;
1515
import stirling.software.proprietary.policy.input.ResolvedInput;
16+
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
1617
import stirling.software.proprietary.policy.model.InputSpec;
1718
import stirling.software.proprietary.policy.model.PipelineDefinition;
1819
import stirling.software.proprietary.policy.model.Policy;
@@ -27,7 +28,8 @@
2728
/**
2829
* Turns a policy's referenced sources into runs: each {@code sourceId} is resolved live to its
2930
* persisted {@link Source}, then to an {@link InputSpec}. Triggers decide <em>when</em> and call
30-
* {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points.
31+
* {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points. A {@link
32+
* SweepKind#FULL} sweep also reconciles the processed-file ledger against what is present.
3133
*/
3234
@Slf4j
3335
@Service
@@ -39,6 +41,12 @@ public class PolicyRunner {
3941
private final List<InputSource> inputSources;
4042
private final SourceStore sourceStore;
4143
private final SourceDocCounter docCounter;
44+
private final ProcessedLedger processedLedger;
45+
46+
/** Full-listing sweep: resolve every source, then reconcile the ledger. */
47+
public List<String> run(Policy policy) {
48+
return run(policy, SweepKind.FULL);
49+
}
4250

4351
/**
4452
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
@@ -47,15 +55,21 @@ public class PolicyRunner {
4755
* rest. Returns the ids of the runs it started (empty when sources yielded no work), so a
4856
* manual trigger can report back which runs to follow.
4957
*/
50-
public List<String> run(Policy policy) {
58+
public List<String> run(Policy policy, SweepKind sweep) {
59+
long sweepStart = System.currentTimeMillis();
60+
PolicySweep context = new PolicySweep(policy.id(), sweep, processedLedger);
61+
List<String> runIds = new ArrayList<>();
5162
List<String> sourceIds = policy.sourceIds();
5263
if (sourceIds.isEmpty()) {
53-
return List.of(startRun(policy, PolicyInputs.of(List.of()), unused -> {}));
64+
// Generator pipeline: one run with no input. Still fall through to the cleanup
65+
// below so rows recorded for its folder outputs are pruned like anything else,
66+
// instead of accumulating until the policy is deleted.
67+
runIds.add(startRun(policy, PolicyInputs.of(List.of()), unused -> {}));
5468
}
55-
List<String> runIds = new ArrayList<>();
5669
for (String sourceId : sourceIds) {
5770
Source source = sourceStore.get(sourceId).orElse(null);
5871
if (source == null) {
72+
// No veto: a deleted source's rows should age out via the cleanup below.
5973
log.warn("Policy {} references missing source {}; skipping", policy.id(), sourceId);
6074
continue;
6175
}
@@ -65,9 +79,21 @@ public List<String> run(Policy policy) {
6579
sourceId,
6680
source.name(),
6781
policy.id());
82+
// Veto: a paused source's files cannot be stamped, so they must not be pruned.
83+
context.vetoCleanup();
6884
continue;
6985
}
70-
runIds.addAll(pullAndRun(policy, sourceId, source.toInputSpec()));
86+
runIds.addAll(pullAndRun(policy, sourceId, source.toInputSpec(), context));
87+
}
88+
if (context.cleanupAllowed()) {
89+
processedLedger.markSeen(policy.id(), context.presentIdentities());
90+
int removed = processedLedger.deleteUnseen(policy.id(), sweepStart);
91+
if (removed > 0) {
92+
log.debug(
93+
"Pruned {} ledger row(s) for files no longer present (policy {})",
94+
removed,
95+
policy.id());
96+
}
7197
}
7298
return runIds;
7399
}
@@ -86,26 +112,33 @@ public PolicyRunHandle runAdHoc(
86112

87113
/**
88114
* Resolves the source and starts a run per unit; records how many documents the source fed and
89-
* returns the ids of the runs started.
115+
* returns the ids of the runs started. Any source that could not be listed completely vetoes
116+
* this sweep's ledger cleanup.
90117
*/
91-
private List<String> pullAndRun(Policy policy, String sourceId, InputSpec spec) {
118+
private List<String> pullAndRun(
119+
Policy policy, String sourceId, InputSpec spec, PolicySweep context) {
92120
InputSource source = sourceFor(spec);
93121
if (source == null) {
94122
log.warn(
95123
"No input source for type '{}' (policy {}); skipping",
96124
spec.type(),
97125
policy.id());
126+
context.vetoCleanup();
98127
return List.of();
99128
}
129+
if (!source.listsExhaustively()) {
130+
context.vetoCleanup();
131+
}
100132
List<ResolvedInput> work;
101133
try {
102-
work = source.resolve(spec);
134+
work = source.resolve(spec, context);
103135
} catch (IOException | RuntimeException e) {
104136
log.warn(
105137
"Failed to resolve source '{}' for policy {}: {}",
106138
spec.type(),
107139
policy.id(),
108140
e.getMessage());
141+
context.vetoCleanup();
109142
return List.of();
110143
}
111144
List<String> runIds = new ArrayList<>();
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package stirling.software.proprietary.policy.engine;
2+
3+
import java.util.Collection;
4+
import java.util.HashMap;
5+
import java.util.HashSet;
6+
import java.util.List;
7+
import java.util.Map;
8+
import java.util.Set;
9+
import java.util.function.Supplier;
10+
11+
import stirling.software.proprietary.policy.input.ResolveContext;
12+
import stirling.software.proprietary.policy.ledger.ClaimState;
13+
import stirling.software.proprietary.policy.ledger.ProcessedFileStatus;
14+
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
15+
16+
/**
17+
* The {@link ResolveContext} for one policy sweep: scopes ledger calls to the policy, gathers the
18+
* present-identity union across sources, prefetches claim state in bulk so per-file claims skip
19+
* their row lookup, and vetoes presence cleanup when any source could not be listed completely
20+
* (pruning would wrongly forget its files).
21+
*/
22+
final class PolicySweep implements ResolveContext {
23+
24+
private final String policyId;
25+
private final SweepKind kind;
26+
private final ProcessedLedger ledger;
27+
private final Set<String> present = new HashSet<>();
28+
// Claim states loaded in bulk at reportPresent; a claim outside the prefetch falls back to a
29+
// single lookup. A stale entry cannot double-claim (the ledger re-checks every transition),
30+
// it can only defer a file to the next sweep.
31+
private final Map<String, ClaimState> prefetched = new HashMap<>();
32+
private final Set<String> prefetchedIdentities = new HashSet<>();
33+
private boolean cleanupVetoed;
34+
35+
PolicySweep(String policyId, SweepKind kind, ProcessedLedger ledger) {
36+
this.policyId = policyId;
37+
this.kind = kind;
38+
this.ledger = ledger;
39+
}
40+
41+
@Override
42+
public synchronized boolean claim(String identity, String gate, Supplier<String> contentHash) {
43+
ClaimState observed =
44+
prefetchedIdentities.contains(identity)
45+
? prefetched.get(identity)
46+
: ledger.statesFor(policyId, List.of(identity)).get(identity);
47+
boolean claimed = ledger.claim(policyId, identity, gate, contentHash, observed);
48+
if (claimed) {
49+
// A nested source surfacing the same file later in this sweep sees it in flight
50+
// without another lookup.
51+
prefetchedIdentities.add(identity);
52+
prefetched.put(identity, new ClaimState(ProcessedFileStatus.PROCESSING, gate, null));
53+
}
54+
return claimed;
55+
}
56+
57+
@Override
58+
public void settle(
59+
String identity, String finalGate, String finalContentHash, boolean success) {
60+
ledger.settle(policyId, identity, finalGate, finalContentHash, success);
61+
}
62+
63+
@Override
64+
public boolean allSettledDone(String identity) {
65+
// Deliberately not policy-scoped: consume deletion needs every claimant's consensus.
66+
return ledger.allSettledDone(identity);
67+
}
68+
69+
@Override
70+
public synchronized void reportPresent(Collection<String> identities) {
71+
if (kind == SweepKind.FULL) {
72+
present.addAll(identities);
73+
}
74+
prefetched.putAll(ledger.statesFor(policyId, identities));
75+
prefetchedIdentities.addAll(identities);
76+
}
77+
78+
synchronized void vetoCleanup() {
79+
cleanupVetoed = true;
80+
}
81+
82+
synchronized boolean cleanupAllowed() {
83+
return kind == SweepKind.FULL && !cleanupVetoed;
84+
}
85+
86+
synchronized Set<String> presentIdentities() {
87+
return Set.copyOf(present);
88+
}
89+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package stirling.software.proprietary.policy.engine;
2+
3+
/**
4+
* How thorough a policy sweep is: {@link #FULL} (complete listing; also stamps presence and prunes
5+
* the ledger) or {@link #LIGHT} (event-driven; claims only, cost proportional to what changed).
6+
*/
7+
public enum SweepKind {
8+
FULL,
9+
LIGHT
10+
}

0 commit comments

Comments
 (0)