diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..24233ef09 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# contentgrid-appserver + +## Testing + +Always run `./gradlew check` before considering work done. diff --git a/contentgrid-appserver-autoconfigure/build.gradle b/contentgrid-appserver-autoconfigure/build.gradle index 99c015bef..fb6e4dd84 100644 --- a/contentgrid-appserver-autoconfigure/build.gradle +++ b/contentgrid-appserver-autoconfigure/build.gradle @@ -9,6 +9,8 @@ dependencies { implementation 'com.contentgrid.thunx:thunx-autoconfigure' compileOnly project(':contentgrid-appserver-application-model') + compileOnly project(':contentgrid-appserver-content-lifecycle') + compileOnly 'io.micrometer:micrometer-core' compileOnly project(':contentgrid-appserver-actuators') compileOnly project(':contentgrid-appserver-contentstore-api') compileOnly project(':contentgrid-appserver-contentstore-impl-encryption') diff --git a/contentgrid-appserver-autoconfigure/src/main/java/com/contentgrid/appserver/autoconfigure/domain/ContentGridDomainAutoConfiguration.java b/contentgrid-appserver-autoconfigure/src/main/java/com/contentgrid/appserver/autoconfigure/domain/ContentGridDomainAutoConfiguration.java index d1a965dd8..9c82b719b 100644 --- a/contentgrid-appserver-autoconfigure/src/main/java/com/contentgrid/appserver/autoconfigure/domain/ContentGridDomainAutoConfiguration.java +++ b/contentgrid-appserver-autoconfigure/src/main/java/com/contentgrid/appserver/autoconfigure/domain/ContentGridDomainAutoConfiguration.java @@ -2,6 +2,8 @@ import com.contentgrid.appserver.application.model.Application; import com.contentgrid.appserver.autoconfigure.events.ContentGridEventsAutoConfiguration; +import com.contentgrid.appserver.autoconfigure.lifecycle.ContentLifecycleAutoConfiguration; +import com.contentgrid.appserver.content.lifecycle.ContentReferenceTracker; import com.contentgrid.appserver.contentstore.api.ContentStore; import com.contentgrid.appserver.domain.ContentApi; import com.contentgrid.appserver.domain.ContentApiImpl; @@ -18,7 +20,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; -@AutoConfiguration(after={ContentGridEventsAutoConfiguration.class}) +@AutoConfiguration(after={ContentGridEventsAutoConfiguration.class, ContentLifecycleAutoConfiguration.class}) @ConditionalOnClass({DatamodelApiImpl.class}) public class ContentGridDomainAutoConfiguration { @@ -45,8 +47,8 @@ public void dispatchDelete(Application application, EntityInstance instance) {} @Bean DatamodelApiImpl datamodelApi(QueryEngine queryEngine, ContentStore contentStore, DomainEventDispatcher dispatcher, - CursorCodec cursorCodec, Clock clock) { - return new DatamodelApiImpl(queryEngine, contentStore, dispatcher, cursorCodec, clock); + CursorCodec cursorCodec, Clock clock, ContentReferenceTracker contentReferenceTracker) { + return new DatamodelApiImpl(queryEngine, contentStore, dispatcher, cursorCodec, clock, contentReferenceTracker); } @Bean diff --git a/contentgrid-appserver-autoconfigure/src/main/java/com/contentgrid/appserver/autoconfigure/lifecycle/ContentLifecycleAutoConfiguration.java b/contentgrid-appserver-autoconfigure/src/main/java/com/contentgrid/appserver/autoconfigure/lifecycle/ContentLifecycleAutoConfiguration.java new file mode 100644 index 000000000..31ad2f2ae --- /dev/null +++ b/contentgrid-appserver-autoconfigure/src/main/java/com/contentgrid/appserver/autoconfigure/lifecycle/ContentLifecycleAutoConfiguration.java @@ -0,0 +1,61 @@ +package com.contentgrid.appserver.autoconfigure.lifecycle; + +import com.contentgrid.appserver.application.model.values.ApplicationName; +import com.contentgrid.appserver.autoconfigure.json.schema.ApplicationResolverAutoConfiguration; +import com.contentgrid.appserver.content.lifecycle.ContentDeletionJob; +import com.contentgrid.appserver.content.lifecycle.ContentLifecycleProperties; +import com.contentgrid.appserver.content.lifecycle.ContentReferenceTracker; +import com.contentgrid.appserver.content.lifecycle.ContentReferenceVerificationQuery; +import com.contentgrid.appserver.content.lifecycle.DeferredContentReferenceTracker; +import com.contentgrid.appserver.content.lifecycle.JooqContentReferenceTracker; +import com.contentgrid.appserver.content.lifecycle.JooqContentReferenceVerificationQuery; +import com.contentgrid.appserver.contentstore.api.ContentStore; +import com.contentgrid.appserver.registry.ApplicationResolver; +import io.micrometer.core.instrument.MeterRegistry; +import org.jooq.DSLContext; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +@AutoConfiguration(after = ApplicationResolverAutoConfiguration.class) +@ConditionalOnClass(JooqContentReferenceTracker.class) +@EnableConfigurationProperties(ContentLifecycleProperties.class) +public class ContentLifecycleAutoConfiguration { + + @Bean + JooqContentReferenceTracker jooqContentReferenceTracker(DSLContext dslContext) { + return new JooqContentReferenceTracker(dslContext); + } + + @Bean + @Primary + ContentReferenceTracker contentReferenceTracker(JooqContentReferenceTracker jooqTracker) { + return new DeferredContentReferenceTracker(jooqTracker); + } + + @Bean + ContentReferenceVerificationQuery contentReferenceVerificationQuery(DSLContext dslContext) { + return new JooqContentReferenceVerificationQuery(dslContext); + } + + @Bean + @ConditionalOnBean({ApplicationResolver.class, ContentStore.class, MeterRegistry.class}) + ContentDeletionJob contentDeletionJob( + DSLContext dslContext, + ContentStore contentStore, + ContentReferenceVerificationQuery verificationQuery, + ApplicationResolver applicationResolver, + MeterRegistry meterRegistry, + ContentLifecycleProperties properties) { + return new ContentDeletionJob( + dslContext, + contentStore, + verificationQuery, + applicationResolver.resolve(ApplicationName.of("default")), + meterRegistry, + properties); + } +} diff --git a/contentgrid-appserver-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/contentgrid-appserver-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 8634e8320..c0815e010 100644 --- a/contentgrid-appserver-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/contentgrid-appserver-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,4 @@ +com.contentgrid.appserver.autoconfigure.lifecycle.ContentLifecycleAutoConfiguration com.contentgrid.appserver.autoconfigure.contentstore.EncryptedContentStoreAutoConfiguration com.contentgrid.appserver.autoconfigure.contentstore.FilesystemContentStoreAutoConfiguration com.contentgrid.appserver.autoconfigure.contentstore.S3ContentStoreAutoConfiguration diff --git a/contentgrid-appserver-autoconfigure/src/test/java/com/contentgrid/appserver/autoconfigure/rest/ContentGridRestAutoConfigurationTest.java b/contentgrid-appserver-autoconfigure/src/test/java/com/contentgrid/appserver/autoconfigure/rest/ContentGridRestAutoConfigurationTest.java index eafa6f112..d85717769 100644 --- a/contentgrid-appserver-autoconfigure/src/test/java/com/contentgrid/appserver/autoconfigure/rest/ContentGridRestAutoConfigurationTest.java +++ b/contentgrid-appserver-autoconfigure/src/test/java/com/contentgrid/appserver/autoconfigure/rest/ContentGridRestAutoConfigurationTest.java @@ -7,6 +7,7 @@ import com.contentgrid.appserver.autoconfigure.contentstore.FilesystemContentStoreAutoConfiguration; import com.contentgrid.appserver.autoconfigure.domain.ContentGridDomainAutoConfiguration; import com.contentgrid.appserver.autoconfigure.events.ContentGridEventsAutoConfiguration; +import com.contentgrid.appserver.autoconfigure.lifecycle.ContentLifecycleAutoConfiguration; import com.contentgrid.appserver.autoconfigure.query.engine.JOOQQueryEngineAutoConfiguration; import com.contentgrid.appserver.registry.ApplicationResolver; import com.contentgrid.appserver.registry.SingleApplicationResolver; @@ -42,6 +43,8 @@ class ContentGridRestAutoConfigurationTest { JOOQQueryEngineAutoConfiguration.class, // autoconfiguration for content store FilesystemContentStoreAutoConfiguration.class, + // autoconfiguration for content lifecycle + ContentLifecycleAutoConfiguration.class, // autoconfiguration for domain ContentGridDomainAutoConfiguration.class, ContentGridEventsAutoConfiguration.class, diff --git a/contentgrid-appserver-content-lifecycle/README.md b/contentgrid-appserver-content-lifecycle/README.md new file mode 100644 index 000000000..27594a9e7 --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/README.md @@ -0,0 +1,46 @@ +# contentgrid-appserver-content-lifecycle + +Tracks content object references and performs safe, grace-period-based deletion from the underlying `ContentStore`. + +## How it works + +- A `_content_references` table tracks a reference count for every content object. +- When content is uploaded, `ContentReferenceTracker.incrementReference()` is called. +- When content is cleared from an entity (set to null, replaced, or entity deleted), `ContentReferenceTracker.decrementReference()` is called **after the entity transaction commits** (via `DeferredContentReferenceTracker`). +- When the reference count reaches zero, the row is marked with `marked_for_deletion_at = NOW()`. +- `ContentDeletionJob` (an `ApplicationRunner`) processes candidates past the grace period, performs a safety verification query, then deletes from the `ContentStore` and removes the tracking row. + +## Running the deletion job + +The `ContentDeletionJob` implements Spring Boot's `ApplicationRunner`. It is intended to be invoked as a **K8s CronJob** using a separate Spring Boot process. Configure the grace period and batch size in `application.properties`: + +```properties +contentgrid.appserver.content.lifecycle.deletion.grace-period=P7D +contentgrid.appserver.content.lifecycle.deletion.batch-size=100 +``` + +## Backfill for existing data + +When deploying this module to an existing database, the `_content_references` table will be empty. Content that was already uploaded before this feature was introduced will never be deleted, which is safe (conservative). If you want to reclaim storage for content that was already dereferenced, a manual backfill is required. + +**Chosen approach: manual operation** — no automatic Flyway migration is provided. The backfill is complex and tenant-specific (depends on the entity/column layout), and running it automatically could be disruptive. Perform the backfill as a one-time manual SQL operation if needed. + +### Backfill SQL pattern + +The following pattern inserts a row into `_content_references` for every content object currently referenced in any entity table. Adjust table and column names to match your application model: + +```sql +INSERT INTO _content_references (content_id, reference_count, first_referenced_at) +SELECT content_id, COUNT(*) AS reference_count, NOW() AS first_referenced_at +FROM ( + SELECT invoice_content_id AS content_id FROM invoice WHERE invoice_content_id IS NOT NULL + UNION ALL + SELECT document_file_id AS content_id FROM document WHERE document_file_id IS NOT NULL + -- ... add one UNION ALL per content attribute per entity table +) AS all_refs +GROUP BY content_id +ON CONFLICT (content_id) DO UPDATE + SET reference_count = EXCLUDED.reference_count; +``` + +Run this in a maintenance window before enabling the deletion job for the first time. diff --git a/contentgrid-appserver-content-lifecycle/build.gradle b/contentgrid-appserver-content-lifecycle/build.gradle new file mode 100644 index 000000000..30db788d5 --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'java-library' + id 'maven-publish' + id 'io.freefair.lombok' +} + +dependencies { + api project(':contentgrid-appserver-contentstore-api') + implementation project(':contentgrid-appserver-application-model') + implementation project(':contentgrid-appserver-query-engine-api') + implementation 'org.jooq:jooq' + implementation 'org.springframework:spring-tx' + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'io.micrometer:micrometer-core' + implementation 'org.slf4j:slf4j-api' + runtimeOnly 'org.postgresql:postgresql' + + annotationProcessor "org.jooq:jooq-checker:3.20.11" + testAnnotationProcessor "org.jooq:jooq-checker:3.20.11" + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-jooq' + testImplementation 'org.assertj:assertj-core' + testImplementation 'org.mockito:mockito-junit-jupiter' + testImplementation testFixtures(project(':contentgrid-appserver-application-model')) + testImplementation testFixtures(project(':contentgrid-appserver-contentstore-impl-utils')) +} + +tasks.withType(JavaCompile) { + options.fork = true + options.compilerArgs += [ + '-processor', 'org.jooq.checker.PlainSQLChecker' + + ',lombok.launch.AnnotationProcessorHider$AnnotationProcessor' + ] + options.forkOptions.jvmArgs += [ + "--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", + "--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED", + "--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED", + "--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED", + "--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED", + "--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED", + "--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED", + "--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED", + "--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED" + ] +} diff --git a/contentgrid-appserver-content-lifecycle/gradle.properties b/contentgrid-appserver-content-lifecycle/gradle.properties new file mode 100644 index 000000000..fd86aa3c3 --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/gradle.properties @@ -0,0 +1 @@ +description=Content lifecycle tracking and deletion for the Appserver diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentDeletionJob.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentDeletionJob.java new file mode 100644 index 000000000..c10296e11 --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentDeletionJob.java @@ -0,0 +1,101 @@ +package com.contentgrid.appserver.content.lifecycle; + +import com.contentgrid.appserver.application.model.Application; +import com.contentgrid.appserver.contentstore.api.ContentReference; +import com.contentgrid.appserver.contentstore.api.ContentStore; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import java.time.OffsetDateTime; +import lombok.extern.slf4j.Slf4j; +import org.jooq.DSLContext; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; + +/** + * Deletes content objects from the {@link ContentStore} that have been marked for deletion and whose grace period + * has elapsed. Intended to be invoked as a K8s CronJob via Spring Boot's {@link ApplicationRunner} mechanism. + * + *

