feat(purge): "!field" entries in onlyWhenFieldsChange ignore a field #21
No files matched your search
@@ -10,6 +10,7 @@ All notable changes to this project are documented here. The format follows Keep
|
||||
|
||||
|
||||
### Fixed
|
||||
- Tag rules accept ignored fields in `onlyWhenFieldsChange`, such as `!viewCount`, so counter-only updates do not purge rendered pages. Plain allow-lists still work, and ignored fields take precedence in mixed lists. No schema change is needed.
|
||||
- Purge actions warn when providers are NONE or hostnames are OFF, and report requests as recorded but not sent. The home explains recorded-only purges and that this version does not run origin header checks.
|
||||
- Tag rule `{prefix.field}` placeholders now resolve the joined table from the `<prefix>Id` field's possible-value source, so prefixed tables (`cms_blog`) work; a missing joined table is logged instead of silently skipped.
|
||||
|
||||
|
||||
@@ -31,7 +31,21 @@ Design: QRun-IO/qqq `docs/specs/2026-10-12-qbit-cdn-design.md`.
|
||||
NONE. See `docs/cloudfront-expected-config.md`.
|
||||
- **Permissions:** `cdn.view`, `cdn.purge`, `cdn.purgeAll`, `cdn.admin`.
|
||||
|
||||
## Purge-path placeholders
|
||||
## Tag rules
|
||||
|
||||
`cdn_table_tag_rule.onlyWhenFieldsChange` is a comma-separated list of QQQ field
|
||||
names. Plain entries (for example `title,slug`) purge only when at least one
|
||||
listed field changes. Prefix a field with `!` to ignore it: `!viewCount` prevents
|
||||
a view-count-only update from purging, while a title change still purges.
|
||||
With `!viewCount, !lastIndexedAt`, any change outside those fields counts.
|
||||
Mixed lists keep the allow-list and exclude ignored fields, even if also listed
|
||||
plainly: `title,viewCount,!viewCount` counts only title changes.
|
||||
|
||||
Names are trimmed and case-sensitive. Partial updates retain omitted old values;
|
||||
only unequal old and new values count. This filter does not restrict inserts or
|
||||
deletes. The separate `publishedField` filter still applies to all changes.
|
||||
|
||||
### Purge-path placeholders
|
||||
|
||||
`cdn_table_tag_rule` templates (`tagTemplates`, `invalidationPaths`) can use `{field}` for the changed record's own fields and `{prefix.field}` for a joined record, e.g. `/blog/{blog.slug}/{slug}*`.
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.kingsrook.qbits.cdn.spi.CdnProviderType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
@@ -273,7 +274,8 @@ public final class CdnTables
|
||||
.withField(new QFieldMetaData("tableName", QFieldType.STRING).withIsRequired(true).withMaxLength(100))
|
||||
.withField(new QFieldMetaData("tagTemplates", QFieldType.TEXT))
|
||||
.withField(new QFieldMetaData("invalidationPaths", QFieldType.TEXT))
|
||||
.withField(new QFieldMetaData("onlyWhenFieldsChange", QFieldType.STRING).withMaxLength(1000))
|
||||
.withField(new QFieldMetaData("onlyWhenFieldsChange", QFieldType.STRING).withMaxLength(1000)
|
||||
.withHelpContent(new QHelpContent("Comma-separated, case-sensitive field names. Plain entries allow only those changes; !viewCount ignores viewCount changes. With only ignored entries, any other changed field counts. In mixed lists, ignored fields never count. Inserts and deletes are unaffected by this filter.")))
|
||||
.withField(new QFieldMetaData("publishedField", QFieldType.STRING).withMaxLength(100))
|
||||
.withField(new QFieldMetaData("enabled", QFieldType.BOOLEAN).withDefaultValue(true)));
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
** expires by TTL instead of being invalidated;
|
||||
** - publishedField: when set, only records that are (or were) published
|
||||
** count, so draft edits purge nothing;
|
||||
** - onlyWhenFieldsChange: comma list; an update that changes none of them
|
||||
** purges nothing.
|
||||
** - onlyWhenFieldsChange: comma list of watched fields; !field ignores a
|
||||
** field. With only ignored fields, any other changed field counts.
|
||||
**
|
||||
** A dotted placeholder "{blog.slug}" reads field "slug" of the record that
|
||||
** field "blogId" points to (the xId convention). The joined table is the one
|
||||
@@ -159,7 +159,27 @@ public class TagRuleEngine
|
||||
String watched = rule.getValueString("onlyWhenFieldsChange");
|
||||
if(current != null && old != null && StringUtils.hasContent(watched))
|
||||
{
|
||||
return (Arrays.stream(watched.split(",")).map(String::strip).filter(StringUtils::hasContent)
|
||||
Set<String> included = new LinkedHashSet<>();
|
||||
Set<String> ignored = new LinkedHashSet<>();
|
||||
for(String entry : watched.split(","))
|
||||
{
|
||||
String field = entry.strip();
|
||||
if(field.startsWith("!"))
|
||||
{
|
||||
ignored.add(field.substring(1).strip());
|
||||
}
|
||||
else if(StringUtils.hasContent(field))
|
||||
{
|
||||
included.add(field);
|
||||
}
|
||||
}
|
||||
if(included.isEmpty() && !ignored.isEmpty())
|
||||
{
|
||||
included.addAll(current.getValues().keySet());
|
||||
included.addAll(old.getValues().keySet());
|
||||
}
|
||||
included.removeAll(ignored);
|
||||
return (included.stream()
|
||||
.anyMatch(field -> !Objects.equals(current.getValue(field), old.getValue(field))));
|
||||
}
|
||||
return (true);
|
||||
|
||||
@@ -28,11 +28,14 @@ import com.kingsrook.qbits.cdn.spi.CdnProviderType;
|
||||
import com.kingsrook.qbits.cdn.spi.CdnPurgeKind;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -138,6 +141,32 @@ class PurgePipelineTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The record-change listener skips counter updates but records title changes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ignoredCounterChangeRecordsNoPurge() throws Exception
|
||||
{
|
||||
QContext.getQInstance().getTable("post").withField(new QFieldMetaData("viewCount", QFieldType.INTEGER));
|
||||
CdnTestSupport.liveProvider(CdnProviderType.CLOUDFRONT, null);
|
||||
QRecord rule = new QueryAction().execute(new QueryInput(CdnTables.TABLE_TAG_RULE)).getRecords().get(0);
|
||||
new UpdateAction().execute(new UpdateInput(CdnTables.TABLE_TAG_RULE).withRecord(new QRecord().withValue("id", rule.getValue("id"))
|
||||
.withValue("onlyWhenFieldsChange", "!viewCount")));
|
||||
QRecord post = CdnTestSupport.insert("post", new QRecord().withValue("slug", "counter-post").withValue("title", "Old")
|
||||
.withValue("blogId", 1).withValue("isPublished", true).withValue("viewCount", 10)).get(0);
|
||||
assertThat(purges()).hasSize(1);
|
||||
|
||||
new UpdateAction().execute(new UpdateInput("post").withRecord(new QRecord().withValue("id", post.getValue("id")).withValue("viewCount", 11)));
|
||||
assertThat(purges()).as("counter-only update adds no purge request").hasSize(1);
|
||||
|
||||
new UpdateAction().execute(new UpdateInput("post").withRecord(new QRecord().withValue("id", post.getValue("id")).withValue("title", "New")));
|
||||
assertThat(purges()).hasSize(2);
|
||||
assertThat(purges().get(1).getValueString("paths")).contains("/blog/quick-bits/counter-post*");
|
||||
assertThat(purges().get(1).getValueString("tags")).contains("post:" + post.getValue("id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Retryable failures back off and end DEAD; permanent ones go DEAD at once.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -135,6 +135,67 @@ class TagRuleEngineTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ignore-only rules compare actual values after merging partial updates.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ignoresCounterOnlyUpdates() throws Exception
|
||||
{
|
||||
QRecord rule = new QRecord().withValue("onlyWhenFieldsChange", " !viewCount, !lastIndexedAt ").withValue("tagTemplates", "post:{id}");
|
||||
QRecord old = new QRecord().withValue("id", 1).withValue("title", "Old").withValue("viewCount", 10).withValue("lastIndexedAt", "yesterday");
|
||||
TagRuleEngine engine = new TagRuleEngine();
|
||||
assertTrue(engine.evaluate(rule, List.of(new QRecord(old)), List.of(old)).isEmpty());
|
||||
assertTrue(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("viewCount", 11).withValue("lastIndexedAt", "today")), List.of(old)).isEmpty());
|
||||
assertTrue(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("title", "Old")), List.of(old)).isEmpty());
|
||||
assertThat(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("title", "New").withValue("viewCount", 11)), List.of(old)).tags()).containsExactly("post:1");
|
||||
assertFalse(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("title", null)), List.of(old)).isEmpty());
|
||||
assertFalse(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("newField", "value")), List.of(old)).isEmpty());
|
||||
assertFalse(engine.evaluate(rule, List.of(new QRecord().withValue("id", 1).withValue("ViewCount", 11)), List.of(old)).isEmpty());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Plain lists retain allow-list behavior; ignored entries always win.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void appliesAllowListAndMixedRules()
|
||||
{
|
||||
QRecord old = new QRecord().withValue("title", "Old").withValue("viewCount", 10);
|
||||
QRecord counterChange = new QRecord(old).withValue("viewCount", 11);
|
||||
QRecord titleChange = new QRecord(old).withValue("title", "New");
|
||||
QRecord rule = new QRecord().withValue("onlyWhenFieldsChange", " title, ,slug ");
|
||||
assertFalse(TagRuleEngine.counts(rule, counterChange, old));
|
||||
assertTrue(TagRuleEngine.counts(rule, titleChange, old));
|
||||
assertFalse(TagRuleEngine.counts(rule, new QRecord(old), old));
|
||||
rule.setValue("onlyWhenFieldsChange", "title, viewCount, !viewCount");
|
||||
assertFalse(TagRuleEngine.counts(rule, counterChange, old));
|
||||
assertTrue(TagRuleEngine.counts(rule, titleChange, old));
|
||||
assertFalse(TagRuleEngine.counts(rule, new QRecord(old).withValue("slug", "new"), old));
|
||||
rule.setValue("onlyWhenFieldsChange", "viewCount,!viewCount");
|
||||
assertFalse(TagRuleEngine.counts(rule, titleChange, old));
|
||||
assertFalse(TagRuleEngine.counts(rule, counterChange, old));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Changed-field filters never suppress inserts or deletes.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void insertsAndDeletesIgnoreFieldFilters() throws Exception
|
||||
{
|
||||
QRecord post = new QRecord().withValue("id", 1).withValue("viewCount", 10);
|
||||
for(String fields : List.of("!viewCount", "title", "title,!viewCount"))
|
||||
{
|
||||
QRecord rule = new QRecord().withValue("onlyWhenFieldsChange", fields).withValue("tagTemplates", "post:{id}");
|
||||
assertThat(new TagRuleEngine().evaluate(rule, List.of(post), List.of()).tags()).containsExactly("post:1");
|
||||
assertThat(new TagRuleEngine().evaluate(rule, List.of(), List.of(post)).tags()).containsExactly("post:1");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Stored text round-trips; invalid lines are dropped.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user