MemberJunction / MemberJunction/MJ

RunQuery fails with "query.UserCanRun is not a function" when Redis caching is enabled (5.51.0)

Open
#3,921 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TSQL
Stars
29
Forks
6
Avg merge
1d 8h
Merged PRs (30d)
308

Description

## Summary

With `REDIS_URL` configured, **every `RunQuery` fails once the cache is warm** with:

```
query.UserCanRun is not a function
```

`QueryEngine` loads `MJ: Queries` as `Type: 'entity'` with `CacheLocal: true`. When that read is
served from the Redis-backed cache, the rows are returned as **plain deserialized JSON objects**
rather than entity instances, so the prototype — and therefore every method on the registered
subclass — is gone. `GenericDatabaseProvider` then calls `query.UserCanRun(user)` and throws.

With the default in-memory cache the bug is invisible, because object identity is preserved.

## Environment

| | |
|---|---|
| MJ version | **5.51.0** |
| Cache | Azure Managed Redis, `RedisLocalStorageProvider` via `REDIS_URL` (wired by `serve()`) |
| Provider | `GenericDatabaseProvider` (SQL Server) |
| Trigger | any `RunQuery` after `MJ: Queries` has been cached once |

## Reproduction

1. Run a server with `REDIS_URL` set so `serve()` installs `RedisLocalStorageProvider`.
2. Let it boot — `QueryEngine` loads and caches `MJ: Queries`.
3. Execute any query, e.g. `new RunQuery().RunQuery({ QueryName: '' })`.
4. The result comes back `Success: false`, `ErrorMessage: "query.UserCanRun is not a function"`.

Restarting clears it only until the cache repopulates — which happens within seconds.

## Evidence

**1. The cached value is JSON, and its rows are plain objects.** Read directly from Redis:

```
key : :RunViewCache:MJ: Queries|_|_|-1|0|_|_|imr:1|mssql://...
type : string, 579,054 bytes
row[0]: plain object — keys = ID, Name, CategoryID, UserQuestion, Description, SQL
row[0].UserCanRun === undefined
```

**2. The method only exists on the registered subclass.** In a plain Node process:

```
MJQueryEntity.prototype.UserCanRun → undefined (generated)
MJQueryEntityExtended.prototype.UserCanRun → function (@RegisterClass(BaseEntity, 'MJ: Queries'))
```

**3. Class registration is correct — this is not a registration bug.**

```js
MJGlobal.Instance.ClassFactory.GetRegistration(BaseEntity, 'MJ: Queries')
// → MJQueryEntityExtended, with UserCanRun on its prototype
```

**4. Client-side metadata is healthy**, so the fault is server-side even though it surfaces in the
browser console (the client only logs `result.ErrorMessage` returned by the API). Inspected live:
`Provider.Queries` held **33/33 real `QueryInfo` instances with `UserCanRun`**.

## Relevant code

- `packages/MJCoreEntities/src/engines/QueryEngine.ts:42`
`{ Type: 'entity', EntityName: 'MJ: Queries', PropertyName: '_queries', CacheLocal: true }`
- `packages/MJCoreEntities/src/custom/MJQueryEntityExtended.ts:23`
`@RegisterClass(BaseEntity, 'MJ: Queries')` — defines `UserCanRun`
- Throwing call sites in `packages/GenericDatabaseProvider/src/`:
- `GenericDatabaseProvider.ts:2923`
- `GenericDatabaseProvider.ts:3089`
- `queryCompositionEngine.ts:523`

Note `GenericDatabaseProvider.ts:3024` (inside the `ResolveQueryCacheAuthorization` override) is
already wrapped in a try/catch documented as *"non-throwing by contract"* and degrades safely —
so the surfaced failures come from the **uncaught** sites on the execution path. That existing
guard suggests the fragility was anticipated in one place but not the others.

## Impact

Broader than queries. **Any `BaseEngine` config using `Type: 'entity'` with `CacheLocal: true`
whose rows have methods called on them will fail the same way** once Redis is enabled. `QueryEngine`
is simply where it surfaces immediately, because `UserCanRun` is invoked on every query execution.

The failure mode is silent until a method is called — a cached read looks perfectly healthy
otherwise.

## Workaround

Set `AllowCaching = 0` on the `MJ: Queries` entity. This keeps Redis (and cross-process
invalidation) while exempting the one entity whose methods are called. Confirmed: after the flag
plus a restart, the cache key stayed absent for 3 minutes while the rest of the cache grew from
260 to 265 keys, and query execution recovered.

## Suggested fix

Rehydrate cached entity-type reads back into their registered subclass via `ClassFactory` before
returning them — mirroring how `MetadataFromSimpleObjectWithoutUser` reconstructs metadata with
`new m.class(d, md)` — so a cache hit is behaviourally identical to a miss. Alternatively, have
`BaseEngine` reconstruct entities for `Type: 'entity'` configs regardless of cache origin.

The general invariant worth stating in the fix: **a cache hit must return the same TYPE as a cache
miss, not merely the same data.**

Contributor guide

Open the contributing guide

Research direction

Start with packages/MJCoreEntities/src/engines/QueryEngine.ts and MJQueryEntityExtended.ts, then inspect the cache read path and the uncaught call sites in GenericDatabaseProvider.ts and queryCompositionEngine.ts. Reproduce with REDIS_URL and a warmed MJ: Queries cache; done means cache hits return registered entity instances like cache misses and RunQuery succeeds at the listed call sites.

Written by the indexing model from the issue text.

Assessment

Tech stack
redis, sql, typescript
Domain
backend, databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
70/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.