For each candidate: + *

    + *
  1. Performs a safety check to confirm the content is truly unreferenced (drift detection). + *
  2. If unreferenced: deletes from the content store and removes the tracking row. + *
  3. If still referenced (drift): clears the deletion mark and records a drift metric. + *
  4. On any failure: records a failure metric and continues to the next candidate. + *
+ */ +@Slf4j +public class ContentDeletionJob implements ApplicationRunner { + + private final DSLContext dslContext; + private final ContentStore contentStore; + private final ContentReferenceVerificationQuery verificationQuery; + private final Application application; + private final ContentLifecycleProperties properties; + private final Counter successCounter; + private final Counter failureCounter; + private final Counter driftCounter; + + public ContentDeletionJob( + DSLContext dslContext, + ContentStore contentStore, + ContentReferenceVerificationQuery verificationQuery, + Application application, + MeterRegistry meterRegistry, + ContentLifecycleProperties properties) { + this.dslContext = dslContext; + this.contentStore = contentStore; + this.verificationQuery = verificationQuery; + this.application = application; + this.properties = properties; + this.successCounter = Counter.builder("content.deletion.success") + .description("Number of content objects successfully deleted") + .register(meterRegistry); + this.failureCounter = Counter.builder("content.deletion.failure") + .description("Number of content deletion attempts that failed") + .register(meterRegistry); + this.driftCounter = Counter.builder("content.deletion.drift") + .description("Number of content objects marked for deletion but found to still be referenced") + .register(meterRegistry); + } + + @Override + public void run(ApplicationArguments args) { + var cutoff = OffsetDateTime.now().minus(properties.getDeletion().getGracePeriod()); + + var candidates = dslContext + .select(ContentReferenceTable.CONTENT_ID) + .from(ContentReferenceTable.TABLE) + .where(ContentReferenceTable.MARKED_FOR_DELETION_AT.lessOrEqual(cutoff)) + .limit(properties.getDeletion().getBatchSize()) + .fetch(ContentReferenceTable.CONTENT_ID); + + log.info("Processing {} content deletion candidates", candidates.size()); + + for (var contentId : candidates) { + processDeletionCandidate(ContentReference.of(contentId)); + } + } + + private void processDeletionCandidate(ContentReference ref) { + try { + if (verificationQuery.isReferenced(application, ref)) { + dslContext.update(ContentReferenceTable.TABLE) + .set(ContentReferenceTable.MARKED_FOR_DELETION_AT, (OffsetDateTime) null) + .where(ContentReferenceTable.CONTENT_ID.eq(ref.getValue())) + .execute(); + driftCounter.increment(); + log.warn("Drift detected: content {} is still referenced; clearing deletion mark", ref.getValue()); + } else { + contentStore.remove(ref); + dslContext.deleteFrom(ContentReferenceTable.TABLE) + .where(ContentReferenceTable.CONTENT_ID.eq(ref.getValue())) + .execute(); + successCounter.increment(); + log.debug("Deleted content {}", ref.getValue()); + } + } catch (Exception e) { + failureCounter.increment(); + log.error("Failed to process content deletion for {}", ref.getValue(), e); + } + } +} diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentLifecycleProperties.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentLifecycleProperties.java new file mode 100644 index 000000000..cbfbe1dda --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentLifecycleProperties.java @@ -0,0 +1,30 @@ +package com.contentgrid.appserver.content.lifecycle; + +import java.time.Duration; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("contentgrid.appserver.content.lifecycle") +@Getter +@Setter +public class ContentLifecycleProperties { + + private Deletion deletion = new Deletion(); + + @Getter + @Setter + public static class Deletion { + + /** + * Grace period after a content object is dereferenced before it becomes eligible for deletion. + * Expressed as an ISO-8601 duration (e.g. {@code P7D} for 7 days). + */ + private Duration gracePeriod = Duration.parse("P7D"); + + /** + * Maximum number of deletion candidates processed per job run. + */ + private int batchSize = 100; + } +} diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceTable.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceTable.java new file mode 100644 index 000000000..f64078bc0 --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceTable.java @@ -0,0 +1,22 @@ +package com.contentgrid.appserver.content.lifecycle; + +import java.time.OffsetDateTime; +import org.jooq.Allow; +import org.jooq.Field; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +@Allow.PlainSQL +final class ContentReferenceTable { + + static final Table TABLE = DSL.table("_content_references"); + + static final Field CONTENT_ID = DSL.field("content_id", SQLDataType.VARCHAR.nullable(false)); + static final Field REFERENCE_COUNT = DSL.field("reference_count", SQLDataType.INTEGER.nullable(false)); + static final Field FIRST_REFERENCED_AT = DSL.field("first_referenced_at", SQLDataType.TIMESTAMPWITHTIMEZONE.nullable(false)); + static final Field LAST_DEREFERENCED_AT = DSL.field("last_dereferenced_at", SQLDataType.TIMESTAMPWITHTIMEZONE.nullable(true)); + static final Field MARKED_FOR_DELETION_AT = DSL.field("marked_for_deletion_at", SQLDataType.TIMESTAMPWITHTIMEZONE.nullable(true)); + + private ContentReferenceTable() {} +} diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceTracker.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceTracker.java new file mode 100644 index 000000000..25314b85f --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceTracker.java @@ -0,0 +1,11 @@ +package com.contentgrid.appserver.content.lifecycle; + +import com.contentgrid.appserver.contentstore.api.ContentReference; + +public interface ContentReferenceTracker { + + void incrementReference(ContentReference ref); + + void decrementReference(ContentReference ref); + +} diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceVerificationQuery.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceVerificationQuery.java new file mode 100644 index 000000000..0d51bb4cc --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/ContentReferenceVerificationQuery.java @@ -0,0 +1,16 @@ +package com.contentgrid.appserver.content.lifecycle; + +import com.contentgrid.appserver.application.model.Application; +import com.contentgrid.appserver.contentstore.api.ContentReference; + +/** + * Verifies whether a content object is still referenced by any entity in the application data model. + * Used as a safety check before physically deleting content from the content store. + */ +public interface ContentReferenceVerificationQuery { + + /** + * Returns true if the given content reference is still referenced by at least one entity in any entity table. + */ + boolean isReferenced(Application application, ContentReference ref); +} diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/DeferredContentReferenceTracker.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/DeferredContentReferenceTracker.java new file mode 100644 index 000000000..cb36400a9 --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/DeferredContentReferenceTracker.java @@ -0,0 +1,37 @@ +package com.contentgrid.appserver.content.lifecycle; + +import com.contentgrid.appserver.contentstore.api.ContentReference; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** + * Wraps a {@link ContentReferenceTracker} to defer {@link #decrementReference} until after the current transaction + * commits. If no transaction is active, the decrement is called directly. + *

+ * This prevents premature deletion markers when the entity transaction rolls back. + */ +@RequiredArgsConstructor +public class DeferredContentReferenceTracker implements ContentReferenceTracker { + + private final ContentReferenceTracker delegate; + + @Override + public void incrementReference(ContentReference ref) { + delegate.incrementReference(ref); + } + + @Override + public void decrementReference(ContentReference ref) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + delegate.decrementReference(ref); + } + }); + } else { + delegate.decrementReference(ref); + } + } +} diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceTracker.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceTracker.java new file mode 100644 index 000000000..a05647b07 --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceTracker.java @@ -0,0 +1,39 @@ +package com.contentgrid.appserver.content.lifecycle; + +import com.contentgrid.appserver.contentstore.api.ContentReference; +import java.time.OffsetDateTime; +import lombok.RequiredArgsConstructor; +import org.jooq.DSLContext; +import org.jooq.impl.DSL; + +@RequiredArgsConstructor +public class JooqContentReferenceTracker implements ContentReferenceTracker { + + private final DSLContext dslContext; + + @Override + public void incrementReference(ContentReference ref) { + dslContext.insertInto(ContentReferenceTable.TABLE) + .set(ContentReferenceTable.CONTENT_ID, ref.getValue()) + .set(ContentReferenceTable.REFERENCE_COUNT, 1) + .set(ContentReferenceTable.FIRST_REFERENCED_AT, OffsetDateTime.now()) + .onConflict(ContentReferenceTable.CONTENT_ID) + .doUpdate() + .set(ContentReferenceTable.REFERENCE_COUNT, + DSL.field(DSL.name("_content_references", "reference_count"), Integer.class).add(1)) + .set(ContentReferenceTable.MARKED_FOR_DELETION_AT, (OffsetDateTime) null) + .execute(); + } + + @Override + public void decrementReference(ContentReference ref) { + dslContext.update(ContentReferenceTable.TABLE) + .set(ContentReferenceTable.REFERENCE_COUNT, ContentReferenceTable.REFERENCE_COUNT.subtract(1)) + .set(ContentReferenceTable.LAST_DEREFERENCED_AT, OffsetDateTime.now()) + .set(ContentReferenceTable.MARKED_FOR_DELETION_AT, + DSL.when(ContentReferenceTable.REFERENCE_COUNT.subtract(1).eq(0), OffsetDateTime.now()) + .otherwise(ContentReferenceTable.MARKED_FOR_DELETION_AT)) + .where(ContentReferenceTable.CONTENT_ID.eq(ref.getValue())) + .execute(); + } +} diff --git a/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceVerificationQuery.java b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceVerificationQuery.java new file mode 100644 index 000000000..dbed8c0c7 --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/src/main/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceVerificationQuery.java @@ -0,0 +1,29 @@ +package com.contentgrid.appserver.content.lifecycle; + +import com.contentgrid.appserver.application.model.Application; +import com.contentgrid.appserver.contentstore.api.ContentReference; +import lombok.RequiredArgsConstructor; +import org.jooq.Allow; +import org.jooq.DSLContext; +import org.jooq.impl.DSL; + +@RequiredArgsConstructor +public class JooqContentReferenceVerificationQuery implements ContentReferenceVerificationQuery { + + private final DSLContext dslContext; + + @Override + @Allow.PlainSQL + public boolean isReferenced(Application application, ContentReference ref) { + return application.getEntities().stream() + .anyMatch(entity -> entity.getContentAttributes().stream() + .anyMatch(contentAttribute -> dslContext.fetchExists( + dslContext.selectOne() + .from(DSL.table(entity.getTable().getValue())) + .where(DSL.field( + contentAttribute.getId().getColumn().getValue(), + String.class + ).eq(ref.getValue())) + ))); + } +} diff --git a/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/ContentDeletionJobTest.java b/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/ContentDeletionJobTest.java new file mode 100644 index 000000000..d96ec8c67 --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/ContentDeletionJobTest.java @@ -0,0 +1,125 @@ +package com.contentgrid.appserver.content.lifecycle; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.contentgrid.appserver.application.model.Application; +import com.contentgrid.appserver.contentstore.api.ContentReference; +import com.contentgrid.appserver.contentstore.api.ContentStore; +import com.contentgrid.appserver.contentstore.api.UnwritableContentException; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import java.util.List; +import org.jooq.Condition; +import org.jooq.DSLContext; +import org.jooq.Field; +import org.jooq.SelectField; +import org.jooq.Table; +import org.mockito.Answers; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.DefaultApplicationArguments; + +@ExtendWith(MockitoExtension.class) +class ContentDeletionJobTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + DSLContext dslContext; + + @Mock + ContentStore contentStore; + + @Mock + ContentReferenceVerificationQuery verificationQuery; + + @Mock + Application application; + + SimpleMeterRegistry meterRegistry; + ContentLifecycleProperties properties; + ContentDeletionJob job; + + @BeforeEach + void setup() { + meterRegistry = new SimpleMeterRegistry(); + properties = new ContentLifecycleProperties(); + job = new ContentDeletionJob(dslContext, contentStore, verificationQuery, application, meterRegistry, properties); + } + + private void givenCandidates(List contentIds) { + given(dslContext.select(ArgumentMatchers.>any()) + .from(ArgumentMatchers.>any()) + .where(ArgumentMatchers.any()) + .limit(anyInt()) + .fetch(ArgumentMatchers.>any())) + .willReturn(contentIds); + } + + private double getCounter(String name) { + Counter counter = meterRegistry.find(name).counter(); + return counter != null ? counter.count() : 0.0; + } + + @Test + void candidate_unreferenced_isDeleted() throws Exception { + var contentId = "content-abc.bin"; + givenCandidates(List.of(contentId)); + given(verificationQuery.isReferenced(any(), any())).willReturn(false); + + job.run(new DefaultApplicationArguments()); + + verify(contentStore).remove(ContentReference.of(contentId)); + assertThat(getCounter("content.deletion.success")).isEqualTo(1.0); + assertThat(getCounter("content.deletion.failure")).isEqualTo(0.0); + assertThat(getCounter("content.deletion.drift")).isEqualTo(0.0); + } + + @Test + void noCandidates_withinGracePeriod_nothingDeleted() throws Exception { + givenCandidates(List.of()); + + job.run(new DefaultApplicationArguments()); + + verify(contentStore, never()).remove(any()); + assertThat(getCounter("content.deletion.success")).isEqualTo(0.0); + } + + @Test + void candidate_drift_markerCleared_noDelete() throws Exception { + var contentId = "content-drift.bin"; + givenCandidates(List.of(contentId)); + given(verificationQuery.isReferenced(any(), any())).willReturn(true); + + job.run(new DefaultApplicationArguments()); + + verify(contentStore, never()).remove(any()); + assertThat(getCounter("content.deletion.drift")).isEqualTo(1.0); + assertThat(getCounter("content.deletion.success")).isEqualTo(0.0); + } + + @Test + void candidate_contentStoreRemoveFails_continuesAndRecordsFailure() throws Exception { + var contentId1 = "content-fail.bin"; + var contentId2 = "content-ok.bin"; + givenCandidates(List.of(contentId1, contentId2)); + given(verificationQuery.isReferenced(any(), any())).willReturn(false); + doThrow(new UnwritableContentException(ContentReference.of(contentId1))) + .when(contentStore).remove(ContentReference.of(contentId1)); + + job.run(new DefaultApplicationArguments()); + + verify(contentStore).remove(ContentReference.of(contentId1)); + verify(contentStore).remove(ContentReference.of(contentId2)); + assertThat(getCounter("content.deletion.failure")).isEqualTo(1.0); + assertThat(getCounter("content.deletion.success")).isEqualTo(1.0); + } +} diff --git a/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/DeferredContentReferenceTrackerTest.java b/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/DeferredContentReferenceTrackerTest.java new file mode 100644 index 000000000..3ea094432 --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/DeferredContentReferenceTrackerTest.java @@ -0,0 +1,82 @@ +package com.contentgrid.appserver.content.lifecycle; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.contentgrid.appserver.contentstore.api.ContentReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@ExtendWith(MockitoExtension.class) +class DeferredContentReferenceTrackerTest { + + @Mock + ContentReferenceTracker delegate; + + @Test + void incrementReference_alwaysDelegatesDirectly() { + var tracker = new DeferredContentReferenceTracker(delegate); + var ref = ContentReference.of("test-content"); + + TransactionSynchronizationManager.initSynchronization(); + try { + tracker.incrementReference(ref); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + + verify(delegate).incrementReference(ref); + } + + @Test + void decrementReference_outsideTransaction_callsDirectly() { + var tracker = new DeferredContentReferenceTracker(delegate); + var ref = ContentReference.of("test-content"); + + tracker.decrementReference(ref); + + verify(delegate).decrementReference(ref); + } + + @Test + void decrementReference_insideTransaction_deferredToAfterCommit() { + var tracker = new DeferredContentReferenceTracker(delegate); + var ref = ContentReference.of("test-content"); + + TransactionSynchronizationManager.initSynchronization(); + try { + tracker.decrementReference(ref); + + // Should NOT be called yet — still inside transaction + verify(delegate, never()).decrementReference(ref); + + // Simulate commit: invoke afterCommit on all synchronizations + TransactionSynchronizationManager.getSynchronizations() + .forEach(s -> s.afterCommit()); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + + // Should be called now + verify(delegate).decrementReference(ref); + } + + @Test + void decrementReference_insideTransaction_notCalledOnRollback() { + var tracker = new DeferredContentReferenceTracker(delegate); + var ref = ContentReference.of("test-content"); + + TransactionSynchronizationManager.initSynchronization(); + try { + tracker.decrementReference(ref); + } finally { + // Simulate rollback: just clear synchronizations without invoking afterCommit + TransactionSynchronizationManager.clearSynchronization(); + } + + verify(delegate, never()).decrementReference(ref); + } +} diff --git a/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceTrackerTest.java b/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceTrackerTest.java new file mode 100644 index 000000000..bff67e1fd --- /dev/null +++ b/contentgrid-appserver-content-lifecycle/src/test/java/com/contentgrid/appserver/content/lifecycle/JooqContentReferenceTrackerTest.java @@ -0,0 +1,51 @@ +package com.contentgrid.appserver.content.lifecycle; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.then; + +import com.contentgrid.appserver.contentstore.api.ContentReference; +import java.time.OffsetDateTime; +import org.jooq.DSLContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class JooqContentReferenceTrackerTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + DSLContext dslContext; + + JooqContentReferenceTracker tracker; + + @BeforeEach + void setup() { + tracker = new JooqContentReferenceTracker(dslContext); + } + + @Test + void incrementReference_insertsOrIncrementsRow() { + var ref = ContentReference.of("content-abc"); + + tracker.incrementReference(ref); + + // Verify the upsert chain was called with the content_id + then(dslContext).should().insertInto(ContentReferenceTable.TABLE); + } + + @Test + void decrementReference_updatesRow() { + var ref = ContentReference.of("content-abc"); + + tracker.decrementReference(ref); + + // Verify the update chain was invoked + then(dslContext).should().update(ContentReferenceTable.TABLE); + } +} diff --git a/contentgrid-appserver-domain/build.gradle b/contentgrid-appserver-domain/build.gradle index 47ca17e69..9856f478a 100644 --- a/contentgrid-appserver-domain/build.gradle +++ b/contentgrid-appserver-domain/build.gradle @@ -11,6 +11,7 @@ dependencies { api 'com.fasterxml.jackson.core:jackson-core' implementation 'org.slf4j:slf4j-api' + implementation project(':contentgrid-appserver-content-lifecycle') implementation project(':contentgrid-appserver-contentstore-api') implementation 'org.springframework:spring-core' implementation 'com.contentgrid.hateoas:contentgrid-pagination-offset:0.0.4' diff --git a/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/DatamodelApiImpl.java b/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/DatamodelApiImpl.java index 21553b8a2..0c7077f7d 100644 --- a/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/DatamodelApiImpl.java +++ b/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/DatamodelApiImpl.java @@ -1,8 +1,12 @@ package com.contentgrid.appserver.domain; import com.contentgrid.appserver.application.model.Application; +import com.contentgrid.appserver.content.lifecycle.ContentReferenceTracker; import com.contentgrid.appserver.application.model.Entity; +import com.contentgrid.appserver.contentstore.api.ContentReference; +import com.contentgrid.appserver.application.model.attributes.ContentAttribute; import com.contentgrid.appserver.application.model.attributes.Attribute; +import com.contentgrid.appserver.application.model.values.AttributeName; import com.contentgrid.appserver.application.model.values.EntityName; import com.contentgrid.appserver.contentstore.api.ContentStore; import com.contentgrid.appserver.domain.authorization.AuthorizationContext; @@ -49,7 +53,9 @@ import com.contentgrid.appserver.query.engine.api.UnlinkEventConsumer; import com.contentgrid.appserver.query.engine.api.UpdateEventConsumer; import com.contentgrid.appserver.query.engine.api.data.AttributeData; +import com.contentgrid.appserver.query.engine.api.data.CompositeAttributeData; import com.contentgrid.appserver.query.engine.api.data.EntityCreateData; +import com.contentgrid.appserver.query.engine.api.data.SimpleAttributeData; import com.contentgrid.appserver.query.engine.api.data.EntityData; import com.contentgrid.appserver.query.engine.api.data.OffsetData; import com.contentgrid.appserver.query.engine.api.data.SortData; @@ -68,6 +74,7 @@ import java.util.Optional; import java.util.Set; import java.util.function.Supplier; +import java.util.stream.Stream; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -81,6 +88,7 @@ public class DatamodelApiImpl implements DatamodelApi { private final DomainEventDispatcher domainEventDispatcher; private final CursorCodec cursorCodec; private final Clock clock; + private final ContentReferenceTracker contentReferenceTracker; private RequestInputDataMapper createInputDataMapper( @NonNull Application application, @@ -109,7 +117,7 @@ private RequestInputDataMapper createInputDataMapper( )) .andThen(new OptionalFlatMapAdaptingMapper<>( AttributeAndRelationMapper.from( - new ContentUploadAttributeMapper(contentStore), + new ContentUploadAttributeMapper(contentStore, contentReferenceTracker), (rel, value) -> Optional.of(value) ) )) @@ -273,6 +281,7 @@ public InternalEntityInstance update(@NonNull Application application, @NonNull AuthorizationContext authorizationContext ) throws QueryEngineException, InvalidPropertyDataException { + var contentValidator = new ContentAttributeModificationValidator(existingEntity); var inputMapper = createInputDataMapper( application, existingEntity.getIdentity().getEntityName(), @@ -281,7 +290,7 @@ public InternalEntityInstance update(@NonNull Application application, // Validate that content attribute is not partially set .andThen(new OptionalFlatMapAdaptingMapper<>( AttributeAndRelationMapper.from( - new AttributeValidationDataMapper(new ContentAttributeModificationValidator(existingEntity)), + new AttributeValidationDataMapper(contentValidator), (rel, d) -> Optional.of(d) ) )), @@ -303,6 +312,12 @@ public InternalEntityInstance update(@NonNull Application application, UpdateEventConsumer onUpdate = new EventConsumerImpl(outputMapper); var updateData = queryEngine.update(application, entityData, authorizationContext.predicate(), onUpdate); + if (existingEntity instanceof InternalEntityInstance internalExisting) { + extractClearedContentReferences(application, existingEntity.getIdentity().getEntityName(), + internalExisting, contentValidator.getDereferencedAttributeNames()) + .forEach(contentReferenceTracker::decrementReference); + } + return outputMapper.mapAttributes(updateData.getUpdated()); } @@ -312,6 +327,7 @@ public InternalEntityInstance updatePartial(@NonNull Application application, @NonNull RequestInputData data, @NonNull AuthorizationContext authorizationContext ) throws QueryEngineException, InvalidPropertyDataException { + var contentValidator = new ContentAttributeModificationValidator(existingEntity); var inputMapper = createInputDataMapper( application, existingEntity.getIdentity().getEntityName(), @@ -320,7 +336,7 @@ public InternalEntityInstance updatePartial(@NonNull Application application, // Validate that content attribute is not partially set .andThen(new OptionalFlatMapAdaptingMapper<>( AttributeAndRelationMapper.from( - new AttributeValidationDataMapper(new ContentAttributeModificationValidator(existingEntity)), + new AttributeValidationDataMapper(contentValidator), (rel, d) -> Optional.of(d) ) )), @@ -342,6 +358,12 @@ public InternalEntityInstance updatePartial(@NonNull Application application, UpdateEventConsumer onUpdate = new EventConsumerImpl(outputMapper); var updateData = queryEngine.update(application, entityData, authorizationContext.predicate(), onUpdate); + if (existingEntity instanceof InternalEntityInstance internalExisting) { + extractClearedContentReferences(application, existingEntity.getIdentity().getEntityName(), + internalExisting, contentValidator.getDereferencedAttributeNames()) + .forEach(contentReferenceTracker::decrementReference); + } + return outputMapper.mapAttributes(updateData.getUpdated()); } @@ -354,7 +376,38 @@ public InternalEntityInstance deleteEntity(@NonNull Application application, @No var deleted = queryEngine.delete(application, entityRequest, authorizationContext.predicate(), onDelete) .orElseThrow(() -> new EntityIdNotFoundException(entityRequest)); - return outputMapper.mapAttributes(deleted); + var deletedInstance = outputMapper.mapAttributes(deleted); + extractContentReferences(application, entityRequest.getEntityName(), deletedInstance) + .forEach(contentReferenceTracker::decrementReference); + + return deletedInstance; + } + + private List extractContentReferences(Application application, EntityName entityName, InternalEntityInstance entityInstance) { + return application.getRequiredEntityByName(entityName).getContentAttributes().stream() + .flatMap(contentAttribute -> extractContentReference(entityInstance, contentAttribute).stream()) + .toList(); + } + + private List extractClearedContentReferences(Application application, EntityName entityName, + InternalEntityInstance entityInstance, List clearedAttributeNames) { + var entity = application.getRequiredEntityByName(entityName); + return clearedAttributeNames.stream() + .flatMap(name -> entity.getContentAttributes().stream() + .filter(ca -> ca.getName().equals(name)) + .findFirst() + .flatMap(ca -> extractContentReference(entityInstance, ca)) + .stream()) + .toList(); + } + + private Optional extractContentReference(InternalEntityInstance entityInstance, ContentAttribute contentAttribute) { + return entityInstance.getByAttributeName(contentAttribute.getName(), CompositeAttributeData.class) + .flatMap(data -> data.getAttributeByName(contentAttribute.getId().getName())) + .filter(SimpleAttributeData.class::isInstance) + .map(SimpleAttributeData.class::cast) + .map(d -> (String) d.getValue()) + .map(ContentReference::of); } @Override diff --git a/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/mapper/ContentUploadAttributeMapper.java b/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/mapper/ContentUploadAttributeMapper.java index 5d36c86f8..7fbf814fa 100644 --- a/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/mapper/ContentUploadAttributeMapper.java +++ b/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/mapper/ContentUploadAttributeMapper.java @@ -4,6 +4,7 @@ import com.contentgrid.appserver.application.model.attributes.ContentAttribute; import com.contentgrid.appserver.application.model.attributes.SimpleAttribute; import com.contentgrid.appserver.application.model.values.AttributePath; +import com.contentgrid.appserver.content.lifecycle.ContentReferenceTracker; import com.contentgrid.appserver.contentstore.api.ContentStore; import com.contentgrid.appserver.contentstore.api.UnwritableContentException; import com.contentgrid.appserver.domain.data.DataEntry; @@ -26,6 +27,7 @@ public class ContentUploadAttributeMapper extends AbstractDescendingAttributeMapper { private final ContentStore contentStore; + private final ContentReferenceTracker contentReferenceTracker; @Override protected Optional mapSimpleAttribute(AttributePath path, SimpleAttribute simpleAttribute, DataEntry inputData) { @@ -68,6 +70,8 @@ protected Optional mapCompositeAttributeUnsupportedDatatype(Attribute var inputStream = new CountingInputStream(fileDataEntry.getInputStream()); var contentAccessor = contentStore.writeContent(inputStream); + contentReferenceTracker.incrementReference(contentAccessor.getReference()); + var builder = MapDataEntry.builder(); builder.item(contentAttribute.getId().getName().getValue(), new StringDataEntry(contentAccessor.getReference().getValue())) .item(contentAttribute.getLength().getName().getValue(), new LongDataEntry(inputStream.getSize())) diff --git a/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/validation/ContentAttributeModificationValidator.java b/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/validation/ContentAttributeModificationValidator.java index af3e7031e..d76f741f3 100644 --- a/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/validation/ContentAttributeModificationValidator.java +++ b/contentgrid-appserver-domain/src/main/java/com/contentgrid/appserver/domain/data/validation/ContentAttributeModificationValidator.java @@ -2,6 +2,7 @@ import com.contentgrid.appserver.application.model.attributes.Attribute; import com.contentgrid.appserver.application.model.attributes.ContentAttribute; +import com.contentgrid.appserver.application.model.values.AttributeName; import com.contentgrid.appserver.application.model.values.AttributePath; import com.contentgrid.appserver.domain.data.DataEntry; import com.contentgrid.appserver.domain.data.DataEntry.MapDataEntry; @@ -11,6 +12,9 @@ import com.contentgrid.appserver.domain.data.EntityInstance; import com.contentgrid.appserver.domain.data.InvalidDataException; import com.contentgrid.appserver.domain.data.validation.AttributeValidationDataMapper.Validator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -19,6 +23,11 @@ @Slf4j public class ContentAttributeModificationValidator implements Validator { private final EntityInstance entityData; + private final List dereferencedAttributeNames = new ArrayList<>(); + + public List getDereferencedAttributeNames() { + return Collections.unmodifiableList(dereferencedAttributeNames); + } @Override public void validate(AttributePath attributePath, Attribute attribute, DataEntry dataEntry) @@ -45,8 +54,7 @@ public void validate(AttributePath attributePath, Attribute attribute, DataEntry } } } else if (hasContent && dataEntry instanceof NullDataEntry) { - // TODO: mark content for deletion, it can only be deleted in ContentStore - // after database transaction has completed + dereferencedAttributeNames.add(attributePath.getFirst()); } } diff --git a/contentgrid-appserver-domain/src/test/java/com/contentgrid/appserver/domain/DatamodelApiImplTest.java b/contentgrid-appserver-domain/src/test/java/com/contentgrid/appserver/domain/DatamodelApiImplTest.java index 30cca285b..1010103e3 100644 --- a/contentgrid-appserver-domain/src/test/java/com/contentgrid/appserver/domain/DatamodelApiImplTest.java +++ b/contentgrid-appserver-domain/src/test/java/com/contentgrid/appserver/domain/DatamodelApiImplTest.java @@ -13,6 +13,7 @@ import com.contentgrid.appserver.application.model.values.EntityName; import com.contentgrid.appserver.application.model.values.RelationName; import com.contentgrid.appserver.application.model.values.SortableName; +import com.contentgrid.appserver.content.lifecycle.ContentReferenceTracker; import com.contentgrid.appserver.contentstore.api.ContentAccessor; import com.contentgrid.appserver.contentstore.api.ContentReference; import com.contentgrid.appserver.contentstore.api.ContentStore; @@ -106,6 +107,8 @@ class DatamodelApiImplTest { private ContentStore contentStore; @Mock private DomainEventDispatcher domainEventDispatcher; + @Mock + private ContentReferenceTracker contentReferenceTracker; @Spy private CursorCodec codec = new RequestIntegrityCheckCursorCodec(new SimplePageBasedCursorCodec()); @@ -142,7 +145,8 @@ void setup() { contentStore, domainEventDispatcher, codec, - clock + clock, + contentReferenceTracker ); } @@ -1698,4 +1702,143 @@ void deleteNonExistent() { } } + + @Nested + class ContentReferenceTracking { + + @Test + void upload_incrementsReference() throws InvalidPropertyDataException, UnwritableContentException { + var entityId = EntityId.of(UUID.randomUUID()); + var personId = EntityId.of(UUID.randomUUID()); + var fileId = "uploaded-file.bin"; + Mockito.when(queryEngine.create(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(EntityData.builder().name(INVOICE.getName()).id(entityId).build()); + Mockito.when(contentStore.writeContent(Mockito.any())).thenAnswer(contentAccessorFor(fileId)); + + datamodelApi.create(APPLICATION, INVOICE.getName(), MapRequestInputData.fromMap(Map.of( + "number", "invoice-1", + "amount", 1.50, + "confidentiality", "public", + "content", new FileDataEntry("file.pdf", "application/pdf", inputStreamWithSize(10)), + "customer", new RelationDataEntry(PERSON.getName(), personId) + )), AuthorizationContext.allowAll()); + + Mockito.verify(contentReferenceTracker).incrementReference(ContentReference.of(fileId)); + } + + @Test + void update_contentCleared_decrementsReference() throws InvalidPropertyDataException { + var entityId = EntityId.of(UUID.randomUUID()); + var existingContentId = "existing-content.bin"; + setupEntityQueryWithContent(existingContentId); + var entity = EntityData.builder().name(INVOICE.getName()).id(entityId).build(); + Mockito.when(queryEngine.update(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(new UpdateResult(entity, entity)); + + datamodelApi.update(APPLICATION, EntityRequest.forEntity(INVOICE.getName(), entityId), + MapRequestInputData.fromMap(Map.of( + "number", "invoice-1", + "amount", 1.50, + "confidentiality", "public", + "content", NullDataEntry.INSTANCE + )), AuthorizationContext.allowAll()); + + Mockito.verify(contentReferenceTracker).decrementReference(ContentReference.of(existingContentId)); + } + + @Test + void update_contentWasEmpty_doesNotDecrementReference() throws InvalidPropertyDataException { + var entityId = EntityId.of(UUID.randomUUID()); + setupEntityQuery(); // existing entity has no content + var entity = EntityData.builder().name(INVOICE.getName()).id(entityId).build(); + Mockito.when(queryEngine.update(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(new UpdateResult(entity, entity)); + + datamodelApi.update(APPLICATION, EntityRequest.forEntity(INVOICE.getName(), entityId), + MapRequestInputData.fromMap(Map.of( + "number", "invoice-1", + "amount", 1.50, + "confidentiality", "public", + "content", NullDataEntry.INSTANCE + )), AuthorizationContext.allowAll()); + + Mockito.verifyNoInteractions(contentReferenceTracker); + } + + @Test + void partialUpdate_contentCleared_decrementsReference() throws InvalidPropertyDataException { + var entityId = EntityId.of(UUID.randomUUID()); + var existingContentId = "existing-content.bin"; + setupEntityQueryWithContent(existingContentId); + var entity = EntityData.builder().name(INVOICE.getName()).id(entityId).build(); + Mockito.when(queryEngine.update(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(new UpdateResult(entity, entity)); + + datamodelApi.updatePartial(APPLICATION, EntityRequest.forEntity(INVOICE.getName(), entityId), + MapRequestInputData.fromMap(Map.of( + "content", NullDataEntry.INSTANCE + )), AuthorizationContext.allowAll()); + + Mockito.verify(contentReferenceTracker).decrementReference(ContentReference.of(existingContentId)); + } + + @Test + void deleteEntity_withContent_decrementsReference() { + var entityId = EntityId.of(UUID.randomUUID()); + var contentId = "content-to-delete.bin"; + var deletedData = EntityData.builder() + .name(INVOICE.getName()) + .id(entityId) + .attribute(CompositeAttributeData.builder() + .name(INVOICE_CONTENT.getName()) + .attribute(new SimpleAttributeData<>(INVOICE_CONTENT.getId().getName(), contentId)) + .build()) + .build(); + Mockito.when(queryEngine.delete(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(deletedData)); + + datamodelApi.deleteEntity(APPLICATION, EntityRequest.forEntity(INVOICE.getName(), entityId), + AuthorizationContext.allowAll()); + + Mockito.verify(contentReferenceTracker).decrementReference(ContentReference.of(contentId)); + } + + @Test + void deleteEntity_withoutContent_doesNotDecrementReference() { + var entityId = EntityId.of(UUID.randomUUID()); + var deletedData = EntityData.builder() + .name(INVOICE.getName()) + .id(entityId) + .attributes(List.of()) + .build(); + Mockito.when(queryEngine.delete(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(deletedData)); + + datamodelApi.deleteEntity(APPLICATION, EntityRequest.forEntity(INVOICE.getName(), entityId), + AuthorizationContext.allowAll()); + + Mockito.verifyNoInteractions(contentReferenceTracker); + } + + @Test + void deleteEntity_withEmptyContent_doesNotDecrementReference() { + var entityId = EntityId.of(UUID.randomUUID()); + // Content attribute present but id is null (no content was uploaded) + var deletedData = EntityData.builder() + .name(INVOICE.getName()) + .id(entityId) + .attribute(CompositeAttributeData.builder() + .name(INVOICE_CONTENT.getName()) + .attribute(new SimpleAttributeData<>(INVOICE_CONTENT.getId().getName(), (String) null)) + .build()) + .build(); + Mockito.when(queryEngine.delete(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())) + .thenReturn(Optional.of(deletedData)); + + datamodelApi.deleteEntity(APPLICATION, EntityRequest.forEntity(INVOICE.getName(), entityId), + AuthorizationContext.allowAll()); + + Mockito.verifyNoInteractions(contentReferenceTracker); + } + } } diff --git a/contentgrid-appserver-query-engine-impl-jooq/src/main/java/com/contentgrid/appserver/query/engine/jooq/JOOQTableCreator.java b/contentgrid-appserver-query-engine-impl-jooq/src/main/java/com/contentgrid/appserver/query/engine/jooq/JOOQTableCreator.java index b7c785bf1..515bdf800 100644 --- a/contentgrid-appserver-query-engine-impl-jooq/src/main/java/com/contentgrid/appserver/query/engine/jooq/JOOQTableCreator.java +++ b/contentgrid-appserver-query-engine-impl-jooq/src/main/java/com/contentgrid/appserver/query/engine/jooq/JOOQTableCreator.java @@ -35,6 +35,8 @@ public void createTables(Application application) { } // Create extensions schema and functions createCGPrefixSearchNormalize(dslContext); + // Create content lifecycle tracking table + createContentReferencesTable(dslContext); } private void createTableForEntity(DSLContext dslContext, Entity entity) { @@ -83,6 +85,25 @@ public void dropTables(Application application) { // Drop extensions schema and functions dropCGPrefixSearchNormalize(dslContext); + // Drop content lifecycle tracking table + dropContentReferencesTable(dslContext); + } + + @Allow.PlainSQL + private void createContentReferencesTable(DSLContext dslContext) { + dslContext.createTableIfNotExists("_content_references") + .column(DSL.field("content_id", String.class), org.jooq.impl.SQLDataType.VARCHAR.nullable(false)) + .column(DSL.field("reference_count", Integer.class), org.jooq.impl.SQLDataType.INTEGER.nullable(false)) + .column(DSL.field("first_referenced_at", java.time.OffsetDateTime.class), org.jooq.impl.SQLDataType.TIMESTAMPWITHTIMEZONE.nullable(false)) + .column(DSL.field("last_dereferenced_at", java.time.OffsetDateTime.class), org.jooq.impl.SQLDataType.TIMESTAMPWITHTIMEZONE.nullable(true)) + .column(DSL.field("marked_for_deletion_at", java.time.OffsetDateTime.class), org.jooq.impl.SQLDataType.TIMESTAMPWITHTIMEZONE.nullable(true)) + .primaryKey("content_id") + .execute(); + } + + @Allow.PlainSQL + private void dropContentReferencesTable(DSLContext dslContext) { + dslContext.dropTableIfExists("_content_references").execute(); } @Allow.PlainSQL diff --git a/openspec/changes/add-content-lifecycle/.openspec.yaml b/openspec/changes/add-content-lifecycle/.openspec.yaml new file mode 100644 index 000000000..3184e5abf --- /dev/null +++ b/openspec/changes/add-content-lifecycle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-06 diff --git a/openspec/changes/add-content-lifecycle/design.md b/openspec/changes/add-content-lifecycle/design.md new file mode 100644 index 000000000..778211ce9 --- /dev/null +++ b/openspec/changes/add-content-lifecycle/design.md @@ -0,0 +1,141 @@ +## Context + +The `ContentStore` stores binary content objects identified by a `ContentReference`. Currently, `ContentStore.remove()` +is never called in production — content objects accumulate indefinitely. Two additional requirements shape this design +beyond simple deletion: + +1. **Content sharing**: Multiple entities may reference the same content object (same `ContentReference`). Deleting + content when one entity is removed must not affect others still pointing to it. +2. **Grace period**: Content must not be deleted immediately after dereference — a configurable delay (default 7 days) + ensures backups are taken before permanent removal. + +The domain layer already has all required integration points: + +- `ContentUploadAttributeMapper`: writes content to the store (increment point) +- `DatamodelApiImpl.updatePartial()` / `update()`: entity content replaced or cleared (decrement point) +- `DatamodelApiImpl.deleteEntity()`: entity removed (decrement point for all content attributes) +- `ContentAttributeModificationValidator`: detects when a content attribute is being set to null + +## Goals / Non-Goals + +**Goals:** + +- Track reference counts for every content object in a `_content_references` table. +- Increment on upload; decrement after a successful transaction commit when content is dereferenced. +- Mark content for deletion when its reference count reaches zero; do not delete immediately. +- A separately-triggered deletion job verifies references and removes content past the grace period. +- Support content sharing: multiple entities pointing to the same `ContentReference`. + +**Non-Goals:** + +- Retroactive cleanup of orphaned content from before this feature. +- Exposing a content-sharing API (future consideration). +- Two-phase commit or distributed transaction guarantees between the DB and the content store. +- Deduplication of identical content on upload. + +## Decisions + +### Decision 1: Reference counting in a dedicated `_content_references` table + +**Chosen:** A new table tracks `content_id`, `reference_count`, `first_referenced_at`, `last_dereferenced_at`, and +`marked_for_deletion_at`. + +**Alternatives considered:** + +- *Immediate deletion in the domain layer (no tracking table)*: Doesn't support content sharing or grace periods. +- *Scan all entity tables on every deletion*: Correct but slow — N queries per deletion where N = number of entity types + with content attributes. Acceptable for the deletion job (runs infrequently), not acceptable for request-time use. + +**Rationale:** A reference count table makes increment/decrement O(1) at request time. The deletion job uses it to find +candidates and the per-entity scan is only done as a safety check before final deletion. + +### Decision 2: New module `contentgrid-appserver-content-lifecycle` + +**Chosen:** All tracking and deletion logic lives in a new module. The domain module gains an optional dependency on it +via Spring auto-configuration. + +**Alternatives considered:** + +- *Inline in `contentgrid-appserver-domain`*: Couples database lifecycle concerns with the domain logic; harder to + disable or replace. + +**Rationale:** Keeping lifecycle tracking in its own module makes it independently deployable. The domain module depends +on the `ContentReferenceTracker` interface; the implementation is provided by the lifecycle module. + +### Decision 3: Decrement-after-commit via `TransactionSynchronizationManager` + +**Chosen:** `DeferredContentReferenceTracker` wraps `ContentReferenceTracker` and registers a +`TransactionSynchronizationAdapter` to call `decrementReference()` in the `afterCommit()` callback. + +**Alternatives considered:** + +- *Decrement inline (before commit)*: If the transaction rolls back, the decrement would incorrectly reduce the + reference count for a content object that is still referenced. +- *Async event after commit*: Introduces messaging infrastructure for a synchronous concern; adds complexity and failure + modes. + +**Rationale:** `TransactionSynchronizationManager.afterCommit()` is the standard Spring pattern for "do this only if the +transaction succeeded." The increment (on upload) does not need deferral because a rolled-back upload is simply an +orphaned object — it gets cleaned up by the same grace-period job. + +### Decision 4: Safety verification before deletion (verified reference counting) + +**Chosen:** Before the deletion job removes a content object, it queries every entity table that has content attributes +to confirm the content ID is truly not referenced anywhere. If drift is detected (count says zero but a live reference +is found), the deletion marker is cleared and the drift is logged and counted in metrics. + +**Alternatives considered:** + +- *Trust the reference count, skip verification*: Any count drift (e.g., from a bug, manual DB operation, migration) + would cause data loss. + +**Rationale:** Content deletion is irreversible. The safety query is cheap (runs infrequently in a batch job, not on +every request) and prevents silent data loss from drift. + +### Decision 5: K8s CronJob for deletion (not a scheduled Spring task) + +**Chosen:** A `ContentDeletionJob` is a one-shot Spring Boot job triggered by a K8s CronJob. + +**Alternatives considered:** + +- *`@Scheduled` inside the running app*: Runs in every replica; requires leader election to avoid duplicate deletions; + harder to observe and operate independently. + +**Rationale:** A separate K8s CronJob runs in a single pod, has full application context (DB + ContentStore), is +independently scalable, and is easy to trigger or disable without redeploying the main app. + +## Risks / Trade-offs + +- **Count drift from bugs or manual DB changes** → Safety verification in the deletion job catches this; drift is logged + with metrics for alerting. No data loss occurs, but the content is not deleted until drift is resolved. +- **Increment on upload has no rollback** → If an upload transaction rolls back after `writeContent()` but before the + increment is persisted, there is an orphaned content object with no tracking entry. The safety query will find no + references to it and the deletion job can clean it up (the missing reference count entry is treated as "no references, + safe to delete" only after the grace period — but without a `marked_for_deletion_at` it would never be found). * + *Mitigation:** The increment should be persisted in the same transaction as the entity write, so both commit or both + roll back together. +- **Race between decrement and CronJob** → If a CronJob run coincides with a decrement that hasn't fully committed yet, + the safety check catches it. +- **Grace period delays storage reclamation** → By design; default 7 days. Configurable. + +## Migration Plan + +1. Deploy the new module. `JOOQTableCreator.createTables()` now also creates the `_content_references` table (via the + JOOQ DSL — no Flyway migration; schema management is handled by an external project that calls `createTables()`). +2. Run the backfill SQL in the external schema management project to populate reference counts from existing entity data: + ```sql + INSERT INTO _content_references (content_id, reference_count, first_referenced_at) + SELECT content_id, COUNT(*), NOW() + FROM ( + -- one UNION ALL per entity/attribute combination with a content reference + ) AS all_refs + GROUP BY content_id; + ``` +3. Enable the deletion job (`contentgrid.content.lifecycle.deletion.enabled: true`) after the backfill has been + verified. + +## Open Questions + +- Should content upload increment be in the same JOOQ transaction as the entity write, or in a separate transaction? ( + Affects the rollback-on-upload risk above.) +- Is there a need to expose the `_content_references` table via an actuator endpoint for operational visibility? diff --git a/openspec/changes/add-content-lifecycle/proposal.md b/openspec/changes/add-content-lifecycle/proposal.md new file mode 100644 index 000000000..39666f284 --- /dev/null +++ b/openspec/changes/add-content-lifecycle/proposal.md @@ -0,0 +1,40 @@ +## Why + +When content is uploaded, it is written to the `ContentStore` and a `ContentReference` is stored in the entity. However, +when that content is later replaced, cleared from an attribute, or the owning entity is deleted, the underlying storage +bytes are never removed — causing unbounded storage growth. Additionally, the system needs to support multiple entities +pointing to the same content object (content sharing), and deletions must not be immediate: a configurable grace period +is required so that backups can be made before content is permanently removed. + +## What Changes + +- A new `contentgrid-appserver-content-lifecycle` module is introduced to own all content lifecycle tracking and + deletion logic. +- A `_content_references` table tracks reference counts, first-referenced time, last-dereferenced time, and deletion + markers for every content object. +- When content is uploaded, its reference count is incremented. When content is dereferenced (replaced, cleared, or + entity deleted), its reference count is decremented — but only after the database transaction commits successfully. +- When the reference count reaches zero, the content is marked for deletion with a timestamp. It is **not** deleted + immediately. +- A K8s CronJob runs the `ContentDeletionJob`, which finds content past the grace period, performs a safety verification + against all entity tables, and only then deletes from the `ContentStore`. +- **BREAKING**: `contentgrid-appserver-domain` gains a required dependency on the content lifecycle module. + +## Capabilities + +### New Capabilities + +- `content-lifecycle`: Reference-counted tracking of content objects, grace-period-based deletion scheduling, and a + verified deletion job. + +### Modified Capabilities + +## Impact + +- `contentgrid-appserver-content-lifecycle`: New module with `ContentReferenceTracker`, `JooqContentReferenceTracker`, + `DeferredContentReferenceTracker`, `ContentReferenceVerificationQuery`, `ContentDeletionJob`, and auto-configuration. +- `contentgrid-appserver-domain`: `ContentUploadAttributeMapper` increments on upload; `DatamodelApiImpl` decrements on + entity delete; the content cleared path decrements via `ContentAttributeModificationValidator`. +- `settings.gradle`: New module registered. +- `contentgrid-appserver-autoconfigure`: Auto-configuration registered. +- Content store implementations (S3, filesystem): `ContentStore.remove()` will now be called by the deletion job. diff --git a/openspec/changes/add-content-lifecycle/specs/content-lifecycle/spec.md b/openspec/changes/add-content-lifecycle/specs/content-lifecycle/spec.md new file mode 100644 index 000000000..93a3af521 --- /dev/null +++ b/openspec/changes/add-content-lifecycle/specs/content-lifecycle/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### Requirement: Reference count is incremented when content is uploaded +When a new content object is written to the `ContentStore`, the system SHALL create a new entry in `_content_references` with `reference_count = 1`. Each upload always produces a unique `ContentReference`, so an existing entry will never be found at upload time. + +#### Scenario: New content uploaded creates reference entry +- **WHEN** a file is uploaded to a content attribute on an entity +- **THEN** a new row is created in `_content_references` for that `content_id` with `reference_count = 1` +- **AND** `first_referenced_at` is set to the current time + +### Requirement: Reference count is decremented after successful transaction when content is dereferenced +When a content attribute is replaced, cleared, or its owning entity is deleted, the system SHALL decrement the reference count for the old `ContentReference` in `_content_references`, but only after the database transaction has committed successfully. + +#### Scenario: Content replaced decrements old reference after commit +- **WHEN** a content attribute is updated with a new file upload +- **AND** the transaction commits successfully +- **THEN** the reference count for the old `ContentReference` is decremented by 1 + +#### Scenario: Content cleared decrements reference after commit +- **WHEN** a content attribute is set to null +- **AND** the transaction commits successfully +- **THEN** the reference count for the old `ContentReference` is decremented by 1 + +#### Scenario: Entity deleted decrements all content references after commit +- **WHEN** an entity with one or more populated content attributes is deleted +- **AND** the transaction commits successfully +- **THEN** the reference count for each content attribute's `ContentReference` is decremented by 1 + +#### Scenario: Transaction rollback suppresses decrement +- **WHEN** an operation that would dereference content is attempted +- **AND** the database transaction rolls back +- **THEN** no decrement is applied to `_content_references` +- **AND** the reference count remains unchanged + +#### Scenario: No decrement when attribute had no prior content +- **WHEN** a content attribute that currently holds no content is updated +- **THEN** no decrement is attempted + +### Requirement: Content is marked for deletion when its reference count reaches zero +When a decrement causes the `reference_count` in `_content_references` to reach zero, the system SHALL set `marked_for_deletion_at` to the current timestamp. The content object SHALL NOT be removed from the `ContentStore` at this point. + +#### Scenario: Content marked for deletion when last reference is removed +- **WHEN** the reference count for a `ContentReference` is decremented to zero +- **THEN** `marked_for_deletion_at` is set to the current timestamp +- **AND** the content object is still readable from the `ContentStore` + +### Requirement: Content past the grace period is deleted by the deletion job +The `ContentDeletionJob` SHALL find all `_content_references` entries where `marked_for_deletion_at` is older than the configured grace period, verify they are truly unreferenced, and delete them from the `ContentStore`. + +#### Scenario: Orphaned content past grace period is deleted +- **WHEN** the `ContentDeletionJob` runs +- **AND** a `_content_references` entry has `marked_for_deletion_at` older than the grace period +- **AND** the safety verification confirms the `content_id` is not referenced in any entity table +- **THEN** the content object is removed from the `ContentStore` +- **AND** the row is removed from `_content_references` +- **AND** the `content.deletion.success` metric is incremented + +#### Scenario: Content within the grace period is not deleted +- **WHEN** the `ContentDeletionJob` runs +- **AND** a `_content_references` entry has `marked_for_deletion_at` set but within the grace period +- **THEN** the content object is NOT deleted + +#### Scenario: Content still referenced is not deleted (drift protection) +- **WHEN** the `ContentDeletionJob` runs +- **AND** a `_content_references` entry has `marked_for_deletion_at` set past the grace period +- **AND** the safety verification finds the `content_id` is still referenced in an entity table +- **THEN** the content object is NOT deleted +- **AND** `marked_for_deletion_at` is cleared +- **AND** the `content.deletion.drift` metric is incremented +- **AND** a warning is logged with the `content_id` + +#### Scenario: Deletion job continues after individual deletion failure +- **WHEN** the `ContentDeletionJob` runs and `ContentStore.remove()` throws for one content object +- **THEN** the deletion is attempted for all remaining candidates +- **AND** the `content.deletion.failure` metric is incremented for each failure +- **AND** each failure is logged with the `content_id` + diff --git a/openspec/changes/add-content-lifecycle/tasks.md b/openspec/changes/add-content-lifecycle/tasks.md new file mode 100644 index 000000000..6c28b3b82 --- /dev/null +++ b/openspec/changes/add-content-lifecycle/tasks.md @@ -0,0 +1,84 @@ +## 1. New Module Scaffolding + +- [x] 1.1 Create `contentgrid-appserver-content-lifecycle` module directory and `build.gradle` with dependencies on + `contentgrid-appserver-contentstore-api`, `spring-boot-starter`, and `jooq` +- [x] 1.2 Register the new module in `settings.gradle` +- [x] 1.3 Add the module as a required dependency in `contentgrid-appserver-domain/build.gradle` + +## 2. Database Schema + +- [x] 2.1 Add `createContentReferencesTable(DSLContext)` and `dropContentReferencesTable(DSLContext)` methods to + `JOOQTableCreator`, using the JOOQ DSL to create/drop the `_content_references` table (`content_id VARCHAR PRIMARY KEY`, + `reference_count INTEGER NOT NULL`, `first_referenced_at TIMESTAMP NOT NULL`, `last_dereferenced_at TIMESTAMP`, + `marked_for_deletion_at TIMESTAMP`); call them from `createTables()` and `dropTables()` +- [x] 2.2 Define `_content_references` table and field references as `DSL.table()` / `DSL.field()` constants in the + new lifecycle module (no codegen — follow the same inline DSL pattern used elsewhere in `contentgrid-appserver-query-engine-impl-jooq`) + +## 3. ContentReferenceTracker Interface and Implementations + +- [x] 3.1 Define the `ContentReferenceTracker` interface in the new module with `incrementReference(ContentReference)` + and `decrementReference(ContentReference)` methods +- [x] 3.2 Implement `JooqContentReferenceTracker`: `incrementReference` upserts a row (insert or increment count, clear + `marked_for_deletion_at`); `decrementReference` decrements count and sets `marked_for_deletion_at = NOW()` when count + reaches zero, sets `last_dereferenced_at` +- [x] 3.3 Implement `DeferredContentReferenceTracker` that delegates `incrementReference` directly and registers + `decrementReference` as a `TransactionSynchronizationManager.afterCommit()` callback +- [x] 3.4 Write unit tests for `JooqContentReferenceTracker` covering: first increment (creates row), second increment ( + increments count, clears deletion marker), decrement to non-zero (only decrements), decrement to zero (sets + `marked_for_deletion_at`) +- [x] 3.5 Write unit tests for `DeferredContentReferenceTracker` verifying that decrement is not called when the + transaction rolls back + +## 4. Domain Integration — Upload (Increment) + +- [x] 4.1 Inject `ContentReferenceTracker` into `ContentUploadAttributeMapper` +- [x] 4.2 After `contentStore.writeContent()` succeeds, call `contentReferenceTracker.incrementReference()` with the new + `ContentReference` +- [x] 4.3 Write tests verifying increment is called on upload + +## 5. Domain Integration — Content Cleared (Decrement) + +- [x] 5.1 Update `ContentAttributeModificationValidator` to collect content IDs that are being set to null; expose + them (e.g., return them from `validate()` or accumulate in a field) +- [x] 5.2 In `DatamodelApiImpl.update()` and `updatePartial()`, after the query engine operation, call + `contentReferenceTracker.decrementReference()` for each collected dereferenced content ID +- [x] 5.3 Write tests verifying decrement is called when content is cleared and not called when attribute was previously + empty + +## 6. Domain Integration — Entity Delete (Decrement) + +- [x] 6.1 Add a helper in `DatamodelApiImpl` that extracts all non-null `ContentReference` values from an + `InternalEntityInstance` by scanning the `Application` model for `ContentAttribute` instances +- [x] 6.2 In `DatamodelApiImpl.deleteEntity()`, after `queryEngine.delete()` returns the deleted entity, call + `contentReferenceTracker.decrementReference()` for each extracted content reference +- [x] 6.3 Write tests for the delete path: entity with multiple content attributes (all decremented), entity with no + content attributes (no decrement), entity with mix of populated and empty content attributes + +## 7. ContentDeletionJob + +- [x] 7.1 Implement `ContentReferenceVerificationQuery` that, given a `content_id`, queries all entity tables with + content attributes and returns true if any reference exists +- [x] 7.2 Implement `ContentDeletionJob`: query `_content_references` where + `marked_for_deletion_at <= NOW() - grace_period` (batch-limited); for each candidate, run the safety check; if + unreferenced delete from `ContentStore` and remove the row, if referenced clear the mark and record drift +- [x] 7.3 Record metrics: `content.deletion.success`, `content.deletion.failure`, `content.deletion.drift` +- [x] 7.4 Expose the job as a Spring Boot `CommandLineRunner` or `ApplicationRunner` for K8s CronJob invocation +- [x] 7.5 Write unit tests for `ContentDeletionJob`: candidate past grace period and unreferenced (deleted), candidate + within grace period (skipped), candidate with drift detected (marker cleared, metric incremented), failure in + `ContentStore.remove()` (continues to next candidate) + +## 8. Configuration and Auto-Configuration + +- [x] 8.1 Define `ContentLifecycleProperties` with `deletion.enabled` (default `true`), + `deletion.grace-period` (default `P7D`), `deletion.batch-size` (default `100`) +- [x] 8.2 Write `ContentLifecycleAutoConfiguration` that creates `JooqContentReferenceTracker`, + `DeferredContentReferenceTracker`, `ContentReferenceVerificationQuery`, and `ContentDeletionJob` beans +- [x] 8.3 Register the auto-configuration in + `contentgrid-appserver-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` + +## 9. Migration Script for Existing Data + +- [x] 9.1 Document the backfill SQL pattern (UNION ALL across all entity content columns) in the module README or a + runbook +- [x] 9.2 Consider whether the backfill should be an optional Flyway migration or a manual operation; document the + chosen approach diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 000000000..392946c67 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/settings.gradle b/settings.gradle index 7b91ccccb..7552ead8c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -43,5 +43,6 @@ include 'contentgrid-appserver-contentstore-impl-fs' include 'contentgrid-appserver-contentstore-impl-s3' include 'contentgrid-appserver-contentstore-impl-encryption' include 'contentgrid-appserver-webjars' +include 'contentgrid-appserver-content-lifecycle' include 'contentgrid-appserver-spring-boot-starter' include 'contentgrid-appserver-integration-test'