MemberJunction / MemberJunction/MJ
RunQuery fails with "query.UserCanRun is not a function" when Redis caching is enabled (5.51.0)
- 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
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