{
  "id": "demo_template",
  "title": "",
  "url": "https://redis.io/docs/latest/develop/use-cases/feature-store/php/demo_template/",
  "summary": "",
  "tags": [],
  "last_updated": "2026-06-04T14:49:57+01:00",
  "page_type": "content",
  "content_hash": "0767e7249df67f2bb643bfa50a4b71f73e871c1e21be0fa2399b9d4009e46b5c",
  "sections": [
    {
      "id": "content",
      "title": "Content",
      "role": "content",
      "text": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <title>Redis Feature Store Demo (PHP)</title>\n  <style>\n    :root {\n      --bg: #eef3f1;\n      --panel: #ffffff;\n      --ink: #1d2730;\n      --accent: #267d6b;\n      --accent-dark: #1a594c;\n      --muted: #5c6770;\n      --line: #d4dfdb;\n      --ok: #d2ecdf;\n      --warn: #f8e0d0;\n      --pill: #d9ebe6;\n      --batch: #e6e0f0;\n      --stream: #d9ebe6;\n    }\n    * { box-sizing: border-box; }\n    body {\n      margin: 0;\n      font-family: Georgia, \"Times New Roman\", serif;\n      color: var(--ink);\n      background:\n        radial-gradient(circle at top left, #f3faf7, transparent 32rem),\n        linear-gradient(180deg, #ecf2f0 0%, var(--bg) 100%);\n      min-height: 100vh;\n    }\n    main { max-width: 1080px; margin: 0 auto; padding: 40px 20px 72px; }\n    h1 { font-size: clamp(2rem, 4.6vw, 3.4rem); line-height: 1.05; margin-bottom: 8px; }\n    p.lede { max-width: 58rem; font-size: 1.05rem; color: var(--muted); }\n    .grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); margin-top: 24px; }\n    .panel { background: rgba(255, 255, 255, 0.96); border: 1px solid var(--line); border-radius: 16px; padding: 20px; box-shadow: 0 10px 32px rgba(20, 60, 50, 0.07); }\n    .panel.wide { grid-column: 1 / -1; }\n    .panel h2 { margin-top: 0; margin-bottom: 8px; font-size: 1.25rem; }\n    .panel h3 { margin: 14px 0 6px; font-size: 1rem; }\n    .pill { display: inline-block; border-radius: 999px; background: var(--pill); color: var(--accent-dark); padding: 6px 10px; font-size: 0.85rem; margin-bottom: 10px; }\n    label { display: block; font-weight: bold; margin: 10px 0 4px; }\n    input, select { width: 100%; padding: 9px 11px; border-radius: 9px; border: 1px solid #c0d2cc; font: inherit; background: white; }\n    button { appearance: none; border: 0; border-radius: 999px; background: var(--accent); color: white; padding: 10px 16px; font: inherit; cursor: pointer; margin-right: 6px; margin-top: 10px; }\n    button.secondary { background: #3b4951; }\n    button.danger { background: #8a3a3a; }\n    button.small { padding: 5px 10px; font-size: 0.85rem; margin-top: 4px; }\n    button:hover { filter: brightness(0.92); }\n    dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; }\n    dt { font-weight: bold; }\n    dd { margin: 0; word-break: break-word; }\n    .row { display: flex; gap: 8px; flex-wrap: wrap; }\n    .row > * { flex: 1 1 0; min-width: 110px; }\n    table { width: 100%; border-collapse: collapse; font-size: 0.92rem; }\n    th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); }\n    th { color: var(--muted); font-weight: bold; }\n    code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }\n    .badge { display: inline-block; border-radius: 6px; padding: 2px 7px; font-size: 0.8rem; font-weight: bold; }\n    .badge.batch { background: var(--batch); color: #43326a; }\n    .badge.stream { background: var(--stream); color: #1d4a2c; }\n    .badge.expired { background: var(--warn); color: #6b3220; }\n    .badge.untracked { background: #eceff1; color: #3b4951; }\n    .badge.running { background: var(--ok); color: #1d4a2c; }\n    .badge.paused { background: var(--warn); color: #6b3220; }\n    .ttl-pos { color: #1a594c; font-weight: bold; }\n    .ttl-neg { color: #6b3220; }\n    .field-list { display: flex; gap: 6px 12px; flex-wrap: wrap; }\n    .field-list label { display: inline-flex; align-items: center; gap: 4px; margin: 0; font-weight: normal; font-size: 0.9rem; }\n    .field-list input { width: auto; }\n    #status { margin-top: 18px; padding: 12px 14px; border-radius: 12px; display: none; }\n    #status.ok { display: block; background: var(--ok); }\n    #status.error { display: block; background: var(--warn); }\n  </style>\n</head>\n<body>\n  <main>\n    <div class=\"pill\">Predis + PHP built-in server</div>\n    <h1>Redis Feature Store Demo</h1>\n    <p class=\"lede\">\n      A small fraud-scoring feature store. Each user is one Redis hash\n      at <code>__KEY_PREFIX__{id}</code> with a batch-materialized\n      <span class=\"badge batch\">batch</span> half (daily aggregates,\n      24-hour key-level <code>EXPIRE</code>) and a streaming\n      <span class=\"badge stream\">streaming</span> half (real-time\n      signals, <code>__STREAM_TTL__</code>s per-field <code>HEXPIRE</code>).\n      Inference reads any subset with one <code>HMGET</code>; batch\n      scoring pipelines <code>HMGET</code> across N users.\n    </p>\n\n    <div class=\"grid\">\n      <section class=\"panel wide\">\n        <h2>Store state</h2>\n        <div id=\"store-view\">Loading...</div>\n      </section>\n\n      <section class=\"panel\">\n        <h2>Materialize batch features</h2>\n        <p>Calls <code>HSET</code> + <code>EXPIRE</code> for each user\n          through one <code>Predis</code> pipeline — the whole batch\n          ships in one round trip.</p>\n        <label for=\"bulk-count\">How many users</label>\n        <input id=\"bulk-count\" type=\"number\" min=\"1\" max=\"2000\" value=\"200\">\n        <label for=\"bulk-ttl\">Key-level TTL (seconds)</label>\n        <input id=\"bulk-ttl\" type=\"number\" min=\"5\" max=\"172800\" value=\"86400\">\n        <p class=\"mono\" style=\"font-size: 0.85rem; color: var(--muted);\">\n          Drop the TTL to e.g. 30 s and watch entities disappear on\n          schedule — the same thing that happens if a daily refresher\n          fails.\n        </p>\n        <button id=\"bulk-button\">Bulk-load</button>\n        <button id=\"reset-button\" class=\"danger\">Reset (drop every user)</button>\n      </section>\n\n      <section class=\"panel\">\n        <h2>Streaming worker</h2>\n        <p>Picks <code>__USERS_PER_TICK__</code> users per tick, writes the\n          streaming features, applies <code>HEXPIRE</code>\n          <code>__STREAM_TTL__</code>s per field. Pause it and the\n          streaming fields drop out via per-field TTL while the batch\n          fields stay populated.</p>\n        <div id=\"worker-view\"></div>\n        <button id=\"worker-pause-button\" class=\"secondary\">Pause / resume</button>\n      </section>\n\n      <section class=\"panel wide\">\n        <h2>Inference read (HMGET)</h2>\n        <p>Pick a user and a feature subset. One <code>HMGET</code>\n          round trip returns whatever the model needs.</p>\n        <div class=\"row\">\n          <div>\n            <label for=\"read-user\">User</label>\n            <select id=\"read-user\"></select>\n          </div>\n          <div>\n            <label>&nbsp;</label>\n            <button id=\"read-button\" class=\"secondary\">Read features</button>\n          </div>\n        </div>\n        <h3>Feature subset</h3>\n        <p class=\"mono\" style=\"font-size: 0.85rem; color: var(--muted);\">\n          Tick to include in the <code>HMGET</code>. Per-field TTL is\n          shown next to each field in the result table.\n        </p>\n        <div id=\"read-fields\" class=\"field-list\"></div>\n        <div id=\"read-result\" style=\"margin-top: 16px;\">\n          <p>Pick a user and click <strong>Read features</strong>.</p>\n        </div>\n      </section>\n\n      <section class=\"panel\">\n        <h2>Batch scoring</h2>\n        <p>Pipelined <code>HMGET</code> across N random users via\n          <code>Predis</code> pipelines. One network round trip for the\n          whole batch.</p>\n        <label for=\"batch-count\">How many users</label>\n        <input id=\"batch-count\" type=\"number\" min=\"1\" max=\"500\" value=\"100\">\n        <button id=\"batch-button\" class=\"secondary\">Pipeline HMGET</button>\n        <div id=\"batch-result\" style=\"margin-top: 14px;\">\n          <p>(no batch read yet)</p>\n        </div>\n      </section>\n\n      <section class=\"panel\">\n        <h2>Inspect one user</h2>\n        <p><code>HGETALL</code> plus per-field <code>HTTL</code> and\n          key-level <code>TTL</code>. Useful for spotting which\n          streaming fields have already expired.</p>\n        <label for=\"inspect-user\">User</label>\n        <select id=\"inspect-user\"></select>\n        <button id=\"inspect-button\">Inspect</button>\n        <div id=\"inspect-result\" style=\"margin-top: 14px;\">\n          <p>(pick a user and click Inspect)</p>\n        </div>\n      </section>\n    </div>\n\n    <div id=\"status\"></div>\n  </main>\n\n  <script>\n    const BATCH_FIELDS = __BATCH_FIELDS_JSON__;\n    const STREAM_FIELDS = __STREAM_FIELDS_JSON__;\n\n    const storeView = document.getElementById(\"store-view\");\n    const workerView = document.getElementById(\"worker-view\");\n    const readUserSelect = document.getElementById(\"read-user\");\n    const inspectUserSelect = document.getElementById(\"inspect-user\");\n    const readFieldsBox = document.getElementById(\"read-fields\");\n    const readResult = document.getElementById(\"read-result\");\n    const batchResult = document.getElementById(\"batch-result\");\n    const inspectResult = document.getElementById(\"inspect-result\");\n    const statusBox = document.getElementById(\"status\");\n\n    function setStatus(message, kind) { statusBox.textContent = message; statusBox.className = kind; }\n    function escapeHtml(value) {\n      return String(value ?? \"\").replace(/[&<>\"']/g, (c) =>\n        ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \"\"\", \"'\": \"'\" }[c]));\n    }\n    function classifyField(name) {\n      if (BATCH_FIELDS.includes(name)) return \"batch\";\n      if (STREAM_FIELDS.includes(name)) return \"stream\";\n      return \"other\";\n    }\n    function ttlLabel(seconds) {\n      if (seconds === -2) return '<span class=\"badge expired\">missing</span>';\n      if (seconds === -1) return '<span class=\"badge untracked\">no TTL</span>';\n      return `<span class=\"ttl-pos mono\">${seconds}s</span>`;\n    }\n\n    function renderStore(state) {\n      const stats = state.stats || {};\n      storeView.innerHTML = `\n        <dl>\n          <dt>Users in store</dt><dd>${state.entity_count}</dd>\n          <dt>Key prefix</dt><dd class=\"mono\">${escapeHtml(state.key_prefix)}*</dd>\n          <dt>Batch TTL</dt><dd>${state.batch_ttl_seconds}s</dd>\n          <dt>Streaming TTL</dt><dd>${state.streaming_ttl_seconds}s</dd>\n          <dt>Batch writes</dt><dd>${stats.batch_writes_total ?? 0}</dd>\n          <dt>Streaming writes</dt><dd>${stats.streaming_writes_total ?? 0}</dd>\n          <dt>Reads</dt><dd>${stats.reads_total ?? 0}</dd>\n          <dt>Fields returned</dt><dd>${stats.read_fields_total ?? 0}</dd>\n        </dl>`;\n    }\n    function renderWorker(state) {\n      const w = state.worker || {};\n      const badge = w.paused\n        ? '<span class=\"badge paused\">paused</span>'\n        : w.running ? '<span class=\"badge running\">running</span>' : '<span class=\"badge expired\">stopped</span>';\n      workerView.innerHTML = `<p>${badge} <span class=\"mono\">ticks=${w.tick_count ?? 0} writes=${w.writes_count ?? 0}</span></p>`;\n    }\n    function populateUserSelects(ids) {\n      for (const sel of [readUserSelect, inspectUserSelect]) {\n        const previous = sel.value;\n        sel.innerHTML = ids.map((id) => `<option value=\"${escapeHtml(id)}\">${escapeHtml(id)}</option>`).join(\"\");\n        if (ids.includes(previous)) sel.value = previous;\n      }\n    }\n    function renderFieldPicker() {\n      const allFields = [...BATCH_FIELDS, ...STREAM_FIELDS];\n      readFieldsBox.innerHTML = allFields.map((f) => {\n        const kind = classifyField(f);\n        const checked = [\"risk_segment\", \"tx_count_7d\", \"avg_amount_30d\", \"tx_count_5m\", \"failed_logins_15m\"].includes(f) ? \"checked\" : \"\";\n        return `<label><input type=\"checkbox\" name=\"field\" value=\"${escapeHtml(f)}\" ${checked}>\n          <span class=\"badge ${kind}\">${kind}</span>\n          <span class=\"mono\">${escapeHtml(f)}</span></label>`;\n      }).join(\"\");\n    }\n    function selectedReadFields() {\n      return Array.from(readFieldsBox.querySelectorAll('input[name=\"field\"]:checked')).map((el) => el.value);\n    }\n    async function refresh() {\n      const r = await fetch(\"/state\");\n      const state = await r.json();\n      renderStore(state); renderWorker(state); populateUserSelects(state.entity_ids || []);\n    }\n\n    document.getElementById(\"bulk-button\").addEventListener(\"click\", async () => {\n      const count = parseInt(document.getElementById(\"bulk-count\").value, 10) || 1;\n      const ttl = parseInt(document.getElementById(\"bulk-ttl\").value, 10) || 86400;\n      const body = new URLSearchParams({ count, ttl });\n      const r = await fetch(\"/bulk-load\", { method: \"POST\", body });\n      const d = await r.json();\n      if (!r.ok) { setStatus(d.error || \"Bulk-load failed.\", \"error\"); return; }\n      setStatus(`Materialized ${d.loaded} user(s) with a ${d.ttl_seconds}s key-level TTL in ${d.elapsed_ms.toFixed(1)} ms.`, \"ok\");\n      await refresh();\n    });\n    document.getElementById(\"reset-button\").addEventListener(\"click\", async () => {\n      if (!confirm(\"Drop every user from the store?\")) return;\n      const r = await fetch(\"/reset\", { method: \"POST\" });\n      const d = await r.json();\n      if (!r.ok) { setStatus(d.error || \"Reset failed.\", \"error\"); return; }\n      setStatus(`Reset. Dropped ${d.deleted} user(s).`, \"ok\");\n      await refresh();\n    });\n    document.getElementById(\"worker-pause-button\").addEventListener(\"click\", async () => {\n      const r = await fetch(\"/worker/toggle\", { method: \"POST\" });\n      const d = await r.json();\n      setStatus(d.paused ? \"Streaming worker paused.\" : \"Streaming worker resumed.\", \"ok\");\n      await refresh();\n    });\n    document.getElementById(\"read-button\").addEventListener(\"click\", async () => {\n      const user = readUserSelect.value;\n      if (!user) { setStatus(\"Pick a user first.\", \"error\"); return; }\n      const fields = selectedReadFields();\n      const body = new URLSearchParams();\n      body.set(\"user\", user);\n      for (const f of fields) body.append(\"field\", f);\n      const r = await fetch(\"/read\", { method: \"POST\", body });\n      const d = await r.json();\n      if (!r.ok) { setStatus(d.error || \"Read failed.\", \"error\"); return; }\n      const rows = (d.requested || []).map((name) => {\n        const value = d.values[name]; const ttl = d.ttls[name]; const kind = classifyField(name);\n        return `<tr>\n          <td><span class=\"badge ${kind}\">${kind}</span> <span class=\"mono\">${escapeHtml(name)}</span></td>\n          <td class=\"mono\">${value === undefined || value === null ? '<span class=\"badge expired\">missing</span>' : escapeHtml(value)}</td>\n          <td>${ttlLabel(ttl)}</td>\n        </tr>`;\n      }).join(\"\");\n      readResult.innerHTML = `\n        <p><strong>HMGET</strong> ${escapeHtml(user)} (${d.requested.length} field(s))\n          returned ${d.returned_count} value(s) in\n          <strong>${d.elapsed_ms.toFixed(2)} ms</strong>.\n          Key-level TTL: ${ttlLabel(d.key_ttl_seconds)}.</p>\n        ${d.requested.length === 0 ? \"\" : `<table><thead><tr><th>field</th><th>value</th><th>per-field TTL</th></tr></thead><tbody>${rows}</tbody></table>`}`;\n    });\n    document.getElementById(\"batch-button\").addEventListener(\"click\", async () => {\n      const count = parseInt(document.getElementById(\"batch-count\").value, 10) || 1;\n      const fields = selectedReadFields();\n      const body = new URLSearchParams();\n      body.set(\"count\", count);\n      for (const f of fields) body.append(\"field\", f);\n      const r = await fetch(\"/batch-read\", { method: \"POST\", body });\n      const d = await r.json();\n      if (!r.ok) { setStatus(d.error || \"Batch read failed.\", \"error\"); return; }\n      const perEntity = d.entity_count === 0 ? 0 : d.elapsed_ms / d.entity_count;\n      const rows = (d.sample || []).map((row) => `<tr><td class=\"mono\">${escapeHtml(row.id)}</td><td>${row.field_count}</td></tr>`).join(\"\");\n      batchResult.innerHTML = `\n        <p>Pipelined <code>HMGET</code> across <strong>${d.entity_count}</strong> users\n          (${d.field_count} field(s) each) in <strong>${d.elapsed_ms.toFixed(2)} ms</strong>\n          (~${perEntity.toFixed(3)} ms / user, one network round trip total).</p>\n        ${(d.sample || []).length === 0 ? \"\" : `<h3>Sample</h3><table><thead><tr><th>user</th><th>fields returned</th></tr></thead><tbody>${rows}</tbody></table>`}`;\n    });\n    document.getElementById(\"inspect-button\").addEventListener(\"click\", async () => {\n      const user = inspectUserSelect.value;\n      if (!user) { setStatus(\"Pick a user first.\", \"error\"); return; }\n      const params = new URLSearchParams({ user });\n      const r = await fetch(`/inspect?${params.toString()}`);\n      const d = await r.json();\n      if (!r.ok) { setStatus(d.error || \"Inspect failed.\", \"error\"); return; }\n      const rows = (d.fields || []).map((f) => `<tr>\n        <td><span class=\"badge ${classifyField(f.name)}\">${classifyField(f.name)}</span> <span class=\"mono\">${escapeHtml(f.name)}</span></td>\n        <td class=\"mono\">${escapeHtml(f.value)}</td>\n        <td>${ttlLabel(f.ttl_seconds)}</td></tr>`).join(\"\");\n      inspectResult.innerHTML = d.exists === false\n        ? `<p><span class=\"badge expired\">missing</span> <code>${escapeHtml(user)}</code> isn't in the store. Either it was never materialized or its key-level TTL expired.</p>`\n        : `<p>Key-level TTL: ${ttlLabel(d.key_ttl_seconds)} &nbsp; (${d.fields.length} field(s))</p>\n           <table><thead><tr><th>field</th><th>value</th><th>per-field TTL</th></tr></thead><tbody>${rows}</tbody></table>`;\n    });\n\n    renderFieldPicker(); refresh(); setInterval(refresh, 1500);\n  </script>\n</body>\n</html>"
    }
  ],
  "examples": []
}
