Skip to content

feat(hooks): add TTL HookCache and persistent hook schema store - #655

Open
gemammercado wants to merge 5 commits into
aws-cloudformation:mainfrom
gemammercado:hooks-pr1-cache-infra
Open

feat(hooks): add TTL HookCache and persistent hook schema store#655
gemammercado wants to merge 5 commits into
aws-cloudformation:mainfrom
gemammercado:hooks-pr1-cache-infra

Conversation

@gemammercado

@gemammercado gemammercado commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

PR 1 of 7 — foundation (stacked)

This is the first slice of the CloudFormation Hooks feature, split into 7 stacked PRs for reviewability. It adds the low-level building blocks that every later slice depends on. Nothing is wired into the server yet — this PR is pure foundation.

What this adds

  • HookCache. Memoises hook configurations and Guard rule bodies, so a feature
    that repeatedly inspects the same hook doesn't re-issue a CloudFormation or S3 call
    each time. Configurations and rule bodies live in separate namespaces, each backed
    by lru-cache with a TTL and a maximum
    entry count. Concurrent loads of the same key are coalesced into a single call.
  • Hook LSP request types. The Params/Result/Request definitions for every
    hook-related LSP message. This is the contract the handlers and client use in later
    slices.

What changed in this revision

Addressing @satyakigh's review. Three of the four comments are resolved by removing
code rather than changing it, so the corresponding inline comments will show as
outdated.

Persisting hook schemas. Correct on both counts, and the second was a latent bug.
CfnService.listHooks() filters to Visibility: PRIVATE, so those records are hook
type names and configuration fields authored inside the customer's own account —
the same category as private_schemas, which is already memory-only here. They were
also keyed by type name alone with no account or region qualifier, so a persisted
record could be served after a profile or region switch, and on disk that survived
restarts with a staleness window measured in days.

Rather than move it to memory, HookSchemaStore is removed entirely. Once
persistence went away, its only distinguishing feature over the schema cache
HooksManager already keeps in memory was a staleness window, which an lru-cache
ttl expresses directly. The schema cache is therefore better introduced alongside
HooksManager than as a separate store here. That also drops the hook_schemas
store, so DataStore.ts is no longer touched by this PR at all.

Cache size. Unbounded as written. In practice neither cache was keyed by user activity — configuration keys come from the hooks activated in the account, and rule keys from the rule URIs those configurations reference, so the key count tracked hook count rather than session length. But that was a property of the callers, not of the cache, and rule bodies read from S3 have no inherent size limit. Both namespaces are
now capped (100 entries each by default) with LRU eviction.

Pruning while iterating in size. The size accessor no longer exists. lru-cache owns staleness now, so there is no second traversal to fold in.

TtlCache didn't need exporting. Correct — nothing outside the module used it.
The hand-rolled TtlCache is deleted outright in favour of lru-cache, which supplies
TTL expiry, the entry bound, LRU eviction and request coalescing. HookCache is the
only export.

New dependency

lru-cache becomes a direct dependency, pinned exactly to match the other production
dependencies. It was already in the tree transitively via path-scurry and is
recorded in THIRD-PARTY-LICENSES.txt and sbom/sbom.csv.

⚠️ One thing to flag: v11 is licensed BlueOak-1.0.0, where the transitive
v10.4.3 was ISC — Isaac relicensed at v11. Blue Oak is permissive and OSI-approved,
but it falls outside the set ATTRIBUTION_README.md treats as routine
(MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD), so it will surface in the
license report and wants a conscious sign-off. v10.4.3 is the ISC alternative, but it
lacks the perf option this PR uses to inject a clock in tests. SBOM and attribution
still need regenerating.

Testing

Tests cover what this wrapper contributes rather than re-testing lru-cache: separate
namespaces for configurations and rules, invalidation across both, that the ttl and
maxEntries options reach each namespace, that each namespace is bounded
independently rather than sharing one budget, that rejected loads stay uncached, and
that concurrent loads of one key are coalesced. Build, lint and unit tests pass.

Comment thread src/datastore/DataStore.ts Outdated
export const PersistedStores: ReadonlyArray<StoreName> = [
StoreName.public_schemas,
StoreName.sam_schemas,
StoreName.hook_schemas,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be persisted? We don't persist private customer data on disk. Will it change with credentials/regions?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to Persistence.memory and reverted the PersistedStores change.

On credentials/regions: yes, this was a bug. Records were keyed hook:${typeName} with no account or region qualifier, so switching profile or region would read back the previous account's schema under the same type name. On disk that survived restarts, and the staleness threshold was in days.

On privacy: CfnService.listHooks() filters Visibility: PRIVATE, so this store was fed almost entirely by hooks the customer registered into their own account. Same category as private_schemas, which is already memory-only here.

Comment thread src/hooks/HookCache.ts Outdated
}

export class TtlCache<T> {
private readonly entries = new Map<string, CacheEntry<T>>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How large can these caches get?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded as written, added a 100-entry cap per cache and oldest-first eviction

Comment thread src/hooks/HookCache.ts Outdated
get size(): number {
let count = 0;
for (const entry of this.entries.values()) {
if (entry.expiresAt > this.now()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the whole map is being iterated, could just prune here as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

size now calls prune()

Comment thread src/hooks/HookCache.ts Outdated
expiresAt: number;
}

export class TtlCache<T> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't need to be exported

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

un-exported. It was only exported so the unit tests could drive it directly

kddejong
kddejong previously approved these changes Jul 31, 2026
Keep private hook schemas in memory rather than on disk. listHooks
filters to PRIVATE visibility, so these records are customer-authored
type names and config fields, matching the existing treatment of
private_schemas. The records were also keyed by type name alone with no
account or region qualifier, so a persisted record could be served after
a profile or region switch.

Bound both hook caches. Keys derive from activated hook count rather
than user activity, but nothing in TtlCache enforced that, and rule
bodies fetched from S3 are unbounded in size. Entries are now capped and
the oldest is evicted at the cap.

Prune expired entries while reporting size rather than counting them,
stop exporting TtlCache now that no consumer outside this module uses
it, and drop its unused set method. Tests exercise the cache through
HookCache.

Add HookSchemaStore unit tests, including a guard that the store is
requested with memory persistence.
Two callers asking for the same key before the first load resolves share
one call. That depends on routing loads through fetch with a fetchMethod
rather than a get and set pair, so it is a property of this wrapper and
not of lru-cache alone.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants