Compare commits
146
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2732622919 | ||
|
|
56133ccfae | ||
|
|
806417c1bd | ||
|
|
242a066f42 | ||
|
|
4ad904efd0 | ||
|
|
690b1fdb25 | ||
|
|
515e221c52 | ||
|
|
4e67059f7a | ||
|
|
2a1da643c1 | ||
|
|
09f2c839f7 | ||
|
|
c5e9bfe6b3 | ||
|
|
1edda33f87 | ||
|
|
8584fc225b | ||
|
|
1ae670410b | ||
|
|
6983c25d10 | ||
|
|
ad60d0c738 | ||
|
|
afcce5dc4a | ||
|
|
efa4dd9a3e | ||
|
|
70d6da761f | ||
|
|
6f61c0ee90 | ||
|
|
9d8696f748 | ||
|
|
a4fa66c466 | ||
|
|
d19eb2de9d | ||
|
|
f1a3ec00dd | ||
|
|
f1d118b81e | ||
|
|
66267e8b8c | ||
|
|
33a95cc3e4 | ||
|
|
f62c6f761b | ||
|
|
7dc5a2ae59 | ||
|
|
537e77eba6 | ||
|
|
a1f2643841 | ||
|
|
721ed4163b | ||
|
|
2967664e43 | ||
|
|
e66ed85af2 | ||
|
|
deed1a4bcc | ||
|
|
515fa6a5de | ||
|
|
e78d5a1a35 | ||
|
|
fd3f591802 | ||
|
|
c6c67d1fc6 | ||
|
|
b3826f47cb | ||
|
|
c6a20f3cf7 | ||
|
|
14a61a3b46 | ||
|
|
f830d54cd7 | ||
|
|
1d99539052 | ||
|
|
45f8f74378 | ||
|
|
8973eea064 | ||
|
|
973883385b | ||
|
|
ebeb6bbf68 | ||
|
|
f31e265454 | ||
|
|
a7742257f8 | ||
|
|
39fb62fc32 | ||
|
|
7b0418ccc5 | ||
|
|
cd3aed30d9 | ||
|
|
d284d13046 | ||
|
|
6871172da3 | ||
|
|
1b73649d40 | ||
|
|
bb52fe3de4 | ||
|
|
6fa530dab5 | ||
|
|
20ea175e3a | ||
|
|
a347988af2 | ||
|
|
855530bee5 | ||
|
|
d342519bb5 | ||
|
|
ae00475df5 | ||
|
|
87301921a9 | ||
|
|
4bec6217cf | ||
|
|
5e1c80f870 | ||
|
|
ba9cdff421 | ||
|
|
eb9215c4f1 | ||
|
|
e659fbb731 | ||
|
|
218c6b9fe7 | ||
|
|
2d78fb4459 | ||
|
|
04573efda1 | ||
|
|
e53fa0b9d4 | ||
|
|
26a35b0560 | ||
|
|
e37c1ed1cc | ||
|
|
805141a0b9 | ||
|
|
e5615c9419 | ||
|
|
64fe665cdc | ||
|
|
d46d9848f4 | ||
|
|
d5cee7ae6d | ||
|
|
d8c98c73c5 | ||
|
|
add67223b8 | ||
|
|
0521b6cc9a | ||
|
|
ee4b6ae5a4 | ||
|
|
58fe605116 | ||
|
|
71d885a9db | ||
|
|
b2ae216b07 | ||
|
|
f1338ccfc5 | ||
|
|
aa3ebbfd7c | ||
|
|
32ffcfb4c5 | ||
|
|
5f16476c81 | ||
|
|
bb9902a448 | ||
|
|
c66ba776c2 | ||
|
|
51c33a543d | ||
|
|
e48fa424e2 | ||
|
|
9175f84cf9 | ||
|
|
ecb41941fc | ||
|
|
a8a122bdfa | ||
|
|
e7ba8cebd0 | ||
|
|
0d9bb047a4 | ||
|
|
ff6086ff21 | ||
|
|
dabcadfc19 | ||
|
|
fde77d33ce | ||
|
|
d5220adcf0 | ||
|
|
3cbaeb5fc2 | ||
|
|
a234c2954d | ||
|
|
c52d0d8b31 | ||
|
|
be1557716f | ||
|
|
cefe9971b8 | ||
|
|
3cb77d9cf6 | ||
|
|
57d1dd3186 | ||
|
|
b29de24c31 | ||
|
|
42c8515b62 | ||
|
|
dfc5748358 | ||
|
|
fa42a4508f | ||
|
|
ee55515b54 | ||
|
|
acfbc4d037 | ||
|
|
a17c775c1d | ||
|
|
1879eb341f | ||
|
|
fc6282a4d4 | ||
|
|
960bfa3ce0 | ||
|
|
e16ea6e0cf | ||
|
|
e9b50406e3 | ||
|
|
197ef1b0d2 | ||
|
|
f18bd7ea39 | ||
|
|
f789839643 | ||
|
|
1730f6801b | ||
|
|
20acb883a9 | ||
|
|
4979a1dc57 | ||
|
|
63b8811776 | ||
|
|
43fb42cfb0 | ||
|
|
9793ab209e | ||
|
|
d25db5b780 | ||
|
|
15f4140ef8 | ||
|
|
69faae7f25 | ||
|
|
42754739cf | ||
|
|
6fd8b69d1c | ||
|
|
ed5a18d655 | ||
|
|
4693147750 | ||
|
|
f636d74e02 | ||
|
|
f374047387 | ||
|
|
b43630cf49 | ||
|
|
d699696869 | ||
|
|
459cb5ab2b | ||
|
|
253268bd60 | ||
|
|
07f9e0c5d2 |
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
mkdocs-material==9.6.14
|
||||||
|
mkdocs-same-dir==0.1.3
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly venv_dir="/var/lib/gitea-runner/venvs/site"
|
||||||
|
|
||||||
|
if [[ $# -ne 1 ]]; then
|
||||||
|
echo "Usage: $0 SOURCE_DIR" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
source_dir=$(realpath "$1")
|
||||||
|
requirements_file="${source_dir}/.gitea/requirements-site.txt"
|
||||||
|
|
||||||
|
if [[ ! -f $requirements_file ]]; then
|
||||||
|
echo "Requirements file not found: ${requirements_file}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$venv_dir")"
|
||||||
|
if [[ ! -x "${venv_dir}/bin/python" ]]; then
|
||||||
|
python3 -m venv "$venv_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
"${venv_dir}/bin/python" -m pip install \
|
||||||
|
--disable-pip-version-check \
|
||||||
|
--no-input \
|
||||||
|
--requirement "$requirements_file"
|
||||||
|
"${venv_dir}/bin/python" -m pip check
|
||||||
|
cd "$source_dir"
|
||||||
|
"${venv_dir}/bin/python" -m mkdocs build --strict
|
||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
readonly deploy_root="/srv/de-roadmap"
|
||||||
|
readonly releases_root="${deploy_root}/releases"
|
||||||
|
readonly keep_releases=3
|
||||||
|
|
||||||
|
if [[ $# -ne 2 ]]; then
|
||||||
|
echo "Usage: $0 SITE_DIR COMMIT_SHA-RUN_ID" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
source_dir=$(realpath "$1")
|
||||||
|
release_id=$2
|
||||||
|
|
||||||
|
if [[ ! $release_id =~ ^[0-9a-f]{40}-[0-9]+$ ]]; then
|
||||||
|
echo "Invalid release id: ${release_id}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${source_dir}/index.html" ]]; then
|
||||||
|
echo "Built site has no index.html: ${source_dir}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d $releases_root || ! -w $releases_root || ! -w $deploy_root ]]; then
|
||||||
|
echo "Deployment directories are missing or not writable" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
readonly release_dir="${releases_root}/${release_id}"
|
||||||
|
readonly staging_dir="${releases_root}/.${release_id}.tmp"
|
||||||
|
readonly next_link="${deploy_root}/.current.${release_id}.tmp"
|
||||||
|
|
||||||
|
if [[ -e $release_dir || -e $staging_dir || -e $next_link ]]; then
|
||||||
|
echo "Release path already exists: ${release_id}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf -- "$staging_dir"
|
||||||
|
rm -f -- "$next_link"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
umask 0022
|
||||||
|
mkdir "$staging_dir"
|
||||||
|
cp -a "${source_dir}/." "$staging_dir/"
|
||||||
|
chmod -R u=rwX,go=rX "$staging_dir"
|
||||||
|
mv "$staging_dir" "$release_dir"
|
||||||
|
|
||||||
|
ln -s "releases/${release_id}" "$next_link"
|
||||||
|
mv -Tf "$next_link" "${deploy_root}/current"
|
||||||
|
|
||||||
|
mapfile -t old_releases < <(
|
||||||
|
find "$releases_root" \
|
||||||
|
-mindepth 1 \
|
||||||
|
-maxdepth 1 \
|
||||||
|
-type d \
|
||||||
|
-regextype posix-extended \
|
||||||
|
-regex '.*/[0-9a-f]{40}-[0-9]+' \
|
||||||
|
-printf '%T@ %f\n' \
|
||||||
|
| sort -nr \
|
||||||
|
| awk -v keep="$keep_releases" 'NR > keep { print $2 }'
|
||||||
|
)
|
||||||
|
|
||||||
|
for old_release in "${old_releases[@]}"; do
|
||||||
|
if [[ $old_release =~ ^[0-9a-f]{40}-[0-9]+$ && $old_release != "$release_id" ]]; then
|
||||||
|
rm -rf -- "${releases_root:?}/${old_release}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
echo "Published release ${release_id}"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
name: Deploy MkDocs to VPS
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: site-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: de-roadmap-host
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out the triggering commit
|
||||||
|
env:
|
||||||
|
COMMIT_SHA: ${{ gitea.sha }}
|
||||||
|
REPOSITORY_URL: ${{ gitea.server_url }}/${{ gitea.repository }}.git
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[[ "$COMMIT_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||||
|
test ! -e source
|
||||||
|
mkdir source
|
||||||
|
git -C source init .
|
||||||
|
git -C source remote add origin "$REPOSITORY_URL"
|
||||||
|
git -C source fetch --no-tags --depth=1 origin "$COMMIT_SHA"
|
||||||
|
test "$(git -C source rev-parse FETCH_HEAD)" = "$COMMIT_SHA"
|
||||||
|
git -C source -c advice.detachedHead=false checkout --detach FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Build the site strictly
|
||||||
|
run: source/.gitea/scripts/build-site.sh source
|
||||||
|
|
||||||
|
- name: Publish the complete release atomically
|
||||||
|
env:
|
||||||
|
RELEASE_ID: ${{ gitea.sha }}-${{ gitea.run_id }}
|
||||||
|
run: source/.gitea/scripts/deploy-site.sh source/site "$RELEASE_ID"
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Check external links
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 6 * * 1" # понедельник 06:00 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
actions: write # для keepalive-шага
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
link-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Run lychee
|
||||||
|
id: lychee
|
||||||
|
uses: lycheeverse/lychee-action@v2
|
||||||
|
with:
|
||||||
|
# остальные настройки — в lychee.toml в корне репозитория
|
||||||
|
args: --no-progress './**/*.md'
|
||||||
|
fail: false
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# если открытый issue с меткой link-check уже есть — обновляем его,
|
||||||
|
# а не создаём дубликат каждую неделю
|
||||||
|
- name: Find open link-check issue
|
||||||
|
if: steps.lychee.outputs.exit_code != 0
|
||||||
|
id: issue
|
||||||
|
run: |
|
||||||
|
echo "number=$(gh issue list --repo "$GITHUB_REPOSITORY" --label link-check --state open --json number --jq '.[0].number // empty')" >> "$GITHUB_OUTPUT"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Create or update issue on broken links
|
||||||
|
if: steps.lychee.outputs.exit_code != 0
|
||||||
|
uses: peter-evans/create-issue-from-file@v5
|
||||||
|
with:
|
||||||
|
title: "Битые внешние ссылки: еженедельная проверка"
|
||||||
|
content-filepath: ./lychee/out.md
|
||||||
|
labels: link-check
|
||||||
|
issue-number: ${{ steps.issue.outputs.number }}
|
||||||
|
|
||||||
|
# GitHub отключает scheduled-workflows после 60 дней без активности
|
||||||
|
# в репозитории; повторное включение сбрасывает таймер
|
||||||
|
- name: Keep scheduled workflow enabled
|
||||||
|
if: always()
|
||||||
|
run: gh api -X PUT "repos/$GITHUB_REPOSITORY/actions/workflows/check-links.yml/enable"
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
name: Deploy MkDocs to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.x"
|
||||||
|
|
||||||
|
- run: pip install mkdocs-material==9.6.14 mkdocs-same-dir==0.1.3
|
||||||
|
|
||||||
|
- run: mkdocs build --strict
|
||||||
|
|
||||||
|
- uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: site/
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -1 +1,5 @@
|
|||||||
.env
|
.env
|
||||||
|
.idea
|
||||||
|
.internal/
|
||||||
|
site/
|
||||||
|
tmp
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
- Root `README.md` describes the learning roadmap (RU) and serves as the main page of the MkDocs site.
|
||||||
|
- `dwh-modeling/` contains the article and demo DWH model; SQL lives in `dwh-modeling/sql` as ordered scripts `01_...sql`–`09_...sql` (07–09 are homework DDL, template and solution).
|
||||||
|
- `postgres-bookings/` is a Dockerized PostgreSQL + demo “bookings” DB; start it first, then apply DWH scripts against the `demo` database.
|
||||||
|
- `mkdocs.yml` — MkDocs Material config; `docs_dir: .` (repo root = site root). Excluded dirs: `project/`, `postgres-bookings/`, `.gitea/`, `.github/`, `.claude/`.
|
||||||
|
- `.gitea/workflows/deploy-site.yml` — основной CI/CD: push в `main` → строгая
|
||||||
|
сборка → атомарная публикация на VPS через repository-scoped Gitea Runner.
|
||||||
|
- `.github/workflows/deploy-site.yml` — сохранённый workflow для резервной
|
||||||
|
публикации на GitHub Pages; Gitea его не исполняет.
|
||||||
|
- `project/` — PRD, ADR, and TODO.md (excluded from site). `project/TODO.md` is the prioritized project backlog: check it when planning or proposing work, and mark items done there when you complete them.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- Start demo Postgres:
|
||||||
|
`cd postgres-bookings && bash download_db.sh && docker compose up -d`
|
||||||
|
- Open `psql` inside the container:
|
||||||
|
`cd postgres-bookings && ./psql_sh`
|
||||||
|
- Apply DWH schema from the repo root (after Postgres is up):
|
||||||
|
`psql -h 127.0.0.1 -p 5432 -U postgres -d demo -f dwh-modeling/sql/01_ddl_stg-dds.sql`
|
||||||
|
- Stop and reset the cluster when needed:
|
||||||
|
`cd postgres-bookings && docker compose down -v`
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
- SQL: PostgreSQL dialect, uppercase keywords, `snake_case` identifiers, 4-space indentation, and concise comments (`-- ...`).
|
||||||
|
- SQL files: keep numeric prefixes (`01_`, `02_`, …) to reflect execution order and use descriptive suffixes like `ddl_*` / `dml_*`.
|
||||||
|
- Shell: target `bash`, prefer simple, POSIX-friendly constructs; mirror the style of existing scripts in `postgres-bookings/`.
|
||||||
|
|
||||||
|
## Markdown Style (dual-compatible: GitHub + MkDocs Material)
|
||||||
|
|
||||||
|
All `.md` files MUST render correctly on both GitHub and the MkDocs Material site. Follow these rules:
|
||||||
|
|
||||||
|
**Headings:**
|
||||||
|
- `README.md` (root): start from `##` (H2). Do NOT use `#` (H1) — MkDocs uses H1 as page title, multiple H1s break the right-sidebar TOC.
|
||||||
|
- `dwh-modeling/*.md`: may use `#` (H1) since each file is a separate page on the site.
|
||||||
|
|
||||||
|
**Lists:**
|
||||||
|
- Always leave a **blank line before the first list item** after a paragraph, heading, or any non-list text. Python-Markdown (MkDocs) requires this; GitHub tolerates its absence but blank lines don't hurt.
|
||||||
|
- Use **4-space indentation** for nested lists (not 2-space). GitHub supports both, Python-Markdown requires 4.
|
||||||
|
- Example:
|
||||||
|
```markdown
|
||||||
|
Some introductory text:
|
||||||
|
|
||||||
|
- Item one
|
||||||
|
- Item two
|
||||||
|
- Nested item (4 spaces)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Links:**
|
||||||
|
- Internal links: always use **relative paths to `.md` files**: `[text](dwh-modeling/SCD.md)`. MkDocs resolves them automatically.
|
||||||
|
- Anchor links: use lowercase slugs with single hyphens. Avoid em-dash `—` in headings (it produces `--` in MkDocs slugs vs `-` on GitHub). Use commas or colons instead.
|
||||||
|
|
||||||
|
**Special characters in headings:**
|
||||||
|
- OK: colons `:`, commas `,`, parentheses `()`, guillemets `«»` — stripped equally by both platforms.
|
||||||
|
- Avoid: em-dash `—`, en-dash `–` — slug behavior differs between GitHub and MkDocs.
|
||||||
|
|
||||||
|
## MkDocs Site Commands
|
||||||
|
- Initialize or update the persistent local environment:
|
||||||
|
`python3 -m venv "${HOME}/.cache/de-roadmap-mkdocs" && "${HOME}/.cache/de-roadmap-mkdocs/bin/python" -m pip install -r .gitea/requirements-site.txt`
|
||||||
|
- Local preview (user starts, ask user to run via `!`):
|
||||||
|
`"${HOME}/.cache/de-roadmap-mkdocs/bin/python" -m mkdocs serve`
|
||||||
|
- Build with strict validation (catches broken links/anchors):
|
||||||
|
`"${HOME}/.cache/de-roadmap-mkdocs/bin/python" -m mkdocs build --strict`
|
||||||
|
- Visual check via Playwright (when `mkdocs serve` is running on port 8000):
|
||||||
|
`npx playwright screenshot --viewport-size='1280,800' 'http://127.0.0.1:8000/#anchor' /path/to/screenshot.png`
|
||||||
|
Then read the screenshot with the Read tool to inspect rendering. Use `--viewport-size='1280,2000'` for tall pages.
|
||||||
|
- Kill stuck dev server: `lsof -ti :8000 | xargs kill`
|
||||||
|
- Site URL: `https://de.dementev.space/` (старый адрес `https://dementev-dev.github.io/de-roadmap/` отдаёт 404)
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
- There is no dedicated test framework; treat SQL scripts as executable documentation.
|
||||||
|
- For `dwh-modeling/sql`, run scripts sequentially and rerun `04_validation.sql` after changes to ensure the demo model still loads and basic checks pass.
|
||||||
|
- For `postgres-bookings`, after modifications run `docker compose up -d && ./psql_sh` and verify simple queries such as `SELECT COUNT(*) FROM bookings.flights;`.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
**Required:** Read [COMMIT_RULES.md](COMMIT_RULES.md) before making commits.
|
||||||
|
|
||||||
|
Pull requests should focus on one topic, include a brief context, list of changes, and manual steps to reproduce or validate (commands you ran, expected results).
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
- Do not commit personal `.env` files or credentials; use local overrides only.
|
||||||
|
- Gitea Runner работает в host mode: не расширяйте его scope, не добавляйте
|
||||||
|
пользователя `gitea-runner` в `sudo` или `docker` и не выдавайте ему запись
|
||||||
|
вне `/var/lib/gitea-runner` и `/srv/de-roadmap`.
|
||||||
|
- Demo credentials and ports in `postgres-bookings` are for local training only—never reuse them in shared or production environments.
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
# Commit Rules
|
||||||
|
|
||||||
|
Unified commit style for all project contributors. Follows [Conventional Commits](https://www.conventionalcommits.org/) specification.
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
- **Primary language**: Russian
|
||||||
|
- If language is not specified, use Russian
|
||||||
|
- For AI-generated commits, Russian is mandatory unless task explicitly sets `lang:en`
|
||||||
|
- English is allowed only by explicit instruction (`lang:en`) or external collaboration requirements
|
||||||
|
- Do not mix languages in free-text parts of one commit message (subject + body + footer)
|
||||||
|
- Conventional Commit `type(scope)` stays in English
|
||||||
|
- Technical terms (PostgreSQL, SQL, DWH, DDL, DML) keep as-is
|
||||||
|
|
||||||
|
## Header Format
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <short description>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Maximum header length: 72 characters
|
||||||
|
- For Russian subject, use result form (e.g. "добавлено", "исправлено", "обновлено")
|
||||||
|
- For English subject, use imperative present form (e.g. "add", "fix", "update")
|
||||||
|
- For English subject, do not use past forms (e.g. "added", "fixed", "updated")
|
||||||
|
- No trailing period
|
||||||
|
- Keep subject specific; avoid vague messages like "update", "fix bug", "changes"
|
||||||
|
|
||||||
|
### Allowed `type`
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `feat` | New feature |
|
||||||
|
| `fix` | Bug fix |
|
||||||
|
| `refactor` | Code restructuring without behavior change |
|
||||||
|
| `docs` | Documentation only |
|
||||||
|
| `test` | Tests, checks, validations |
|
||||||
|
| `chore` | Maintenance (configs, scripts, hooks) |
|
||||||
|
| `ci` | CI/CD changes |
|
||||||
|
| `perf` | Performance optimization |
|
||||||
|
| `revert` | Revert previous commit |
|
||||||
|
|
||||||
|
### Recommended `scope` for this repo
|
||||||
|
|
||||||
|
| Scope | Used for |
|
||||||
|
|-------|----------|
|
||||||
|
| `sql` | SQL scripts in `dwh-modeling/sql/` |
|
||||||
|
| `modeling` | DWH modeling docs, articles, schemas in `dwh-modeling/` |
|
||||||
|
| `bookings` | Docker PostgreSQL demo in `postgres-bookings/` |
|
||||||
|
| `docs` | Documentation, README, guides |
|
||||||
|
| `data` | Data files (CSV, fixtures) |
|
||||||
|
|
||||||
|
## Body Structure
|
||||||
|
|
||||||
|
For non-trivial changes, body is required. Use bullet points for readability.
|
||||||
|
|
||||||
|
Body is considered required when at least one condition is true:
|
||||||
|
- behavior or API/contract changed
|
||||||
|
- migration, rollback risk, or compatibility impact exists
|
||||||
|
- more than one meaningful file/module changed
|
||||||
|
- fix is non-obvious from header alone
|
||||||
|
|
||||||
|
### Multiline body in CLI (important)
|
||||||
|
|
||||||
|
- Do not pass body as one quoted string with `\n` (it will be stored literally).
|
||||||
|
- Use multiple `-m` flags, or `-F` with heredoc.
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit \
|
||||||
|
-m "feat(sql): добавлена валидация данных для DWH" \
|
||||||
|
-m "- Зачем:
|
||||||
|
- нужна проверка целостности перед загрузкой
|
||||||
|
- Что:
|
||||||
|
- добавлен скрипт 04_validation.sql
|
||||||
|
- добавлены проверки на NULL и уникальность
|
||||||
|
- Проверка:
|
||||||
|
- psql -f dwh-modeling/sql/04_validation.sql"
|
||||||
|
```
|
||||||
|
|
||||||
|
Also correct:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -F- <<'MSG'
|
||||||
|
feat(sql): добавлена валидация данных для DWH
|
||||||
|
|
||||||
|
- Зачем:
|
||||||
|
- нужна проверка целостности перед загрузкой
|
||||||
|
- Что:
|
||||||
|
- добавлен скрипт 04_validation.sql
|
||||||
|
- добавлены проверки на NULL и уникальность
|
||||||
|
- Проверка:
|
||||||
|
- psql -f dwh-modeling/sql/04_validation.sql
|
||||||
|
MSG
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template (Russian - default)
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <краткое описание результата>
|
||||||
|
|
||||||
|
- Зачем:
|
||||||
|
- причина изменения
|
||||||
|
- Что:
|
||||||
|
- ключевое изменение 1
|
||||||
|
- ключевое изменение 2
|
||||||
|
- Проверка:
|
||||||
|
- как проверено
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template (English - only with `lang:en`)
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <short action description>
|
||||||
|
|
||||||
|
- Why:
|
||||||
|
- reason for change
|
||||||
|
- What:
|
||||||
|
- key change 1
|
||||||
|
- key change 2
|
||||||
|
- Check:
|
||||||
|
- how verified (command/test/smoke-check)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit Scope Rules
|
||||||
|
|
||||||
|
- One commit = one logical task
|
||||||
|
- Don't mix feature changes with large refactoring
|
||||||
|
- Update docs in the same commit where behavior changes
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
Use `!` in header for breaking changes:
|
||||||
|
```
|
||||||
|
feat(sql)!: rename stg_orders column contract
|
||||||
|
```
|
||||||
|
|
||||||
|
Add footer:
|
||||||
|
```
|
||||||
|
BREAKING CHANGE: column order_date renamed to created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Good examples
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(sql): добавлен скрипт загрузки DM-слоя
|
||||||
|
|
||||||
|
- Зачем:
|
||||||
|
- нужны витрины для аналитики
|
||||||
|
- Что:
|
||||||
|
- добавлен 06_dml_dm.sql с загрузкой фактов и измерений
|
||||||
|
- добавлены индексы для оптимизации запросов
|
||||||
|
- Проверка:
|
||||||
|
- psql -f dwh-modeling/sql/06_dml_dm.sql
|
||||||
|
- SELECT COUNT(*) FROM dm.fact_orders;
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(bookings): исправлен порт в docker-compose.yml
|
||||||
|
|
||||||
|
- Зачем:
|
||||||
|
- конфликт с локальным PostgreSQL на 5432
|
||||||
|
- Что:
|
||||||
|
- порт хоста изменен на 5433
|
||||||
|
- Проверка:
|
||||||
|
- docker compose up -d
|
||||||
|
- psql -h localhost -p 5433 -U postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
docs(modeling): обновлена схема Data Vault после ревью
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
chore(docs): синхронизировано оглавление README
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bad examples (don't do this)
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ added sql script # no type, past tense
|
||||||
|
❌ feat: добавлен скрипт # no scope
|
||||||
|
❌ fix: исправлен баг # no scope, vague and non-actionable
|
||||||
|
❌ feat(sql): added new table # past tense in English subject
|
||||||
|
❌ feat(sql): add script and fix validation and update docs # multiple concerns
|
||||||
|
❌ feat(docs): add README и почини SQL # mixed languages in one message
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Feature
|
||||||
|
feat(scope): добавлена новая возможность
|
||||||
|
|
||||||
|
# Bug fix
|
||||||
|
fix(scope): исправлена проблема
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
docs(scope): обновлена документация
|
||||||
|
|
||||||
|
# Refactoring
|
||||||
|
refactor(scope): упрощена структура без изменения поведения
|
||||||
|
|
||||||
|
# Performance
|
||||||
|
perf(scope): ускорено выполнение
|
||||||
|
|
||||||
|
# Maintenance
|
||||||
|
chore(scope): обновлены служебные настройки
|
||||||
|
|
||||||
|
# Feature (lang:en)
|
||||||
|
feat(scope): add new capability
|
||||||
|
|
||||||
|
# Bug fix (lang:en)
|
||||||
|
fix(scope): correct response parsing
|
||||||
|
|
||||||
|
# Documentation (lang:en)
|
||||||
|
docs(scope): update setup guide
|
||||||
|
```
|
||||||
@@ -1,157 +1,605 @@
|
|||||||
# Основные знания
|
## О роадмапе
|
||||||
|
|
||||||
## База по Git
|
Этот роадмап — конспект моих подходов к обучению Data Engineering.
|
||||||
|
Он подойдёт тем, кто хочет:
|
||||||
|
|
||||||
|
- системно войти в профессию с нуля или близкого к нулю уровня;
|
||||||
|
- закрыть пробелы в базе (SQL, Git, Python, DWH, Airflow, Greenplum, Kafka, ClickHouse);
|
||||||
|
- подготовиться к собеседованиям и первым рабочим задачам.
|
||||||
|
|
||||||
|
Роадмап можно проходить самостоятельно или вместе со мной в формате менторства.
|
||||||
|
Если хотите идти с поддержкой ментора — напишите в Telegram: [@dementev_dev](https://t.me/dementev_dev).
|
||||||
|
|
||||||
|
### Оглавление
|
||||||
|
|
||||||
|
- [Основные знания](#основные-знания) — Linux, Git, SQL, Python, методологии, Docker
|
||||||
|
- [Практика и инструменты](#практика-и-инструменты) - Airflow, Greenplum, Streaming, ClickHouse, две курсовые
|
||||||
|
- [Карьера и менторство](#карьера-и-менторство) — резюме, собеседования, испытательный срок
|
||||||
|
- [Расширенные навыки](#расширенные-навыки) - Lakehouse, dbt
|
||||||
|
- [Софт скиллы](#софт-скиллы)
|
||||||
|
- [Дополнительные материалы](#дополнительные-материалы)
|
||||||
|
|
||||||
|
Рекомендуемый способ использования:
|
||||||
|
|
||||||
|
- двигаться по разделам последовательно, не перепрыгивая через базу;
|
||||||
|
- выполнять практику и домашки, а не только смотреть материалы;
|
||||||
|
- возвращаться к разделам по мере появления реальных задач.
|
||||||
|
|
||||||
|
Основная программа включает две курсовые: сначала хранилище на Greenplum,
|
||||||
|
затем [Clickstream на ClickHouse](#большая-курсовая-clickstream).
|
||||||
|
Streaming и ClickHouse входят в обязательную часть подготовки к трудоустройству.
|
||||||
|
Lakehouse и dbt можно изучать после нее, под задачи и интересующие вакансии.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Основные знания
|
||||||
|
|
||||||
|
[[к оглавлению]](#оглавление)
|
||||||
|
|
||||||
|
### Git и базовые инструменты
|
||||||
|
|
||||||
|
#### Linux и терминал
|
||||||
|
|
||||||
|
Командная строка — рабочее место дата-инженера: docker, psql, git, подключение к серверам живут именно там. Отдельная практика не нужна — все стенды этого роадмапа консольные, команды закрепятся сами. На Windows поставьте [WSL](https://learn.microsoft.com/ru-ru/windows/wsl/install) — полноценный Linux внутри Windows.
|
||||||
|
|
||||||
|
- [Основы Linux для начинающих за 1.5 часа - Youtube](https://www.youtube.com/watch?v=Be6tB59b7D0) — что такое терминал, навигация, файлы, права, ssh: мягкий вход перед статьями
|
||||||
|
- [Linux: Файлы, навигация и поиск - Habr](https://habr.com/ru/articles/1003550/) — перемещение по каталогам, чтение файлов и логов: less, tail, grep
|
||||||
|
- [Права доступа к файлам и папкам в Linux - FirstVDS](https://firstvds.ru/technology/linux-permissions) — rwx, chmod, chown
|
||||||
|
- [SSH для начинающих - Cloud.ru](https://cloud.ru/blog/ssh-dlya-nachinayuschikh) — подключение к удалённой машине
|
||||||
|
- [Основы командной строки - Hexlet](https://ru.hexlet.io/programs/cli-basics) — опционально: бесплатный интерактивный курс с терминалом прямо в браузере; достаточно уроков про навигацию, grep и права доступа
|
||||||
|
|
||||||
|
#### База по Git
|
||||||
Что такое контроль версий, когда используется, ПОЧЕМУ и как мы в обучении будем использовать.
|
Что такое контроль версий, когда используется, ПОЧЕМУ и как мы в обучении будем использовать.
|
||||||
Как создать репозиторий на GitHub, сохранять в нем изменения.
|
Как создать репозиторий на GitHub, сохранять в нем изменения.
|
||||||
|
|
||||||
- [Что такое Git для Начинающих / GitHub за 30 минут / Git Уроки - Youtube](https://www.youtube.com/watch?v=VJm_AjiTEEc)
|
- [Что такое Git для Начинающих / GitHub за 30 минут / Git Уроки - Youtube](https://www.youtube.com/watch?v=VJm_AjiTEEc)
|
||||||
|
- [Git: Конфликты для Начинающих // Git Cherry Pick, Git Revert, Git Reset - Youtube](https://www.youtube.com/watch?v=F7FnnfnB9YY)
|
||||||
- Книга [Pro Git](https://git-scm.com/book/ru/v2) - читать главу 1
|
- Книга [Pro Git](https://git-scm.com/book/ru/v2) - читать главу 1
|
||||||
|
|
||||||
Основы Markdown
|
#### Основы Markdown
|
||||||
|
|
||||||
- [Язык Markdown и файл README | Git и GitHub для начинающих - Youtube](https://www.youtube.com/watch?v=8lEDTrr-G4U)
|
- [Язык Markdown и файл README | Git и GitHub для начинающих - Youtube](https://www.youtube.com/watch?v=8lEDTrr-G4U)
|
||||||
- [Markdown и его возможности: простой способ оформления текста](https://kurshub.ru/journal/blog/markdown-chto-eto/)
|
- [Markdown и его возможности: простой способ оформления текста](https://kurshub.ru/journal/blog/markdown-chto-eto/)
|
||||||
- [Синтаксис Markdown: подробная шпаргалка для веб-разработчиков / Skillbox Media](https://skillbox.ru/media/code/yazyk-razmetki-markdown-shpargalka-po-sintaksisu-s-primerami/)
|
- [Синтаксис Markdown: подробная шпаргалка для веб-разработчиков / Skillbox Media](https://skillbox.ru/media/code/yazyk-razmetki-markdown-shpargalka-po-sintaksisu-s-primerami/)
|
||||||
|
|
||||||
Домашки по остальным темам тренируемся делать в Git, там же пишем документацию.
|
Домашки по остальным темам тренируемся делать в Git, там же пишем документацию.
|
||||||
## SQL
|
|
||||||
### База по SQL
|
**Когда блок Git и базовые инструменты считаем пройденным:**
|
||||||
|
|
||||||
|
- ориентируетесь в терминале: перемещаетесь по каталогам и находите нужное в логах (grep, tail, less);
|
||||||
|
- понимаете права файлов (rwx, chmod) и знаете, как подключиться к серверу по ssh;
|
||||||
|
- вы уверенно создаёте репозиторий, коммитите изменения и отправляете их на GitHub;
|
||||||
|
- имеете представление о работе с ветками: создание, переключение, что такое merge/PR и разруливание простых конфликтов;
|
||||||
|
- оформляете базовую документацию в Markdown (README, заголовки, списки, ссылки, кодовые блоки).
|
||||||
|
|
||||||
|
### Базы данных: SQL и моделирование данных
|
||||||
|
|
||||||
|
SQL и моделирование данных специально идут рядом: сначала учимся уверенно извлекать данные запросами, затем — понимать и проектировать структуру данных, чтобы ETL/витрины были осмысленными.
|
||||||
|
#### База по SQL
|
||||||
Книга: [PostgreSQL. Основы языка SQL](https://postgrespro.ru/education/books/sqlprimer) - Глава 1 "Введение в базы данных и SQL" + ДЗ
|
Книга: [PostgreSQL. Основы языка SQL](https://postgrespro.ru/education/books/sqlprimer) - Глава 1 "Введение в базы данных и SQL" + ДЗ
|
||||||
|
|
||||||
Бесплатный тренажер: [Интерактивный тренажер по SQL – Stepik](https://stepik.org/course/63054/promo)
|
Бесплатный тренажер: [Интерактивный тренажер по SQL – Stepik](https://stepik.org/course/63054/promo)
|
||||||
Целевой уровень знания SQL - Live кодинг на собесе. Проверяем на первом мок-интервью
|
Целевой уровень знания SQL - Live кодинг на собесе. Проверяем на первом мок-интервью
|
||||||
**СТЕ**
|
|
||||||
|
**CTE**
|
||||||
|
|
||||||
- Зачем нам CTE: [Getting started with CTEs | dbt Labs](https://www.getdbt.com/blog/getting-started-with-cte)
|
- Зачем нам CTE: [Getting started with CTEs | dbt Labs](https://www.getdbt.com/blog/getting-started-with-cte)
|
||||||
- Подробнее про синтаксис: [PostgreSQL : Документация: 17: 7.8. Запросы WITH (Общие табличные выражения) : Компания Postgres Professional](https://postgrespro.ru/docs/postgresql/17/queries-with)
|
- Подробнее про синтаксис: [PostgreSQL : Документация: 17: 7.8. Запросы WITH (Общие табличные выражения) : Компания Postgres Professional](https://postgrespro.ru/docs/postgresql/17/queries-with)
|
||||||
|
|
||||||
Для дальнейшей тренировки и поддержания уровня можно использовать [Database - LeetCode](https://leetcode.com/problem-list/database/). Хорошая подборка задачек: [SQL 50 - Study Plan - LeetCode](https://leetcode.com/studyplan/top-sql-50/)
|
Для дальнейшей тренировки и поддержания уровня можно использовать [Database - LeetCode](https://leetcode.com/problem-list/database/). Хорошая подборка задачек: [SQL 50 - Study Plan - LeetCode](https://leetcode.com/studyplan/top-sql-50/)
|
||||||
### Повышение знаний SQL
|
|
||||||
|
#### Повышение знаний SQL
|
||||||
|
|
||||||
|
**Перед DEV1: запустите учебную базу.** Для практики рекомендую
|
||||||
|
[стенд PostgreSQL с базой bookings](https://github.com/dementev-dev/de-roadmap/tree/main/postgres-bookings).
|
||||||
|
В README стенда описаны запуск в Docker, подключение через `psql` и DBeaver.
|
||||||
|
Это наше первое знакомство с Docker: пока достаточно научиться запускать
|
||||||
|
и останавливать стенд, смотреть его статус и подключаться к базе.
|
||||||
|
Подробный [блок Docker](#docker) будет дальше.
|
||||||
|
|
||||||
Смотрим курс от Postgres Pro [DEV1](https://postgrespro.ru/education/courses/DEV1)
|
Смотрим курс от Postgres Pro [DEV1](https://postgrespro.ru/education/courses/DEV1)
|
||||||
Темы - от "Введение" до "SQL" включительно, "Управление доступом", "Резервное копирование". Для лучшего усваивания материала проделываем все примеры и домашние задания из конспектов лекция.
|
Темы - от "Введение" до "SQL" включительно, "Управление доступом", "Резервное копирование". Для лучшего усваивания материала проделываем все примеры и домашние задания из конспектов лекций.
|
||||||
С темой "PL/pgSQL" можно ознакомиться обзорно.
|
С темой "PL/pgSQL" можно ознакомиться обзорно.
|
||||||
|
|
||||||
Для развития навыков инженера будет полезно лабораторные работы делать не в виртуальной машине, а в docker контейнере. Предложенный (не обязательный) вариант - в каталоге `postgres-bookings` репозитория.
|
|
||||||
|
|
||||||
Для дальнейшего закрепления материала - читаем книгу [PostgreSQL. Основы языка SQL](https://postgrespro.ru/education/books/sqlprimer)
|
Для дальнейшего закрепления материала - читаем книгу [PostgreSQL. Основы языка SQL](https://postgrespro.ru/education/books/sqlprimer)
|
||||||
|
|
||||||
- Глава 8 - Индексы + ДЗ
|
- Глава 8 - Индексы + ДЗ
|
||||||
- Глава 9 - Транзакции
|
- Глава 9 - Транзакции
|
||||||
- Глава 10 - Повышение производительности + ДЗ
|
- Глава 10 - Повышение производительности + ДЗ
|
||||||
|
|
||||||
Вопросы оптимизации запросов хорошо описаны в курсе [QPT](https://postgrespro.ru/education/courses/QPT) от Postgres Pro. Полученные навыки применимы для работы в том числе с GreenPlum, и частично, другими БД.
|
Вопросы оптимизации запросов хорошо описаны в курсе [QPT](https://postgrespro.ru/education/courses/QPT) от Postgres Pro. Полученные навыки применимы для работы в том числе с Greenplum, и частично, другими БД.
|
||||||
На момент написания, видеолекции были доступны только для старой версии Postgres 13, но ее вполне достаточно.
|
На странице курса выбирайте материалы и видеозаписи для PostgreSQL 16.
|
||||||
|
|
||||||
## Python
|
#### Моделирование данных
|
||||||
2 курса по Python - простой и расширенный
|
Понимание того, **как устроены данные и зачем они нужны**, — ключ к качественным ETL-процессам.
|
||||||
- ["Поколение Python": курс для начинающих – Stepik](https://stepik.org/course/58852/info)
|
Мы кратко разбираем:
|
||||||
- ["Поколение Python": курс для продвинутых – Stepik](https://stepik.org/course/68343/info)
|
|
||||||
- Кратко(???) про ООП, менеджеры контекста
|
- Основные подходы: нормализованные (3NF) vs денормализованные (звезда, снежинка)
|
||||||
|
- Что такое staging, marts, слои raw / clean / business
|
||||||
|
- Как проектировать таблицы под конкретные сценарии использования
|
||||||
|
|
||||||
|
Цель — не стать архитектором, а **уметь читать и объяснять структуру данных**, чтобы писать осмысленные запросы и трансформации.
|
||||||
|
|
||||||
|
Материалы (включая демо DWH-модель из этого репозитория):
|
||||||
|
|
||||||
|
- Мартин Клеппман — «Высоконагруженные приложения» - Глава 2: Модели данных и языки запросов. - Для понимания, чем реляционная модель (SQL) отличается от документной (NoSQL) и графовой, и почему для аналитики мы всё ещё любим таблицы
|
||||||
|
(Важно: не перепутайте главу 2 с Частью 2 про распределенные данные!).
|
||||||
|
- [Яндекс Практикум: что такое нормализация, простыми словами (для самых начинающих)](https://practicum.yandex.ru/blog/chto-takoe-normalizaciya-dannyh/)
|
||||||
|
- [Базы данных. 1,2,3 нормальные формы. - Youtube](https://www.youtube.com/watch?v=zwQzL80U51c)
|
||||||
|
- [Введение в структуру хранилища данных](dwh-modeling/README.md)
|
||||||
|
- Теория про Slowly Changing Dimensions: [SCD](dwh-modeling/SCD.md)
|
||||||
|
- (Опционально) Ральф Кимбалл — «Инструментарий хранения и анализа данных» (The Data Warehouse Toolkit) - первые 3 главы
|
||||||
|
- Практика по моделированию статусов клиента: [домашка STG → ODS → DDS → DM](dwh-modeling/Homework_Customer_Status_DDS_DM.md)
|
||||||
|
- Data Vault и Anchor Modeling изучаем обзорно, для знакомства с основными
|
||||||
|
понятиями. Глубокий разбор и самостоятельное проектирование на этом этапе
|
||||||
|
не требуются. Материалы для знакомства:
|
||||||
|
- Конспект и примеры из этого репозитория: [DataVault.md](dwh-modeling/DataVault.md)
|
||||||
|
- [DataVault за 10 минут - Youtube](https://www.youtube.com/watch?v=9oQs_wJ045I)
|
||||||
|
- Статья [«Что такое Data Vault: моделирование КХД для архитектора Big Data»](https://bigdataschool.ru/blog/what-is-data-vault/) — обзор, плюсы/минусы, контекст применения.
|
||||||
|
- [Гибкие методологии проектирования Data Vault и Anchor Modeling | Евгений Ермаков | karpov.courses](https://www.youtube.com/watch?v=fNGIOb8SJvU)
|
||||||
|
- Лекция в рамках курса по DWH: именно [«Основы Data Vault, создаем первую модель»](https://www.youtube.com/watch?v=65b99XCuiR4) — хороший формат: теория + пример.
|
||||||
|
- Доклад - практический пример: [Денис Лукьянов — Data Vault 2.0. Когда внедрять, проблемы применения при построении DWH на Greenplum](https://www.youtube.com/watch?v=oGwQbeP5iss)
|
||||||
|
- Хорошее общее введение в модели данных дано в статье и докладе от Yandex: [Как мы внедрили свою модель хранения данных — highly Normalized hybrid Model. Доклад Яндекса](https://habr.com/ru/companies/yandex/articles/557140/)
|
||||||
|
|
||||||
|
**Когда блок «Базы данных» считаем пройденным:**
|
||||||
|
|
||||||
|
- вы уверенно пишете запросы с JOIN, агрегатами, подзапросами и CTE;
|
||||||
|
- можете подробно объяснить план запроса в Postgres, понимаете где планировщик отработал корректно, а где - есть возможность улучшить;
|
||||||
|
- можете объяснить простую модель данных (3NF/звезда) и прочитать схему DWH;
|
||||||
|
- решаете типовые задачи уровня SQL live-coding без долгих пауз.
|
||||||
|
|
||||||
|
|
||||||
|
### Python
|
||||||
|
|
||||||
|
Если совсем не знакомы с Python, начинаем с курса ["Поколение Python": курс для начинающих - Stepik](https://stepik.org/course/58852/info).
|
||||||
|
|
||||||
|
Перед курсом для продвинутых подготовьте рабочую среду. Она пригодится для
|
||||||
|
экспериментов с кодом и домашних заданий.
|
||||||
|
|
||||||
|
- Виртуальные окружения (venv) изолируют зависимости проекта:
|
||||||
|
- [Habr: как настроить виртуальное окружение](https://habr.com/ru/articles/889670/)
|
||||||
|
- [SkillFactory: Виртуальные окружения в Python](https://blog.skillfactory.ru/venv-virtualnoe-okruzhenie-v-python/)
|
||||||
|
- [Создание venv и установка пакетов: документация Python](https://docs.python.org/3/tutorial/venv.html)
|
||||||
|
- JupyterLab позволяет выполнять код по ячейкам и сохранять пояснения рядом с результатами:
|
||||||
|
- [Блог Практикума: что такое Jupyter Notebook](https://practicum.yandex.ru/blog/chto-takoe-jupyter-notebook/)
|
||||||
|
- [Установка JupyterLab](https://jupyterlab.readthedocs.io/en/stable/getting_started/installation.html)
|
||||||
|
|
||||||
|
Создайте venv, установите в него JupyterLab и запустите из этого же окружения.
|
||||||
|
В новом ноутбуке выполните несколько ячеек, сохраните `.ipynb`, перезапустите
|
||||||
|
ядро и выполните все ячейки по порядку. В Git сохраняйте ноутбуки и скрипты,
|
||||||
|
а каталог `.venv/` добавьте в `.gitignore`.
|
||||||
|
|
||||||
|
Теперь продолжаем изучать Python:
|
||||||
|
|
||||||
|
- Изучаем глубже и "оттачиваем" live coding: ["Поколение Python": курс для продвинутых – Stepik](https://stepik.org/course/68343/info)
|
||||||
|
- Продолжение "базы", спрашиваемой на собеседованиях, по Python: ["Поколение Python": курс для профессионалов](https://stepik.org/course/82541/promo). Курс очень полезный, но платный. Вместо него можно почитать "продвинутые" темы дальше.
|
||||||
|
- "Продвинутые" темы:
|
||||||
|
- [Полезные функции](https://pyneng.readthedocs.io/ru/latest/book/10_useful_functions/index.html)
|
||||||
|
- [Работа с файлами в формате CSV, JSON, YAML](https://pyneng.readthedocs.io/ru/latest/book/17_serialization/index.html)
|
||||||
|
- [Итераторы, итерируемые объекты и генераторы](https://pyneng.readthedocs.io/ru/latest/book/13_iterator_generator/index.html)
|
||||||
|
- Управление памятью (документация на английском):
|
||||||
|
- [Объекты, ссылки и изменяемость](https://docs.python.org/3/reference/datamodel.html#objects-values-and-types) - почему два имени могут указывать на один список и что происходит при его изменении.
|
||||||
|
- [Как Python управляет памятью](https://docs.python.org/3/faq/design.html#how-does-python-manage-memory) - подсчет ссылок и сборка циклов в CPython; особенности других реализаций Python.
|
||||||
|
- [Модуль gc](https://docs.python.org/3/library/gc.html) - справочник для экспериментов со сборщиком мусора. Детали поколений и порогов смотрите для своей версии Python.
|
||||||
|
- [Декораторы Python: пошаговое руководство](https://habr.com/ru/companies/otus/articles/727590/)
|
||||||
|
- Работа с датой/временем: [официальная документация по модулю datetime](https://docs.python.org/3/library/datetime.html)
|
||||||
|
- [Сложность алгоритмов. Разбор Big O](https://habr.com/ru/articles/782608/) — короткий материал, чтобы понимать O(n) vs O(n²) на собеседованиях и в коде
|
||||||
|
- ООП
|
||||||
|
- [Tproger: «ООП простыми словами»](https://tproger.ru/experts/oop-in-simple-words)
|
||||||
|
- Введение в [ООП](https://metanit.com/python/tutorial/7.1.php)
|
||||||
|
- [Яндекс Учебник: «Объектная модель Python: классы, поля и методы»](https://education.yandex.ru/handbook/python/article/obuektnaya-model-python-klassy-polya-i-metody)
|
||||||
|
- [Real Python: OOP in Python (tutorial)](https://realpython.com/python3-object-oriented-programming/)
|
||||||
- Pandas
|
- Pandas
|
||||||
- Jupyter Lab - кратко
|
- [GeeksforGeeks: “Why Pandas is Used in Python”](https://www.geeksforgeeks.org/pandas/why-pandas-is-used-in-python/)
|
||||||
|
- [Skillbox: «Для чего нужна библиотека Pandas»](https://skillbox.ru/media/code/rabotaem-s-pandas-osnovnye-ponyatiya-i-realnye-dannye/)
|
||||||
|
- [Official: “10 minutes to pandas”](https://pandas.pydata.org/docs/user_guide/10min.html)
|
||||||
|
- [Хабр (RUVDS): «Моя шпаргалка по pandas»](https://habr.com/ru/companies/ruvds/articles/494720/)
|
||||||
|
- [Tproger: «Наглядная шпаргалка по операциям с DataFrame»](https://tproger.ru/articles/pandas-data-wrangling-cheatsheet)
|
||||||
|
|
||||||
Полезно, но дороговато и не обязательно: хорошее комбо SQL + Python: ["Поколение Python": профи + ООП + SQL – Stepik](https://stepik.org/course/233341/promo?search=7181036958)
|
Полезно, но дороговато и не обязательно: хорошее комбо SQL + Python — ["Поколение Python": профи + ООП + SQL – Stepik](https://stepik.org/course/233341/promo?search=7181036958)
|
||||||
|
|
||||||
Цель - LiveCoding простых задач Python, далее нужно будет для создания DAG Airflow
|
Цель — уверенно решать простые задачи на Python в формате live-coding; дальше эти навыки пригодятся для создания DAG Airflow.
|
||||||
|
|
||||||
## Технические навыки
|
**Когда блок Python считаем пройденным:**
|
||||||
### Запись встреч
|
|
||||||
OBS Studio
|
- создаете venv, устанавливаете в него пакеты и запускаете JupyterLab; можете повторить вычисления в ноутбуке после перезапуска ядра;
|
||||||
[Настройка записи экрана](https://docs.google.com/document/d/1qd8uRYlAaZp9c5zpvCVBOvYQCEukGHI9PEPjnjahI1k/)
|
- вы без подсказок пишете небольшие скрипты с циклами, функциями, обработкой ошибок и работой с коллекциями;
|
||||||
|
- умеете читать и модифицировать чужой код, в том числе с использованием pandas и DataFrame;
|
||||||
|
- уверенно проходите простой live-coding по Python для DE: прочитать CSV/JSON, отфильтровать, сгруппировать данные и посчитать агрегаты.
|
||||||
|
- можете отвечать как на простые вопросы собеседований (циклы, списки, словари), так и продвинутые (итераторы, декораторы, базовые понятия ООП);
|
||||||
|
- на примере со списком объясняете разницу между объектом и ссылкой на него,
|
||||||
|
почему `del` одного имени не обязательно уничтожает объект и зачем CPython
|
||||||
|
нужен сборщик циклического мусора.
|
||||||
|
|
||||||
|
### Методологии разработки
|
||||||
|
|
||||||
|
> **Зачем это разработчику?**
|
||||||
|
> 1. **Работа в команде.** Вам нужно понимать «правила игры». Почему задачи двигаются именно так? Зачем мы встречаемся каждое утро на 15 минут? Почему нельзя просто взять задачу из середины списка?
|
||||||
|
> 2. **Собеседование и «легенда».** Когда вас спросят: «Как строилась работа в вашей прошлой команде?», вы должны ответить грамотно. Использование правильной терминологии (спринты, груминг, ретроспектива, WIP-лимиты) — это маркер профессионализма. Это показывает, что вы не просто писали код в вакууме, а были частью налаженного процесса.
|
||||||
|
|
||||||
|
#### 1. Основы и сравнение подходов
|
||||||
|
Для начала нужно понять глобальную разницу между жестким планированием (Waterfall) и гибкой разработкой (Agile).
|
||||||
|
|
||||||
|
- [Waterfall или Agile, Scrum или Kanban: что выбрать](https://habr.com/ru/companies/kaiten/articles/906006/) — *Базовая статья. Читать внимательно, закрывает вопросы и по Waterfall, и по выбору пути.*
|
||||||
|
- [Agile, Scrum, Kanban - обзор, отличия, мифы](https://www.youtube.com/watch?v=LhA5aWjHTJw) — *Видео для закрепления. Помогает разложить кашу в голове по полочкам.*
|
||||||
|
|
||||||
|
#### 2. Agile: Философия гибкости
|
||||||
|
Agile — это не метод, а философия. Scrum и Kanban — это инструменты этой философии.
|
||||||
|
|
||||||
|
- [Scrum vs Kanban: отличия и разница Agile методов](https://kaiten.ru/blog/kanban-vs-scrum/) — *Сравнение двух главных фреймворков. Важно понять, где заканчивается один и начинается другой.*
|
||||||
|
|
||||||
|
#### 3. Scrum (Скрам)
|
||||||
|
Используется, когда мы создаем продукт и работаем спринтами (циклами).
|
||||||
|
*Часто встречается в продуктовых командах, где DE работает в связке с Backend/Frontend.*
|
||||||
|
|
||||||
|
- [Методология Scrum: принципы, ценности, этапы](https://kaiten.ru/blog/chto-takoie-scrum-i-kak-ispolzovat/)
|
||||||
|
|
||||||
|
#### 4. Kanban (Канбан)
|
||||||
|
Используется для управления потоком задач и поддержки.
|
||||||
|
*Наиболее популярен в Data Engineering и DevOps, так как данные поступают непрерывно, и их сложно «запереть» в двухнедельный спринт.*
|
||||||
|
|
||||||
|
- [Канбан: метод, инструменты и принципы](https://kaiten.ru/blog/cto-takoe-kanban/)
|
||||||
|
|
||||||
|
#### Практика: Как это выглядит в жизни
|
||||||
|
|
||||||
|
Теория — это хорошо, но на работе вы увидите конкретный интерфейс (Jira или Yandex Tracker). Важно понимать, куда нажимать и как двигать задачи.
|
||||||
|
|
||||||
|
##### 1. Jira (Мировой стандарт)
|
||||||
|
Самый популярный инструмент. Скорее всего, вы столкнетесь именно с ним.
|
||||||
|
|
||||||
|
* [Как работать с Jira на реальных проектах](https://www.youtube.com/watch?v=oPgm-fsHVfM) (15 мин) — *Отличное видео, где показывают базу: как создать задачу, как перетащить её по доске (Kanban) и что писать в комментариях. Смотреть с 04:00, где начинается практика.*
|
||||||
|
* [Создание и настройка Scrum-досок в JIRA](https://www.youtube.com/watch?v=u-u8NRyApUs) — *Если хотите увидеть, как выглядит Спринт и Бэклог изнутри.*
|
||||||
|
|
||||||
|
##### 2. Yandex Tracker (Российский стандарт)
|
||||||
|
Активно внедряется в крупных компаниях РФ. Логика та же, но интерфейс другой.
|
||||||
|
|
||||||
|
* [Начало работы в Яндекс.Трекере](https://www.youtube.com/watch?v=pdlYiijjn70) (3 мин) — *Супер-короткий официальный гайд. За 3 минуты показывают всё: очереди, доски, карточки.*
|
||||||
|
* [Настройка процесса разработки в Tracker](https://www.youtube.com/watch?v=EdKlYJR2ph0&t=397s) (c 06:37) — *Более глубокий разбор: как выглядит очередь задач разработчика и жизненный цикл тикета.*
|
||||||
|
|
||||||
|
> **💡 Совет:**
|
||||||
|
> Не бойтесь кнопок. Главное правило любого трекера: **«Взял задачу в работу — переведи статус в In Progress»**. Это сигнал команде, что вы заняты и вас лучше не отвлекать.
|
||||||
|
|
||||||
|
**Когда блок «Методологии разработки» считаем пройденным:**
|
||||||
|
|
||||||
|
- вы в общих чертах можете объяснить разницу Waterfall vs Agile и Scrum vs Kanban;
|
||||||
|
- знаете основные мероприятия Scrum (planning / daily / review / retro) и что от вас ожидается на каждом;
|
||||||
|
- умеете работать с трекером (Jira/Tracker): создать/уточнить задачу, взять в работу, корректно двигать статусы и оставлять понятные комментарии;
|
||||||
|
- умеете своевременно сообщать о блокерах и уточнять требования, если задача «не бьётся» или в ней не хватает входных данных.
|
||||||
|
|
||||||
|
### Технические навыки
|
||||||
|
|
||||||
|
#### Продвинутый Git
|
||||||
|
|
||||||
|
- Сжатый, но емкий видеогайд: [GIT, GitHub, GitLab. Полный АКТУАЛЬНЫЙ гайд ЗА ПОЛТОРА ЧАСА. Без этого выгонят с работы - Youtube](https://www.youtube.com/watch?v=0Y-fneoUIO8)
|
||||||
|
- Книга: [Pro Git](https://git-scm.com/book/ru/v2) - главы
|
||||||
|
- 2 Основы Git
|
||||||
|
- 3 Ветвление в Git
|
||||||
|
- 5 Распределённый Git
|
||||||
|
- 6 GitHub
|
||||||
|
- [Курс работы с Git и GitLab - ЭФКО ЦПР | YouTube плейлист](https://www.youtube.com/playlist?list=PLbf8m52BvqlFlblJqQKPuEU26pwgqe7zK). Настоятельно рекомендую проделать за лектором все те действия что он показывает.
|
||||||
|
|
||||||
### Git
|
|
||||||
Книга: [Pro Git](https://git-scm.com/book/ru/v2) - указать главы для чтения
|
|
||||||
[Курс работы с Git и GitLab - ЭФКО ЦПР | YouTube плейлист](https://www.youtube.com/playlist?list=PLbf8m52BvqlFlblJqQKPuEU26pwgqe7zK) - указать номера лекций для просмотра и повторения за лектором.
|
|
||||||
Целевой уровень знания - понимание процесса GitFlow. Как создать ветку, влить изменения в другие ветки. Понимание, зачем.
|
Целевой уровень знания - понимание процесса GitFlow. Как создать ветку, влить изменения в другие ветки. Понимание, зачем.
|
||||||
На собесах обычно не спрашивают, но нужно в работе.
|
На собесах обычно не спрашивают, но нужно в работе.
|
||||||
|
|
||||||
### Docker
|
#### Docker
|
||||||
- Postgres (развертывание, допиливание, запекание в него учебной БД, выгрузка на docker hub)
|
|
||||||
- Основа для будущих домашних работ - сборка стендов. Хранение в Git и проверка ментором.
|
|
||||||
|
|
||||||
### Методы разработки (водопад, scrum, kanban)
|
- Курс [Docker от karpov.courses](https://karpov.courses/docker)
|
||||||
Найти краткие обзоры методов разработки. Потом проговорить на занятии, когда что используется
|
|
||||||
|
|
||||||
## Airflow
|
Основное предназначение для нас - учебные стенды, где мы разбираем и тренируемся с разными технологиями. На работе - иногда пригождается. На собесах спрашивают редко.
|
||||||
Один из основных инструментов.
|
|
||||||
Упрощенный docker compose: https://github.com/LexxaRRioo/rzv_de_shared_folder/tree/main/docker_compose
|
|
||||||
Найти объяснение для менти...
|
|
||||||
|
|
||||||
## Курсовая работа
|
#### Запись встреч
|
||||||
### Стенд в Docker Compose
|
OBS Studio
|
||||||
- Apache Airflow
|
|
||||||
- Источник данных - TelecomX
|
- Руководство по OBS: [OBS Studio - Настройка ОБС для Записи Игр и Стрима | Настройка Микрофона в Обс и т.д - Youtube](https://www.youtube.com/watch?v=bj8VEphZ65U)
|
||||||
- Postgres
|
- [Как записывать собеседования](https://docs.google.com/document/d/1qd8uRYlAaZp9c5zpvCVBOvYQCEukGHI9PEPjnjahI1k/)
|
||||||
- ETL
|
|
||||||
- Исходные коды всего - в Git
|
**Когда блок технических навыков считаем пройденным:**
|
||||||
|
|
||||||
|
- вы понимаете базовый GitFlow: как организована работа с ветками в команде и как ваши коммиты попадают в прод;
|
||||||
|
- используете Docker для учебных стендов: запускаете контейнеры, смотрите логи и при необходимости перезапускаете сервисы;
|
||||||
|
- при необходимости умеете настроить запись экрана/созвонов, чтобы сохранять материалы обучения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Практика и инструменты
|
||||||
|
|
||||||
|
[[к оглавлению]](#оглавление)
|
||||||
|
|
||||||
|
### Airflow
|
||||||
|
Apache Airflow — инструмент для оркестрации ETL-процессов.
|
||||||
|
|
||||||
|
Мы используем его для:
|
||||||
|
|
||||||
|
- планирования задач,
|
||||||
|
- отслеживания зависимостей между шагами,
|
||||||
|
- визуализации статуса выполнения.
|
||||||
|
|
||||||
|
Материалы:
|
||||||
|
|
||||||
|
- [Учебник по Airflow](https://github.com/dementev-dev/airflow-manual)
|
||||||
|
- [Практические задания на стенде](https://github.com/dementev-dev/airflow-manual/blob/main/educational-tasks.md)
|
||||||
|
|
||||||
|
Задания выполняем параллельно с чтением учебника. Начинаем с простых DAG
|
||||||
|
и SQL, затем обрабатываем файлы, загружаем CSV в PostgreSQL и добавляем
|
||||||
|
проверки качества. Дальше расширяем ETL, разбираем ветвление, повторные
|
||||||
|
попытки и логирование. Здесь Python и SQL впервые работают вместе
|
||||||
|
в пайплайне, которым управляет Airflow.
|
||||||
|
|
||||||
|
**Когда блок Airflow считаем пройденным:**
|
||||||
|
|
||||||
|
- вы можете объяснить, что такое DAG, задачи, операторы и сенсоры, и как между ними задаются зависимости;
|
||||||
|
- на базе учебного стенда подготавливаете, отлаживаете и запускаете свои DAG'и с расписанием и несколькими шагами (например, загрузка данных и последующие трансформации);
|
||||||
|
- расширяете загрузку CSV в PostgreSQL: меняете генерацию данных, схему таблицы и загрузку новой колонки;
|
||||||
|
- добавляете свою проверку качества и убеждаетесь, что она обнаруживает подготовленную ошибку в данных;
|
||||||
|
- уверенно смотрите логи, находите место падения и понимаете, как перезапустить задачу.
|
||||||
|
|
||||||
|
### Greenplum
|
||||||
|
Разбираем, чем Greenplum отличается от PostgreSQL и зачем нужны MPP-хранилища.
|
||||||
|
|
||||||
|
#### Фундаментальная теория
|
||||||
|
Прежде чем нажимать кнопки, нужно понять "физику" больших данных. Почему обычный Postgres начинает тормозить?
|
||||||
|
|
||||||
|
- Мартин Клеппман, "Высоконагруженные приложения":
|
||||||
|
- Глава 1. Надежность, масштабируемость. (Разбираемся, чем вертикальное масштабирование отличается от горизонтального).
|
||||||
|
- Глава 3 (только конец главы). Читаем разделы:
|
||||||
|
- «Обработка транзакций или аналитика?» (OLTP or OLAP?) — ключевое различие нагрузок.
|
||||||
|
- «Хранение по столбцам» — почему аналитика требует другого способа записи данных на диск.
|
||||||
|
- Зачем: Это объясняет, почему Greenplum устроен именно так. Без этого вы будете пытаться работать с ним как с обычным Postgres.
|
||||||
|
|
||||||
|
#### Знакомство с Greenplum
|
||||||
|
Теперь, понимая теорию, смотрим, как это реализовано в конкретном инструменте.
|
||||||
|
|
||||||
|
- Простое введение: [Greenplum | Что это такое и как оно работает? - Youtube](https://www.youtube.com/watch?v=rLG9Z_HcKPY)
|
||||||
|
- [Визуализатор распределения Greenplum](https://gpskew.rzvde.pro/)
|
||||||
|
|
||||||
|
**Курс Yandex по Greenplum** — основной учебный курс, рекомендуется пройти целиком:
|
||||||
|
|
||||||
|
- [Бесплатный курс Yandex Cloud по Greenplum](https://yandex.cloud/ru/training/greenplum)
|
||||||
|
- Практику по курсу удобно делать на стенде [airflow-dwh-gp-lab](https://github.com/dementev-dev/airflow-greenplum) — `make up` поднимает рабочий Greenplum с PXF, не нужен облачный кластер.
|
||||||
|
- Стенд покрывает основные темы курса: типы таблиц (heap / appendonly), политики дистрибуции, сжатие, PXF, анализ планов выполнения (`EXPLAIN`).
|
||||||
|
- Единственное ограничение: cloud-специфичные темы (тема 2 курса — развёртывание в Yandex Cloud) на локальном стенде не покрыты.
|
||||||
|
|
||||||
|
**Когда блок Greenplum считаем пройденным:**
|
||||||
|
|
||||||
|
- вы понимаете, как данные распределяются по сегментам, что такое skew и как его увидеть;
|
||||||
|
- на базе стенда `airflow-dwh-gp-lab` можете загружать и выгружать данные в Greenplum, выполнять запросы и разбирать планы выполнения (`EXPLAIN`);
|
||||||
|
- можете объяснить, в чём практическая разница между MPP-хранилищем и одиночным Postgres на уровне типичных задач DE и собеседований.
|
||||||
|
|
||||||
|
### Курсовая работа
|
||||||
|
Первая курсовая собирает изученное в один проект: загрузку данных из PostgreSQL,
|
||||||
|
слои хранилища в Greenplum и управление обработкой через Airflow.
|
||||||
|
|
||||||
|
Курсовая выполняется на том же стенде [airflow-dwh-gp-lab](https://github.com/dementev-dev/airflow-greenplum), который вы уже использовали для практики по Greenplum.
|
||||||
|
|
||||||
|
**Что внутри:**
|
||||||
|
|
||||||
|
- Стенд содержит DWH с реализованным эталонным срезом (STG → ODS → DDS → DM) — это ваш образец для подражания.
|
||||||
|
- Задача — довести DWH до полного, реализовав недостающие загрузки по аналогии с эталоном.
|
||||||
|
- Есть готовый план от аналитика (ТЗ с маппингами и бизнес-правилами) — не нужно придумывать, что делать.
|
||||||
|
- Встроенная автоматическая проверка реализации поможет убедиться в корректности до проверки ментором.
|
||||||
|
- Ветка `main` — рабочая (с заготовками для реализации), ветка `solution` — эталон для сверки.
|
||||||
|
|
||||||
|
**Когда блок курсовой работы считаем пройденным:**
|
||||||
|
|
||||||
|
- все загрузки реализованы, DWH заполняется полностью (STG → ODS → DDS → DM);
|
||||||
|
- автоматическая проверка (валидационный DAG) проходит без ошибок;
|
||||||
|
- вы можете на собеседовании за 5–10 минут рассказать архитектуру проекта, его цели и показать ключевые части кода.
|
||||||
|
|
||||||
|
После этой работы переходим к потокам событий и ClickHouse. Они понадобятся
|
||||||
|
для [большой курсовой Clickstream](#большая-курсовая-clickstream).
|
||||||
|
|
||||||
|
### Streaming (NiFi + Kafka)
|
||||||
|
В первой курсовой мы загружали данные из базы. Теперь разберем прием потока
|
||||||
|
событий: как доставить их в хранилище и продолжить чтение после остановки
|
||||||
|
обработчика. NiFi помогает собрать поток обработки визуально, Kafka хранит
|
||||||
|
события в распределенном журнале и позволяет потребителям читать их независимо.
|
||||||
|
|
||||||
|
Материалы:
|
||||||
|
|
||||||
|
- [Apache NiFi с нуля за 3 часа (Youtube-плейлист)](https://youtube.com/playlist?list=PL4MpKy3QjNp_rOEEibc4Ro8UK4g8vLX6_&si=W_hidjHmBOZ_aUfS) — первые 4 видео, дальше — по желанию
|
||||||
|
- [Лучший Гайд по Kafka для Начинающих За 1 Час (Youtube)](https://www.youtube.com/watch?v=hbseyn-CfXY)
|
||||||
|
|
||||||
|
Практика — на стенде [nifi-kafka-postgres-lab](https://github.com/dementev-dev/nifi-kafka-postgres-lab) (Docker Compose с NiFi, Kafka и Postgres):
|
||||||
|
|
||||||
|
- настраиваем в NiFi простой генератор данных и поток в Postgres;
|
||||||
|
- строим поток NiFi → Kafka → NiFi → Postgres.
|
||||||
|
|
||||||
|
**Перед большой курсовой нужно уметь:**
|
||||||
|
|
||||||
|
- объяснить связь между топиком, партицией, consumer group и offset;
|
||||||
|
- отправить сообщения в Kafka и прочитать их потребителем;
|
||||||
|
- показать на учебном потоке, откуда продолжится чтение после перезапуска,
|
||||||
|
и объяснить, почему при повторной доставке могут появиться дубли.
|
||||||
|
|
||||||
|
В [курсовой Clickstream](#большая-курсовая-clickstream) события и заказы
|
||||||
|
поступают через Kafka в ClickHouse. Вернемся к этим вопросам, когда будем
|
||||||
|
разбирать прием данных и их обработку в хранилище.
|
||||||
|
|
||||||
|
### ClickHouse
|
||||||
|
ClickHouse используем для аналитики по событиям и заказам. Здесь нужно
|
||||||
|
разобраться, как хранить данные, выбирать ключ сортировки и строить витрины.
|
||||||
|
Затем применим это в большой курсовой.
|
||||||
|
|
||||||
|
Материалы:
|
||||||
|
|
||||||
|
- Бесплатный курс [ClickHouse от Yandex Cloud](https://yandex.cloud/ru/training/clickhouse) берем за основу.
|
||||||
|
|
||||||
|
Практика:
|
||||||
|
|
||||||
|
Упражнения по самой СУБД можно выполнять локально на
|
||||||
|
[clickhouse-learning-cluster](https://github.com/dementev-dev/clickhouse-learning-cluster):
|
||||||
|
четыре узла ClickHouse в Docker Compose, репликация, шардинг и HAProxy.
|
||||||
|
Задания по облачной инфраструктуре требуют Yandex Cloud и оплаты ресурсов.
|
||||||
|
|
||||||
|
**Когда готовы переходить к курсовой:**
|
||||||
|
|
||||||
|
- создаете таблицы семейства MergeTree и объясняете выбор `ORDER BY` и `PARTITION BY`;
|
||||||
|
- различаете шардирование и репликацию, понимаете роль таблиц Distributed;
|
||||||
|
- загружаете данные, пишете аналитические запросы и можете объяснить,
|
||||||
|
как устройство таблицы влияет на чтение данных.
|
||||||
|
|
||||||
|
### Большая курсовая: Clickstream
|
||||||
|
|
||||||
|
На стенде [clickstream-data-platform](https://github.com/dementev-dev/clickstream-data-platform)
|
||||||
|
работаем с хранилищем интернет-магазина. Генератор создает события посетителей
|
||||||
|
и заказы, Kafka доставляет их в ClickHouse. Данные проходят слои STG, ODS,
|
||||||
|
DDS и DM; Airflow управляет обработкой, Superset показывает отчеты.
|
||||||
|
|
||||||
|
Часть хранилища уже работает. В заданиях вы строите свои витрины, разбираете
|
||||||
|
готовые загрузки и меняете их, сверяя результат с исходными данными.
|
||||||
|
Проверки качества и мониторинг нужны здесь для конкретных задач: заметить
|
||||||
|
потери и дубли, найти причину расхождения, проверить результат своей правки.
|
||||||
|
|
||||||
|
Начните с запуска по README репозитория, затем прочитайте
|
||||||
|
[путеводитель по стенду](https://github.com/dementev-dev/clickstream-data-platform/blob/main/docs/handbook/README.md)
|
||||||
|
и переходите к [учебным заданиям](https://github.com/dementev-dev/clickstream-data-platform/blob/main/docs/labs/README.md).
|
||||||
|
В основной программе семь ступеней: пять витрин DM, свой график в Superset,
|
||||||
|
самостоятельная нарезка сессий, изменение расчета сессий в конвейере,
|
||||||
|
проверки и обработка заказов, разбор сырых событий, мониторинг.
|
||||||
|
Факультативные расследования можно взять после основной программы.
|
||||||
|
|
||||||
|
**Когда большую курсовую считаем пройденной:**
|
||||||
|
|
||||||
|
- выполнены семь ступеней основной программы и проверки из заданий;
|
||||||
|
- пять учебных витрин проходят `homework_check`, в Superset построен свой график;
|
||||||
|
- можете проследить путь события от Kafka до отчета, объяснить свои изменения
|
||||||
|
и показать, как проверяли их корректность.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Карьера и менторство
|
||||||
|
|
||||||
|
[[к оглавлению]](#оглавление)
|
||||||
|
|
||||||
|
### Менторство по этому роадмапу
|
||||||
|
|
||||||
|
Если вы нашли этот роадмап в интернете и хотите пройти его не в одиночку, а с поддержкой ментора, можно присоединиться ко мне.
|
||||||
|
|
||||||
|
**Что даёт менторство:**
|
||||||
|
|
||||||
|
- структурный план прохождения роадмапа под вашу ситуацию;
|
||||||
|
- разбор вопросов по SQL / DWH / Airflow и другим темам из этого документа;
|
||||||
|
- разбор домашних заданий и код-ревью;
|
||||||
|
- помощь с подготовкой к собеседованиям (резюме, мок-интервью).
|
||||||
|
|
||||||
|
**Как записаться**
|
||||||
|
|
||||||
|
Просто напишите мне в Telegram: [@dementev_dev](https://t.me/dementev_dev)
|
||||||
|
со словами «Хочу пройти роадмап с ментором» — дальше всё обсудим.
|
||||||
|
|
||||||
|
### Подготовка к собеседованиям
|
||||||
|
Цель блока — сформировать «опыт от 2 лет» и уметь корректно его показать в резюме и на собеседовании.
|
||||||
|
|
||||||
|
#### Помощь в подготовке резюме
|
||||||
|
|
||||||
## Понятие сложности алгоритмов
|
|
||||||
### SQL
|
|
||||||
### Python
|
|
||||||
## Подготовка к собеседованиям
|
|
||||||
Думаем, как "сделать" опыт, от 2 лет
|
|
||||||
### Помощь в подготовке резюме
|
|
||||||
- Видео от ОМ по составлению резюме
|
- Видео от ОМ по составлению резюме
|
||||||
- [Как накрутить опыт в резюме | «Ультимативный гайд» @digital_ninja](https://www.youtube.com/watch?v=EPuogJuYsvY)
|
- [Как накрутить опыт в резюме | «Ультимативный гайд» @digital_ninja](https://www.youtube.com/watch?v=EPuogJuYsvY)
|
||||||
- [Как писать резюме, чтобы его читали - доклад - Boosty](https://boosty.to/m0rtymerr/posts/71b02a6b-8116-466a-b945-b2ed793abd8f)
|
- [Как писать резюме, чтобы его читали - доклад - Boosty](https://boosty.to/m0rtymerr/posts/71b02a6b-8116-466a-b945-b2ed793abd8f)
|
||||||
- [Как грамотно продать себя на собеседовании / Созвон сообщества - Boosty](https://boosty.to/m0rtymerr/posts/7289cd23-60c6-4010-bb1c-a5b28dac399a)
|
- [Как грамотно продать себя на собеседовании / Созвон сообщества - Boosty](https://boosty.to/m0rtymerr/posts/7289cd23-60c6-4010-bb1c-a5b28dac399a)
|
||||||
- Попытки менти написать, моя обратная связь - итеративно
|
- Практика: совместная работа над резюме — ментор помогает переработать опыт, сформировать убедительную карьерную историю и подготовиться к вопросам по ней.
|
||||||
### Помощь с прохождением испытательного срока
|
|
||||||
|
#### Поиск работы и собеседования
|
||||||
|
|
||||||
|
- [Как подтвердить опыт без трудовой / Хабр против работяг](https://www.youtube.com/watch?v=GHqABzA1zi8)
|
||||||
|
- Практика: мок-собеседования с ментором — тренировка ответов, разбор слабых мест, психологическая подготовка к реальным интервью.
|
||||||
|
|
||||||
|
#### Помощь с прохождением испытательного срока
|
||||||
|
|
||||||
- [Как успешно пройти испытательный срок в IT | «Ультимативный гайд» c @digital_ninja - Youtube](https://www.youtube.com/watch?v=r1lWP5rYVdk)
|
- [Как успешно пройти испытательный срок в IT | «Ультимативный гайд» c @digital_ninja - Youtube](https://www.youtube.com/watch?v=r1lWP5rYVdk)
|
||||||
- [Испытательный срок - доклад - Boosty](https://boosty.to/m0rtymerr/posts/40e7f17e-022b-495c-8d03-dabbe4383b8e)
|
- [Испытательный срок - доклад - Boosty](https://boosty.to/m0rtymerr/posts/40e7f17e-022b-495c-8d03-dabbe4383b8e)
|
||||||
Видео по прохождению собесов от ОМ. Мои комментарии к нему, мой опыт
|
|
||||||
Первые тренировки мок собесы, обратная связь
|
|
||||||
|
|
||||||
Навыки поиска работы с HH и Habr карьера
|
**Когда блок подготовки к собеседованиям считаем пройденным:**
|
||||||
|
|
||||||
[Как накрутить опыт в резюме | «Ультимативный гайд» @digital_ninja](https://www.youtube.com/watch?v=EPuogJuYsvY)
|
- у вас есть актуальное резюме под DE с понятными примерами проектов вместо «пустого» опыта;
|
||||||
[Как подтвердить опыт без трудовой / Хабр против работяг](https://www.youtube.com/watch?v=GHqABzA1zi8)
|
- вы умеете искать и отбирать вакансии на HH и Habr Карьера, адаптируя отклики под конкретную позицию;
|
||||||
[Как успешно пройти испытательный срок в IT | «Ультимативный гайд» c @digital_ninja - Youtube](https://www.youtube.com/watch?v=r1lWP5rYVdk)
|
- вы прошли хотя бы пару мок-собеседований, получили обратную связь и по результатам доработали резюме и стратегию поиска.
|
||||||
|
|
||||||
# Расширенные навыки
|
---
|
||||||
Поясняю, что главное - уметь пользоваться и отвечать на вопросы собесов. Уметь самому разворачивать сложные конфигурации - излишне, для этого в компаниях обычно есть DevOps и DBA. Достаточно прочувствовать на простом docker стенде.
|
|
||||||
## Greenplum
|
|
||||||
Дать теоретический материал - разница с Postgres.
|
|
||||||
Предварительно: [Учебный курс по Greenplum](https://datafinder.ru/products/uchebnyy-kurs-po-greenplum) - дать только отдельные главы
|
|
||||||
Контейнер с GreenPlum, несколько домашек по нему, чтобы прочувствовать работу распределенных запросов.
|
|
||||||
- [sergeyosechkin/greenplum Tags | Docker Hub](https://hub.docker.com/r/sergeyosechkin/greenplum/tags)
|
|
||||||
- [Как собрать Docker-образ Greengage DB | Greengage DB Docs](https://greengagedb.org/ru/docs-gg/current/use_docker.html)
|
|
||||||
В сложности с виртуалками - только если менти сильно захочет. Не буду рекомендовать.
|
|
||||||
|
|
||||||
## ClickHouse
|
## Расширенные навыки
|
||||||
Бесплатный курс https://yandex.cloud/ru/training/clickhouse
|
После двух курсовых можно расширить стек: попробовать обработку файлов
|
||||||
Платный курс [ClickHouse для аналитика – Stepik](https://stepik.org/course/100210/promo?search=6551441002)
|
в Lakehouse и управление SQL-моделями через dbt. Выбирайте тему под задачи,
|
||||||
|
с которыми хотите работать.
|
||||||
|
|
||||||
## NiFi
|
[[к оглавлению]](#оглавление)
|
||||||
Плейлист [Apache NiFi с нуля за 3 часа. Конструктор вместо кода - Youtube](https://youtube.com/playlist?list=PL4MpKy3QjNp_rOEEibc4Ro8UK4g8vLX6_&si=W_hidjHmBOZ_aUfS) - первые 4 видео. Дальше - по желанию.
|
|
||||||
Делаем отдельный docker compose Postgres + Nifi
|
|
||||||
В NiFi собираем генератор данных
|
|
||||||
|
|
||||||
## Kafka
|
### Lakehouse (Spark, Iceberg, Trino)
|
||||||
[Лучший Гайд по Kafka для Начинающих За 1 Час - Youtube](https://www.youtube.com/watch?v=hbseyn-CfXY)
|
|
||||||
Добавляем к предыдущему docker compose Kafka.
|
|
||||||
Строим поток данных NiFi->Kafka
|
|
||||||
Kafka->NiFi->Postgres
|
|
||||||
|
|
||||||
## dbt
|
Lakehouse — архитектурный подход, который соединяет гибкость Data Lake с гарантиями классического DWH.
|
||||||
Обзорная лекция, для понимания смысла
|
|
||||||
|
В классическом DWH данные и вычисления живут внутри одной СУБД, в её закрытом формате. В Lakehouse они разделены. Данные лежат файлами в дешёвом хранилище (обычно объектном, вроде S3). Открытый табличный формат (Iceberg, Delta, Hudi) добавляет поверх файлов привычные по СУБД вещи: схемы, транзакции, историю изменений. А вычислительные движки (Spark, Trino, Flink и другие) подключаются к данным снаружи — хоть несколько разных к одним и тем же таблицам.
|
||||||
|
|
||||||
|
Роадмап фокусируется на классическом DWH-стеке, поэтому цель здесь — знакомство, но с настоящей практикой: стенд ниже собирает один из типовых наборов этого конструктора.
|
||||||
|
|
||||||
|
Материалы:
|
||||||
|
|
||||||
|
- Введение в тему: [«Как не утонуть в данных: выбираем между DWH, Data Lake и Lakehouse» (Habr, Arenadata)](https://habr.com/ru/companies/arenadata/articles/885722/) — что такое Lakehouse, чем он отличается от классического DWH и Data Lake и зачем появился
|
||||||
|
- [DataLearn: «Что такое Apache Spark»](https://youtu.be/Tl9YzC-dQLI) — введение в Spark с нуля, ~40 минут
|
||||||
|
|
||||||
|
Практика — курс [«Lakehouse без магии»](https://github.com/dementev-dev/mini-lakehouse-lab) на стенде mini-lakehouse-lab (Spark + Iceberg + Trino + MinIO, всё локально в Docker, без облаков и регистраций):
|
||||||
|
|
||||||
|
- 8 модулей на ~12–15 часов самостоятельной работы; в каждом — объяснение, демонстрация, задание и checkpoint;
|
||||||
|
- пайплайн `raw → bronze → silver` на реальном датасете NYC Taxi;
|
||||||
|
- одна таблица из двух движков: запись через Spark, чтение через Trino — и почему это работает без копирования данных;
|
||||||
|
- schema evolution, time travel и обслуживание таблиц (compaction, expire_snapshots) — с параллелями к знакомым VACUUM/REORGANIZE из мира Postgres/Greenplum.
|
||||||
|
|
||||||
|
Глубже про Iceberg (опционально, лучше после практики на стенде):
|
||||||
|
|
||||||
|
- [«Как на самом деле работает Apache Iceberg» — Владимир Озеров, HighLoad Channel (Youtube)](https://www.youtube.com/watch?v=_3fsE2a2FO4)
|
||||||
|
- [Введение в устройство Parquet и Iceberg (Habr, VK Tech)](https://habr.com/ru/companies/vktech/articles/959398/) — подробный и местами непростой разбор форматов изнутри
|
||||||
|
- [Введение в Apache Iceberg: основы, архитектура, как работает](https://ivan-shamaev.ru/apache-iceberg-tutorial-architecture-how-to-work/#__Apache_Iceberg-2)
|
||||||
|
- [Spark + Iceberg in 1 Hour: Memory Tuning, Joins, Partition (Youtube, англ.)](https://www.youtube.com/watch?v=3R-SLYK-P_0)
|
||||||
|
|
||||||
|
### dbt
|
||||||
|
dbt (data build tool) — инструмент для трансформации данных в хранилище.
|
||||||
|
|
||||||
|
Мы рассматриваем его как альтернативу «ручному» написанию сложных CTE и для понимания современного подхода к моделированию данных как кода.
|
||||||
|
|
||||||
|
Материалы:
|
||||||
|
|
||||||
|
- [dbt quickstart (официальный гайд)](https://docs.getdbt.com/guides/manual-install?step=1)
|
||||||
|
- [Перевод документации dbt на русский](https://docs.getdbt.tech/)
|
||||||
|
|
||||||
|
Практика:
|
||||||
|
|
||||||
|
- Клонировать [jaffle-shop](https://github.com/dbt-labs/jaffle-shop), установить dbt-duckdb, прогнать `dbt build`, `dbt test`, `dbt docs generate && dbt docs serve` — этого достаточно, чтобы увидеть весь цикл.
|
||||||
|
|
||||||
|
**Когда блок расширенных навыков считаем пройденным:**
|
||||||
|
|
||||||
|
Оценивайте выбранную тему по результату практики:
|
||||||
|
|
||||||
|
- для Lakehouse можете записать таблицу через Spark, прочитать ее через Trino
|
||||||
|
и объяснить роль Iceberg, каталога и объектного хранилища;
|
||||||
|
- для dbt можете изменить SQL-модель, выполнить сборку и тесты,
|
||||||
|
посмотреть зависимости в документации проекта.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Софт скиллы
|
||||||
|
|
||||||
|
[[к оглавлению]](#оглавление)
|
||||||
|
|
||||||
# Софт скиллы
|
|
||||||
- [Все ветви дохода в IT / Полный гайд по деньгам](https://youtube.com/live/JHClTWwK1EM)
|
- [Все ветви дохода в IT / Полный гайд по деньгам](https://youtube.com/live/JHClTWwK1EM)
|
||||||
- [Гайд как писать отзывы](https://boosty.to/m0rtymerr/posts/b04040ec-0f46-4524-9c75-188a513140ad?share=post_link)
|
- [Гайд как писать отзывы](https://boosty.to/m0rtymerr/posts/b04040ec-0f46-4524-9c75-188a513140ad?share=post_link)
|
||||||
- [Гайд по Антистрессу](https://youtu.be/bu0YiXOKaoU)
|
- [Гайд по Антистрессу](https://youtu.be/bu0YiXOKaoU)
|
||||||
|
|
||||||
# Тех. материалы несортировано
|
---
|
||||||
|
|
||||||
|
## Дополнительные материалы
|
||||||
|
|
||||||
|
[[к оглавлению]](#оглавление)
|
||||||
|
|
||||||
- [ananevsyu/SandBox_DB_public: Песочница для изучения различных технологий связанных с инженерией данных](https://gitflic.ru/project/ananevsyu/sandbox_db_public)
|
- [ananevsyu/SandBox_DB_public: Песочница для изучения различных технологий связанных с инженерией данных](https://gitflic.ru/project/ananevsyu/sandbox_db_public)
|
||||||
- Клон проекта [dementev_dev/sandbox_db_public-форк](https://gitflic.ru/project/dementev_dev/sandbox_db_public-fork)
|
- Клон проекта [dementev_dev/sandbox_db_public-форк](https://gitflic.ru/project/dementev_dev/sandbox_db_public-fork)
|
||||||
|
- [System Design. Разбор книги "Высоконагруженные приложения". Глава 1 - Youtube](https://www.youtube.com/watch?v=owjrIB_5go8) — отличный видео-конспект первой главы Клеппмана на русском.
|
||||||
- [Индексы в БД - Youtube](https://www.youtube.com/watch?v=DyqtBiDrz3g)
|
- [Индексы в БД - Youtube](https://www.youtube.com/watch?v=DyqtBiDrz3g)
|
||||||
- [Spark + Iceberg in 1 Hour - Memory Tuning, Joins, Partition - Youtube](https://www.youtube.com/watch?v=3R-SLYK-P_0)
|
|
||||||
- [Введение в Apache Iceberg. Основы, архитектура, как работает?](https://ivan-shamaev.ru/apache-iceberg-tutorial-architecture-how-to-work/#__Apache_Iceberg-2)
|
|
||||||
- [Алгоритмы: теория и практика. Методы – Stepik](https://stepik.org/course/217/info)
|
- [Алгоритмы: теория и практика. Методы – Stepik](https://stepik.org/course/217/info)
|
||||||
- [Алгоритмы: теория и практика. Структуры данных – Stepik](https://stepik.org/course/1547/promo)
|
- [Алгоритмы: теория и практика. Структуры данных – Stepik](https://stepik.org/course/1547/promo)
|
||||||
- [Apache Hadoop для самых маленьких: HDFS, RACK-AWARENESS, репликация и Data Locality - Youtube](https://youtu.be/0fsY5bW2l84)
|
- [Apache Hadoop для самых маленьких: HDFS, RACK-AWARENESS, репликация и Data Locality - Youtube](https://youtu.be/0fsY5bW2l84)
|
||||||
- [■ Книга. Введение в Apache Kafka для системных аналитиков и проектировщиков интеграций](https://systems.education/kafka)
|
- [Книга. Введение в Apache Kafka для системных аналитиков и проектировщиков интеграций](https://systems.education/kafka)
|
||||||
-
|
- [Перевод документации dbt на русский язык](https://docs.getdbt.tech/)
|
||||||
|
|
||||||
|
### Записи ОМ
|
||||||
|
|
||||||
## Записи ОМ
|
|
||||||
- [Как пройти собеседование на программиста | Ультимативный гайд с @om_nazarov - Youtube](https://www.youtube.com/watch?v=tzSdiYZ52kI)
|
- [Как пройти собеседование на программиста | Ультимативный гайд с @om_nazarov - Youtube](https://www.youtube.com/watch?v=tzSdiYZ52kI)
|
||||||
- [Как стать программистом в 2025 | «Ультимативный гайд» с @om_nazarov](https://www.youtube.com/watch?v=6151ekTOl38)
|
- [Как стать программистом в 2025 | «Ультимативный гайд» с @om_nazarov](https://www.youtube.com/watch?v=6151ekTOl38)
|
||||||
-
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Разработка с ИИ
|
||||||
|
|
||||||
|
Практические материалы о работе с ИИ-ассистентами для разработчиков: как эффективно взаимодействовать с coding agents, организовать контекст и память, выстроить рабочий процесс.
|
||||||
|
|
||||||
|
Раздел не привязан к основному роадмапу по Data Engineering и может использоваться независимо.
|
||||||
|
|
||||||
|
## Материалы
|
||||||
|
|
||||||
|
- [Лучшие практики работы с coding agents](best-practice.md): 10 принципов эффективной работы с ИИ-ассистентами, от структурирования задач до автоматизации
|
||||||
|
- [Механизм памяти coding agents](memory-mechanism.md): как устроена память агентов, типы памяти, многоуровневая организация и практические рекомендации
|
||||||
|
|
||||||
|
## Источники
|
||||||
|
|
||||||
|
Материалы раздела подготовлены на основе документации [Z.AI DevPack](https://docs.z.ai/devpack/resources/best-practice).
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# Лучшие практики работы с coding agents
|
||||||
|
|
||||||
|
> По мотивам [Best Practice](https://docs.z.ai/devpack/resources/best-practice) (Z.AI DevPack)
|
||||||
|
|
||||||
|
По мере развития фундаментальных моделей ИИ-инструменты для разработки эволюционируют от простых ассистентов автодополнения кода в **coding agents**, способных участвовать в полном цикле разработки ПО. В отличие от традиционных copilot-инструментов, coding agents умеют читать и навигировать по кодовой базе, модифицировать файлы, выполнять команды, вызывать внешние инструменты и решать сложные задачи через многошаговое взаимодействие.
|
||||||
|
|
||||||
|
С этим сдвигом разработчикам нужно больше, чем техники написания промптов. Нужен надёжный подход к работе с coding agents на практике. Среди ведущих инструментов формируется общий паттерн использования: предоставить чёткий контекст задачи, спланировать шаги выполнения, зафиксировать проектные правила, подключить внешние инструменты и системы, автоматизировать повторяющиеся процессы.
|
||||||
|
|
||||||
|
Опираясь на официальные рекомендации этих инструментов, статья описывает **общий фреймворк лучших практик для coding agents**.
|
||||||
|
|
||||||
|
## 1. Относитесь к агенту как к коллеге, а не к одноразовому инструменту
|
||||||
|
|
||||||
|
Типичная ошибка при работе с coding agent — использовать его как одноразовый вопрос-ответ:
|
||||||
|
|
||||||
|
> Задать вопрос, получить код, завершить взаимодействие.
|
||||||
|
|
||||||
|
На практике этот подход не раскрывает возможности агента.
|
||||||
|
|
||||||
|
Coding agent лучше воспринимать как настраиваемого коллегу, которого можно совершенствовать со временем. Через конфигурационные файлы проекта, интеграции с инструментами и переиспользуемые навыки (skills) разработчик может постоянно формировать поведение агента так, чтобы оно соответствовало рабочему процессу команды.
|
||||||
|
|
||||||
|
!!! tip "Ключевая мысль"
|
||||||
|
Ценность coding agent определяется не только возможностями модели. Она складывается из возможностей модели **и** рабочего процесса вокруг неё.
|
||||||
|
|
||||||
|
## 2. Структурируйте входные данные задачи: контекст важнее промпт-инженерии
|
||||||
|
|
||||||
|
При работе с coding agent многие разработчики слишком фокусируются на технике написания промптов и недостаточно на том, что важнее: **контексте задачи**.
|
||||||
|
|
||||||
|
В сложной кодовой базе эффективное описание задачи обычно включает четыре элемента:
|
||||||
|
|
||||||
|
- **Цель.** Чётко опишите, что нужно сделать: исправить баг, реализовать эндпоинт, отрефакторить модуль
|
||||||
|
- **Контекст.** Укажите релевантные файлы, сообщения об ошибках, документацию или примеры. Назовите конкретные файлы, функции или модули
|
||||||
|
- **Ограничения.** Перечислите инженерные требования: стандарты кодирования, архитектурные правила, требования безопасности, ограничения зависимостей
|
||||||
|
- **Критерии завершения.** Определите, как оценивать готовность: тесты проходят, поведение изменилось ожидаемым образом, баг больше не воспроизводится
|
||||||
|
|
||||||
|
Такой структурированный ввод снижает лишние догадки и делает изменения агента более последовательными и легко проверяемыми.
|
||||||
|
|
||||||
|
В большинстве coding agents контекст можно предоставить, указав на файлы, приложив фрагменты кода или явно описав детали в промпте. Когда контекст задан, следующий шаг для сложной работы: планирование перед внесением изменений.
|
||||||
|
|
||||||
|
## 3. Планируйте перед выполнением сложных задач
|
||||||
|
|
||||||
|
Когда задача имеет чёткий контекст, следующая проблема: выполнение. Для сложных запросов coding agents наиболее эффективны, когда они **планируют перед действием**.
|
||||||
|
|
||||||
|
Если попросить агента сразу писать код при сложном запросе, это часто приводит к логическим ошибкам, ненужной переработке или повторным правкам. Более эффективный подход: **сначала план, потом реализация**.
|
||||||
|
|
||||||
|
Фаза планирования обычно включает:
|
||||||
|
|
||||||
|
- Анализ кодовой базы
|
||||||
|
- Определение объёма изменений
|
||||||
|
- Подтверждение подхода к реализации до начала правок
|
||||||
|
|
||||||
|
Например, Claude Code поощряет шаг анализа и планирования для сложных задач. Некоторые coding agents также предоставляют выделенный режим планирования, который генерирует полный план выполнения перед реализацией.
|
||||||
|
|
||||||
|
Это сдвигает агента от простой генерации кода по запросу к выполнению работы пошагово по явному плану.
|
||||||
|
|
||||||
|
## 4. Фиксируйте повторяющиеся правила в конфигурационных файлах проекта
|
||||||
|
|
||||||
|
На практике многие промпты повторяют одни и те же проектные правила:
|
||||||
|
|
||||||
|
- структура директорий проекта
|
||||||
|
- команды сборки
|
||||||
|
- процесс тестирования
|
||||||
|
- стандарты кодирования
|
||||||
|
- процесс подачи PR
|
||||||
|
|
||||||
|
Если эти правила повторяются в каждом промпте, рабочий процесс становится неэффективным, а инструкции со временем начинают расходиться.
|
||||||
|
|
||||||
|
Поэтому большинство coding agents позволяют хранить **долгоживущие проектные правила** в конфигурационных файлах проекта, чтобы агент автоматически загружал нужный контекст при выполнении задач.
|
||||||
|
|
||||||
|
В одних инструментах это файлы-инструкции для агента, описывающие структуру репозитория, способ запуска проекта и принятые конвенции. В других та же информация фиксируется через конфигурационные файлы, скрипты или настройки проекта.
|
||||||
|
|
||||||
|
Независимо от реализации, цель одна: перенести информацию, которую иначе пришлось бы повторять в диалоге, в **стабильный проектный контекст**.
|
||||||
|
|
||||||
|
!!! success "Практическое правило"
|
||||||
|
**Временные инструкции пишите в промпте, а долгоживущие правила фиксируйте в конфигурационных файлах проекта.**
|
||||||
|
|
||||||
|
## 5. Среда выполнения определяет возможности агента
|
||||||
|
|
||||||
|
Работая с coding agents, разработчики часто объясняют непоследовательные результаты возможностями модели. На практике многие из этих проблем вызваны неполной или неправильно настроенной **средой выполнения**.
|
||||||
|
|
||||||
|
В отличие от традиционных инструментов автодополнения, coding agents обычно работают в реальной среде разработки и выполняют задачи:
|
||||||
|
|
||||||
|
- чтение и модификация исходных файлов
|
||||||
|
- запуск команд сборки или тестирования
|
||||||
|
- вызов внешних инструментов или API
|
||||||
|
- взаимодействие с системами контроля версий
|
||||||
|
|
||||||
|
Поведение агента зависит не только от возможностей модели, но и от того, **насколько среда выполнения полна, стабильна и доступна**. При неправильной конфигурации агент может столкнуться с проблемами:
|
||||||
|
|
||||||
|
!!! warning "Типичные проблемы среды"
|
||||||
|
- Невозможность найти нужную директорию проекта
|
||||||
|
- Отсутствие прав на чтение или модификацию критичных файлов
|
||||||
|
- Невозможность запустить команды сборки или тестирования
|
||||||
|
- Отсутствие доступа к внешним инструментам или сервисам
|
||||||
|
|
||||||
|
Эти проблемы часто выглядят как непонимание со стороны модели или низкое качество кода, но реальная причина обычно в том, что у агента недостаточно прав выполнения или доступа к нужному контексту.
|
||||||
|
|
||||||
|
Большинство ведущих coding agents предоставляют настройки среды:
|
||||||
|
|
||||||
|
- выбор модели или уровня рассуждений
|
||||||
|
- управление правами доступа к файлам и политиками песочницы
|
||||||
|
- определение разрешённых команд
|
||||||
|
- настройка подключений к внешним инструментам или сервисам
|
||||||
|
|
||||||
|
!!! success "Три типа контекста"
|
||||||
|
Coding agent зависит от трёх типов контекста:
|
||||||
|
|
||||||
|
- **Контекст задачи**: промпт и входные данные текущей задачи
|
||||||
|
- **Контекст проекта**: структура репозитория и инженерные правила
|
||||||
|
- **Контекст среды**: инструменты, права доступа и среда выполнения
|
||||||
|
|
||||||
|
Из них контекст среды определяет **что агент может делать и как далеко зайти**.
|
||||||
|
|
||||||
|
## 6. Вовлекайте агента в полный цикл разработки
|
||||||
|
|
||||||
|
Когда у coding agent есть правильная среда выполнения, следующий шаг: вовлечь его в полный цикл разработки, а не использовать только для генерации кода. В реальной разработке изменение кода оценивается не только по генерации. Оно должно пройти тесты, соответствовать инженерным стандартам и пройти ревью.
|
||||||
|
|
||||||
|
Типичный цикл разработки с агентом включает шаги:
|
||||||
|
|
||||||
|
1. **Реализация изменений.** Модификация существующего кода или добавление нового по требованиям задачи
|
||||||
|
2. **Написание или обновление тестов.** Добавление тестового покрытия для новой функциональности или исправляемого бага
|
||||||
|
3. **Запуск тестов.** Выполнение модульных или интеграционных тестов для проверки ожидаемого поведения
|
||||||
|
4. **Проверка кода.** Запуск линтеров, форматирования или проверки типов для соответствия стандартам
|
||||||
|
5. **Ревью изменений.** Инспекция диффа для выявления потенциальных проблем, рисков регрессии или нежелательных модификаций
|
||||||
|
|
||||||
|
В этом рабочем процессе coding agent перестаёт быть просто генератором кода. Он становится активным участником **реализации, валидации и ревью**.
|
||||||
|
|
||||||
|
!!! success "Смена роли"
|
||||||
|
С точки зрения рабочего процесса, coding agent трансформируется из традиционного **генератора кода** в **узел выполнения внутри цикла разработки**.
|
||||||
|
|
||||||
|
## 7. Расширяйте контекст агента через MCP
|
||||||
|
|
||||||
|
В реальных рабочих процессах информация, необходимая coding agent, не всегда находится в репозитории. Многие данные, влияющие на решения при реализации, распределены по внешним системам:
|
||||||
|
|
||||||
|
- системы трекинга задач и требований
|
||||||
|
- статус и результаты CI/CD
|
||||||
|
- схемы баз данных или продуктовые данные
|
||||||
|
- документация API и ссылки на внешние сервисы
|
||||||
|
|
||||||
|
Если эту информацию приходится копировать и вставлять вручную каждый раз, процесс становится неэффективным, а контекст, передаваемый агенту, фрагментирован и ненадёжен.
|
||||||
|
|
||||||
|
Поэтому многие coding agents поддерживают **Model Context Protocol (MCP)**, который предоставляет стандартный способ подключения внешних инструментов и систем. Через MCP coding agent может получать доступ к:
|
||||||
|
|
||||||
|
- платформам хостинга и совместной работы с кодом
|
||||||
|
- базам данных и интерфейсам запросов
|
||||||
|
- API-сервисам и технической документации
|
||||||
|
- внутренним инструментам и системам автоматизации
|
||||||
|
|
||||||
|
!!! success "Расширение границ"
|
||||||
|
Когда агент может работать только с информацией из промпта, он обычно ограничен локальными задачами. Подключение к внешним системам позволяет ему участвовать в более полных рабочих процессах: читать контекст задач, исследовать упавшие CI-запуски, проверять определения API, анализировать проблемы по схемам баз данных.
|
||||||
|
|
||||||
|
Агент эволюционирует из **исполнителя уровня репозитория** в **узел взаимодействия внутри реальной инженерной среды**.
|
||||||
|
|
||||||
|
## 8. Оформляйте повторяющиеся процессы как Skills
|
||||||
|
|
||||||
|
Со временем команды обнаруживают, что определённые задачи возникают снова и снова:
|
||||||
|
|
||||||
|
- ревью PR
|
||||||
|
- анализ логов
|
||||||
|
- генерация release notes
|
||||||
|
- стандартные отладочные процессы
|
||||||
|
|
||||||
|
Если описывать эти задачи вручную в промпте каждый раз, результат: ненужное повторение и менее стабильные результаты.
|
||||||
|
|
||||||
|
Поэтому многие системы coding agents предоставляют механизм **Skills**: упаковку типовых процессов в переиспользуемые шаблоны.
|
||||||
|
|
||||||
|
На высоком уровне Skill можно понимать как **структурированный шаблон рабочего процесса**. Он абстрагирует логику выполнения, которая иначе была бы разбросана по промптам, и позволяет агенту применять один и тот же процесс последовательно при обработке похожих задач.
|
||||||
|
|
||||||
|
Разные инструменты реализуют Skills по-разному: через выделенные файлы, конфигурацию или скрипты. Но цель одна: **превратить разовые промпты в переиспользуемые рабочие процессы**.
|
||||||
|
|
||||||
|
На практике работает простое правило:
|
||||||
|
|
||||||
|
> **Если паттерн промпта или поток задач используется повторно, он, вероятно, должен быть оформлен как Skill.**
|
||||||
|
|
||||||
|
## 9. Автоматизируйте стабильные процессы
|
||||||
|
|
||||||
|
Когда Skill можно выполнить надёжно, следующий шаг: автоматизация.
|
||||||
|
|
||||||
|
В долгоживущих рабочих процессах разработки многие задачи повторяются или привязаны ко времени:
|
||||||
|
|
||||||
|
- генерация резюме коммитов по расписанию
|
||||||
|
- автоматическое расследование упавших CI-запусков
|
||||||
|
- сканирование на потенциальные баги или аномальные логи
|
||||||
|
- подготовка ежедневных или еженедельных инженерных отчётов
|
||||||
|
|
||||||
|
Даже если эти задачи уже оформлены как Skills, они по-прежнему создают ручную работу, если разработчикам приходится запускать их каждый раз.
|
||||||
|
|
||||||
|
!!! info "Автоматизация как следующий слой"
|
||||||
|
Автоматизация находится уровнем выше Skills. Skill определяет **как** выполняется рабочий процесс, а автоматизация определяет **когда** он запускается и **как** продолжает работать со временем.
|
||||||
|
|
||||||
|
Например, навык генерации release notes можно настроить на запуск:
|
||||||
|
|
||||||
|
- при каждой новой публикации релиза
|
||||||
|
- раз в неделю для подготовки сводки релизов
|
||||||
|
- автоматически после завершения CI
|
||||||
|
|
||||||
|
Это сдвигает coding agent из **интерактивного инструмента** в **непрерывного ассистента разработки**.
|
||||||
|
|
||||||
|
## 10. Управляйте сессиями осознанно
|
||||||
|
|
||||||
|
При работе с coding agents сессия — это больше, чем история чата. На практике она функционирует как **рабочий контекст**, который накапливает контекст, промежуточные рассуждения и результаты выполнения.
|
||||||
|
|
||||||
|
По мере продвижения задачи агент постепенно наращивает информацию в рамках той же сессии:
|
||||||
|
|
||||||
|
- цель задачи
|
||||||
|
- релевантный контекст кода
|
||||||
|
- уже внесённые изменения
|
||||||
|
- промежуточные рассуждения и решения
|
||||||
|
|
||||||
|
Если сессиями не управлять, несвязанные задачи накапливаются в одной сессии, делая контекст излишне сложным. Это часто снижает качество рассуждений и выполнения агента.
|
||||||
|
|
||||||
|
Общие практики:
|
||||||
|
|
||||||
|
- **Используйте отдельную сессию для каждой задачи.** Не смешивайте несвязанные задачи, чтобы рабочий контекст оставался ясным
|
||||||
|
- **Избегайте слишком длинных сессий.** Когда сессия накапливает слишком много истории, используйте резюме или сжатие для снижения нагрузки на контекст
|
||||||
|
- **Начинайте новую сессию для ответвлений.** Если задача открывает новое направление исследования, продолжайте его в отдельной сессии
|
||||||
|
- **Периодически сжимайте исторический контекст.** Резюмируйте старые части разговора для снижения давления на контекстное окно
|
||||||
|
|
||||||
|
В более сложных сценариях команды могут использовать **модель многоагентного сотрудничества**: подзадачи (исследование кодовой базы, запуск тестов, расследование сбоев) делегируются отдельным агентам, а главный агент координирует общую задачу. Это сохраняет ясность основной сессии и повышает эффективность выполнения.
|
||||||
|
|
||||||
|
## Заключение
|
||||||
|
|
||||||
|
Эффективность coding agent определяется не только моделью. Она зависит от того, как разработчики выстраивают рабочий процесс вокруг неё.
|
||||||
|
|
||||||
|
Зрелый рабочий процесс с coding agent обычно включает следующие этапы:
|
||||||
|
|
||||||
|
1. Структурированный ввод задачи с контекстом
|
||||||
|
2. Планирование перед выполнением
|
||||||
|
3. Проектные правила в конфигурационных файлах
|
||||||
|
4. Настроенная среда выполнения
|
||||||
|
5. Участие в полном цикле разработки
|
||||||
|
6. Расширение контекста через MCP
|
||||||
|
7. Повторяющиеся процессы как Skills
|
||||||
|
8. Автоматизация стабильных процессов
|
||||||
|
9. Осознанное управление сессиями
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
# Механизм памяти coding agents
|
||||||
|
|
||||||
|
> По мотивам [Memory Mechanism](https://docs.z.ai/devpack/resources/memory-mechanism) (Z.AI DevPack)
|
||||||
|
|
||||||
|
Память позволяет coding agent сохранять контекст между задачами и сессиями, сокращая повторный ввод и повышая эффективность выполнения. С хорошо продуманной системой памяти агент может постоянно учитывать структуру проекта, инженерные конвенции и предпочтения пользователя, автоматически переиспользуя эту информацию в будущей работе.
|
||||||
|
|
||||||
|
В системах coding agents память обычно организована в несколько слоёв: **автоматическая память, проектная память** и **сессионная память**.
|
||||||
|
|
||||||
|
## Зачем coding agents нужна память?
|
||||||
|
|
||||||
|
Традиционные большие языковые модели не сохраняют состояние между вызовами. Они не могут запомнить контекст проекта между сессиями, накапливать опыт решения проблем или последовательно адаптироваться к предпочтениям пользователя.
|
||||||
|
|
||||||
|
Агентные системы решают это ограничение через **внешнюю память**.
|
||||||
|
|
||||||
|
Типичная архитектура выглядит так:
|
||||||
|
|
||||||
|
```
|
||||||
|
Ввод пользователя
|
||||||
|
↓
|
||||||
|
Извлечение памяти
|
||||||
|
↓
|
||||||
|
Сборка контекста
|
||||||
|
↓
|
||||||
|
Рассуждение LLM
|
||||||
|
↓
|
||||||
|
Действие / вызов инструмента
|
||||||
|
↓
|
||||||
|
Обновление памяти
|
||||||
|
```
|
||||||
|
|
||||||
|
Агент извлекает релевантную память перед началом задачи и обновляет память после завершения.
|
||||||
|
|
||||||
|
Эта архитектура является общим паттерном в современных агентных системах, таких как LangGraph, AutoGPT и Devin.
|
||||||
|
|
||||||
|
## Полная архитектура памяти
|
||||||
|
|
||||||
|
На высоком уровне полная архитектура памяти агента выглядит так:
|
||||||
|
|
||||||
|
```
|
||||||
|
Краткосрочная память
|
||||||
|
↓
|
||||||
|
Контекст сессии
|
||||||
|
|
||||||
|
Долгосрочная память
|
||||||
|
├ семантическая память
|
||||||
|
├ эпизодическая память
|
||||||
|
└ процедурная память
|
||||||
|
```
|
||||||
|
|
||||||
|
## Основные типы памяти
|
||||||
|
|
||||||
|
### Сессионная память
|
||||||
|
|
||||||
|
Сессионная память — это контекстная информация текущей задачи. Включает текущую историю разговора, последние результаты инструментов, текущий план выполнения и содержимое файлов в области видимости. Эта информация обычно находится в контекстном окне модели.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```
|
||||||
|
Пользователь: Исправь этот баг в Python
|
||||||
|
Агент: Анализирует ошибку
|
||||||
|
Агент: Модифицирует код
|
||||||
|
Агент: Запускает тесты
|
||||||
|
```
|
||||||
|
|
||||||
|
Все эти шаги выполнения относятся к сессионной памяти.
|
||||||
|
|
||||||
|
### Проектная память
|
||||||
|
|
||||||
|
Проектная память хранит **долгоживущую информацию о всей кодовой базе**: архитектуру проекта, стандарты кодирования, процессы сборки, часто используемые команды. Такая память обычно записывается в `.md`-файлы и загружается в начале сессии.
|
||||||
|
|
||||||
|
Пример структуры:
|
||||||
|
|
||||||
|
```
|
||||||
|
your-project/
|
||||||
|
├── .claude/
|
||||||
|
│ ├── CLAUDE.md # Основные инструкции проекта
|
||||||
|
│ └── rules/
|
||||||
|
│ ├── code-style.md # Стиль кода
|
||||||
|
│ ├── testing.md # Конвенции тестирования
|
||||||
|
│ └── security.md # Требования безопасности
|
||||||
|
```
|
||||||
|
|
||||||
|
При такой структуре агент автоматически следует этим правилам при модификации кода.
|
||||||
|
|
||||||
|
### Семантическая память
|
||||||
|
|
||||||
|
Семантическая память хранит фактические знания и справочную информацию: документацию API, правила языков программирования, базы знаний проекта. На практике часто реализуется через RAG (Retrieval-Augmented Generation).
|
||||||
|
|
||||||
|
Типичный поток:
|
||||||
|
|
||||||
|
```
|
||||||
|
запрос
|
||||||
|
↓
|
||||||
|
эмбеддинг
|
||||||
|
↓
|
||||||
|
векторный поиск
|
||||||
|
↓
|
||||||
|
извлечение документов
|
||||||
|
↓
|
||||||
|
рассуждение LLM
|
||||||
|
```
|
||||||
|
|
||||||
|
Это один из наиболее распространённых методов запоминания в coding agents.
|
||||||
|
|
||||||
|
### Эпизодическая память
|
||||||
|
|
||||||
|
Эпизодическая память записывает прошлый опыт агента: шаги исправления предыдущего бага, корневую причину прошлого сбоя сборки, стратегию отладки, которая сработала. Этот тип памяти помогает агенту учиться на предыдущем опыте.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```
|
||||||
|
Эпизод:
|
||||||
|
Сбой CI из-за отсутствующей зависимости
|
||||||
|
Решение: обновить pip-пакет
|
||||||
|
```
|
||||||
|
|
||||||
|
### Процедурная память
|
||||||
|
|
||||||
|
Процедурная память хранит стратегии или пошаговые процессы выполнения задач.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```
|
||||||
|
Debug_Workflow.md
|
||||||
|
1. прочитать лог ошибок
|
||||||
|
2. найти файл
|
||||||
|
3. написать патч
|
||||||
|
4. запустить тесты
|
||||||
|
```
|
||||||
|
|
||||||
|
Такая память обычно используется в системных промптах, шаблонах рабочих процессов и политиках агента.
|
||||||
|
|
||||||
|
## Стандартный паттерн использования памяти
|
||||||
|
|
||||||
|
В реальных системах агенты обычно следуют единообразному процессу работы с памятью.
|
||||||
|
|
||||||
|
**Шаг 1: Извлечение памяти**
|
||||||
|
|
||||||
|
Перед началом задачи агент извлекает релевантную проектную память, записи из базы знаний и предыдущий опыт, затем внедряет их в рабочий контекст.
|
||||||
|
|
||||||
|
**Шаг 2: Сборка контекста**
|
||||||
|
|
||||||
|
Извлечённые воспоминания собираются в полный контекст и передаются модели.
|
||||||
|
|
||||||
|
**Шаг 3: Обновление памяти**
|
||||||
|
|
||||||
|
После завершения задачи агент решает, нужно ли записать новые воспоминания: обнаруженные проектные правила, опыт отладки или предпочтения пользователя.
|
||||||
|
|
||||||
|
## Как правильно использовать память
|
||||||
|
|
||||||
|
В основных агентных системах память проектируется как **многослойная, управляемая, извлекаемая и обновляемая**.
|
||||||
|
|
||||||
|
Обычно память делится на **краткосрочную** и **долгосрочную**. Краткосрочная используется для сохранения состояния в текущем потоке или сессии. Долгосрочная поддерживается через явные файлы, конфигурации правил, векторное извлечение или другие механизмы постоянного хранения.
|
||||||
|
|
||||||
|
Например, в **Claude Code** каждая сессия начинается с чистого контекстного окна. Знания переносятся между сессиями через файлы инструкций (CLAUDE.md) и **автоматическую память**. В **LangChain / LangGraph** память также делится на **краткосрочную в рамках потока** и **долгосрочную между сессиями**.
|
||||||
|
|
||||||
|
На практике наиболее эффективный подход: не полагаться на модель в автоматическом «запоминании всего», а установить чёткий паттерн управления памятью. Определить: что записывать в проектные файлы памяти, что извлекать из базы знаний или векторного хранилища, что оставить только в текущей сессии, а что продвинуть в долгосрочную память после завершения задачи.
|
||||||
|
|
||||||
|
### Разделяйте инструкционную и обучающую память
|
||||||
|
|
||||||
|
Один из наиболее практичных принципов: различать два фундаментально разных вида памяти.
|
||||||
|
|
||||||
|
- **Инструкционная память**: написана людьми, чтобы указать агенту, как он должен работать. Обычно включает стандарты кодирования, конвенции директорий, команды сборки, процедуры тестирования, требования к именованию, правила коммитов и правила безопасности на уровне команды. В Claude Code это файлы инструкций вроде `CLAUDE.md`
|
||||||
|
|
||||||
|
- **Обучающая память**: не определена заранее, а накоплена агентом из ваших поправок, предпочтений, неудачных попыток, частых команд и привычек проекта. В Claude Code это называется автоматическая память (auto memory)
|
||||||
|
|
||||||
|
Если эти два типа памяти смешиваются, поведение системы со временем дрейфует. Лучший подход: чётко разделить их роли.
|
||||||
|
|
||||||
|
- **Правила, политики и поведенческие ограничения** записывайте в **инструкционную память**, чтобы поведение агента оставалось стабильным и предсказуемым
|
||||||
|
- **Опыт, предпочтения пользователя, временные открытия и ретроспективные выводы** записывайте в **обучающую память**, чтобы решения улучшались в будущих задачах
|
||||||
|
|
||||||
|
Это разделение предотвращает постепенное загрязнение основных правил системы заметками из опыта.
|
||||||
|
|
||||||
|
### Многоуровневое управление памятью
|
||||||
|
|
||||||
|
#### Уровень организации
|
||||||
|
|
||||||
|
Правила, определённые и распространяемые на уровне команды или компании, применимые ко всем разработчикам и проектам:
|
||||||
|
|
||||||
|
- требования безопасности и соответствия
|
||||||
|
- базовые стандарты код-ревью
|
||||||
|
- запрещённые директории для чтения/записи
|
||||||
|
- ограничения зависимостей и лицензий
|
||||||
|
- инженерные стандарты организации
|
||||||
|
|
||||||
|
На организационном уровне общий файл правил развёртывается по системному пути и не должен легко отключаться пользователями. **Организационная память — это высокоприоритетный управленческий слой, который не должен обходиться.**
|
||||||
|
|
||||||
|
#### Уровень проекта
|
||||||
|
|
||||||
|
Командный контекст проекта, версионируемый и общий для всех участников. **Это самый важный слой памяти для coding agent.**
|
||||||
|
|
||||||
|
- документация архитектуры проекта
|
||||||
|
- конвенции структуры директорий
|
||||||
|
- команды сборки и тестирования
|
||||||
|
- где должны располагаться API
|
||||||
|
- конвенции именования
|
||||||
|
- типовые процессы разработки
|
||||||
|
|
||||||
|
Claude Code рекомендует хранить эту информацию в проектном файле, а команда `/init` может автоматически сгенерировать первоначальный черновик. Ключевое свойство этого слоя: **общий для проекта, под контролем версий, стабильный во времени**.
|
||||||
|
|
||||||
|
#### Уровень пользователя
|
||||||
|
|
||||||
|
Персональные предпочтения разработчика, применимые ко всем проектам. Лучше хранить в домашней директории пользователя как переиспользуемый личный контекст для всех рабочих пространств:
|
||||||
|
|
||||||
|
- предпочитаемый стиль кодирования
|
||||||
|
- привычная последовательность отладки
|
||||||
|
- предпочитаемый формат вывода
|
||||||
|
- персональные быстрые команды
|
||||||
|
|
||||||
|
Должен дополнять проектные конвенции, а не переопределять их.
|
||||||
|
|
||||||
|
#### Локальный уровень
|
||||||
|
|
||||||
|
Специфичен для вашей локальной копии проекта, **не должен попадать в Git**:
|
||||||
|
|
||||||
|
- персональные тестовые аккаунты
|
||||||
|
- локальные порты разработки
|
||||||
|
- временные адреса тестовых заглушек
|
||||||
|
- заметки по среде выполнения на конкретной машине
|
||||||
|
- экспериментальные рабочие процессы, не готовые к распространению
|
||||||
|
|
||||||
|
Ценность этого слоя: **позволяет индивидуальную эффективную работу без загрязнения общей памяти**.
|
||||||
|
|
||||||
|
#### Уровень субагента / роли
|
||||||
|
|
||||||
|
Разные субагенты могут поддерживать собственные области памяти вместо использования единой глобальной. Это особенно важно в многоагентных системах, где одна из самых частых проблем: загрязнение контекста между ролями.
|
||||||
|
|
||||||
|
Лучший паттерн: каждый субагент хранит только память, релевантную его роли:
|
||||||
|
|
||||||
|
- **агент тестирования** помнит команды тестирования, поведение CI, стиль утверждений
|
||||||
|
- **агент рефакторинга** помнит границы модулей, запрещённые зависимости, стратегии миграции
|
||||||
|
- **агент документации** помнит глоссарий терминов, шаблоны документации, стиль для целевой аудитории
|
||||||
|
|
||||||
|
Это делает память короче, точнее и стабильнее.
|
||||||
|
|
||||||
|
### Загрузка `.md`-файлов по пути
|
||||||
|
|
||||||
|
Для крупных репозиториев рекомендуется разделять инструкции на несколько Markdown-файлов в `.claude/rules/`, каждый посвящён одной теме: `testing.md`, `api-design.md`, `security.md`.
|
||||||
|
|
||||||
|
Claude Code также поддерживает **привязку правил к определённым поддиректориям или типам файлов**: правила загружаются только когда агент работает с подходящими файлами. Это снижает шум и экономит контекстное окно.
|
||||||
|
|
||||||
|
Три принципа организации:
|
||||||
|
|
||||||
|
- **Основной файл памяти ограничен глобальным общим контекстом**: фон проекта, высокоуровневая архитектура, кросс-проектные конвенции
|
||||||
|
- **Специализированные правила модульны**: один файл правил на тему
|
||||||
|
- **Если правило можно загрузить по пути, не загружайте его глобально**: включайте в контекст только при необходимости
|
||||||
|
|
||||||
|
Пример структуры:
|
||||||
|
|
||||||
|
```
|
||||||
|
agent-memory/
|
||||||
|
├── project.md # Обзор проекта
|
||||||
|
├── rules/
|
||||||
|
│ ├── code-style.md # Стиль кода
|
||||||
|
│ ├── testing.md # Конвенции тестирования
|
||||||
|
│ ├── api-design.md # Правила дизайна API
|
||||||
|
│ ├── security.md # Требования безопасности
|
||||||
|
│ └── frontend/
|
||||||
|
│ └── react.md # Правила фронтенда
|
||||||
|
└── local/
|
||||||
|
└── developer.local.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Три преимущества такой структуры:
|
||||||
|
|
||||||
|
1. **Проще поддерживать.** Каждый файл правил фокусируется на одной теме, набор правил менее склонен к разрастанию
|
||||||
|
2. **Проще загружать по запросу.** Когда агент работает над тестами, ему не нужно загружать конвенции фронтенда или правила баз данных
|
||||||
|
3. **Лучше для командной работы.** Разные команды могут поддерживать собственные директории правил вместо редактирования единого монолитного файла
|
||||||
|
|
||||||
|
### Пишите правила памяти как конкретные инструкции
|
||||||
|
|
||||||
|
При написании памяти агента используйте **конкретные, проверяемые правила**, а не абстрактные принципы. Чем яснее инструкции, тем стабильнее поведение агента.
|
||||||
|
|
||||||
|
Общие рекомендации:
|
||||||
|
|
||||||
|
- инструкции должны быть **лаконичными и явными**
|
||||||
|
- правила должны быть **согласованы** друг с другом
|
||||||
|
- основной файл памяти **не более 200 строк** по возможности
|
||||||
|
- используйте **Markdown-заголовки и списки** для читаемости
|
||||||
|
- формулируйте требования как правила, которые можно **проверить и выполнить**
|
||||||
|
|
||||||
|
Избегайте расплывчатых формулировок:
|
||||||
|
|
||||||
|
- ~~Держите код чистым~~
|
||||||
|
- ~~Пишите хорошие тесты~~
|
||||||
|
- ~~Следите за дизайном API~~
|
||||||
|
- ~~Разделяйте модули при необходимости~~
|
||||||
|
|
||||||
|
Предпочитайте конкретные правила:
|
||||||
|
|
||||||
|
- Используйте **2-пробельный отступ** во всех новых TypeScript-файлах
|
||||||
|
- **Запускайте `pnpm test`** после модификации бизнес-логики
|
||||||
|
- Размещайте **все обработчики API в `src/api/handlers/`**
|
||||||
|
- Держите React-компоненты страниц **менее 300 строк**; разбивайте большие на хуки или дочерние компоненты
|
||||||
|
|
||||||
|
Конкретные правила значительно сокращают пространство для интерпретации агентом, что повышает стабильность поведения.
|
||||||
|
|
||||||
|
### Переиспользование памяти через импорт
|
||||||
|
|
||||||
|
В реальных проектах многие правила — это **общие инженерные конвенции между репозиториями**. Переписывание их в каждом репозитории увеличивает накладные расходы на поддержку и повышает вероятность рассогласования.
|
||||||
|
|
||||||
|
В Claude Code:
|
||||||
|
|
||||||
|
- `CLAUDE.md` может импортировать другие файлы правил через `@path/to/import`
|
||||||
|
- `.claude/rules/` может делить правила через **символические ссылки** (symlinks)
|
||||||
|
- импортируемый контент раскрывается **рекурсивно**, символические ссылки разрешаются нормально
|
||||||
|
|
||||||
|
Это позволяет командам создавать **переиспользуемые пакеты правил**:
|
||||||
|
|
||||||
|
- `company-security-rules`
|
||||||
|
- `frontend-react-rules`
|
||||||
|
- `backend-api-rules`
|
||||||
|
- `python-testing-rules`
|
||||||
|
|
||||||
|
Каждый проект ссылается только на нужные модули правил, а не поддерживает полную копию всего набора.
|
||||||
|
|
||||||
|
Два прямых преимущества:
|
||||||
|
|
||||||
|
1. **Правила поддерживаются централизованно и обновляются единообразно**
|
||||||
|
2. **Разные проекты разделяют один инженерный язык**, делая поведение агента согласованным между репозиториями
|
||||||
|
|
||||||
|
## Устранение проблем с памятью
|
||||||
|
|
||||||
|
### Агент не следует `.md`-файлам памяти
|
||||||
|
|
||||||
|
`.md`-файлы памяти предоставляются агенту как контекстные инструкции, а не как принудительная конфигурация. Агент прочитает их и попытается следовать, но не гарантирует строгое соблюдение при расплывчатых, неясных или конфликтующих правилах.
|
||||||
|
|
||||||
|
Если агент не следует правилам, проверьте:
|
||||||
|
|
||||||
|
- Подтвердите загрузку `.md`-файлов памяти (команда `/memory` или аналог)
|
||||||
|
- Проверьте, находятся ли файлы в пути, разрешённом для загрузки в текущей сессии
|
||||||
|
- Проверьте конфликты правил между файлами. Если разные файлы дают разные инструкции для одного поведения, агент может выбрать произвольно
|
||||||
|
|
||||||
|
### Непонятно, что сохранила автоматическая память
|
||||||
|
|
||||||
|
Большинство coding agents поддерживают авто-память в фоне для захвата контекста проекта, предпочтений пользователя или частых действий.
|
||||||
|
|
||||||
|
Способы проверки:
|
||||||
|
|
||||||
|
- Выполните `/memory` (или аналогичную команду) для просмотра текущей директории авто-памяти
|
||||||
|
- Авто-память обычно хранится в Markdown-файлах, которые можно читать, редактировать или удалять напрямую
|
||||||
|
|
||||||
|
### Файлы памяти слишком большие
|
||||||
|
|
||||||
|
Раздутые файлы памяти потребляют больше контекстного окна, снижают следование инструкциям и увеличивают вероятность конфликтов.
|
||||||
|
|
||||||
|
Рекомендуется:
|
||||||
|
|
||||||
|
- разделить детальный контент на несколько Markdown-файлов
|
||||||
|
- использовать ссылки на файлы или импорты (`@path/to/file`)
|
||||||
|
- перенести правила в выделенную директорию правил (`rules/`)
|
||||||
|
|
||||||
|
### Инструкции исчезают после сжатия контекста
|
||||||
|
|
||||||
|
Многие coding agents **сжимают или резюмируют контекст** в длинных разговорах для уменьшения длины контекста.
|
||||||
|
|
||||||
|
В большинстве случаев файлы памяти **перезагружаются с диска** после сжатия, поэтому сохраняется только контент, записанный в файлы памяти. Если правила исчезают после сжатия, значит они **существовали только в разговоре** и не были записаны в файл.
|
||||||
|
|
||||||
|
Решение:
|
||||||
|
|
||||||
|
- записывайте долгосрочные инструкции в `.md`-файлы памяти
|
||||||
|
- не полагайтесь только на разговор для сохранения правил
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# Airflow Configuration
|
|
||||||
AIRFLOW_USER=admin
|
|
||||||
AIRFLOW_PASSWORD=admin
|
|
||||||
|
|
||||||
# PostgreSQL (Airflow metadata)
|
|
||||||
PG_USER=airflow
|
|
||||||
PG_PASSWORD=airflow
|
|
||||||
PG_DB=airflow
|
|
||||||
|
|
||||||
# Greenplum Configuration
|
|
||||||
GP_USER=gpadmin
|
|
||||||
GP_PASSWORD=gpadmin
|
|
||||||
GP_DB=gpadmin
|
|
||||||
GP_HOST=greenplum
|
|
||||||
GP_PORT=5432
|
|
||||||
GP_CONN_ID=greenplum_conn
|
|
||||||
GP_USE_AIRFLOW_CONN=true
|
|
||||||
|
|
||||||
# CSV Pipeline
|
|
||||||
CSV_DIR=/opt/airflow/data
|
|
||||||
CSV_ROWS=1000
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.vscode/settings.json
|
|
||||||
|
|
||||||
# Do not commit secrets
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
__pycache__/
|
|
||||||
*/__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.venv/
|
|
||||||
data/
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
3.11
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# Repository Guidelines (для агентa и контрибьюторов)
|
|
||||||
|
|
||||||
Эта репа — учебный стенд для студентов (менти), которые только начинают с Airflow/Greenplum и Python. Пожалуйста, держите решения простыми, стабильными и хорошо объяснёнными.
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
- `airflow/dags/` — DAG-файлы (например, `csv_to_greenplum.py`, `data_quality_greenplum.py`).
|
|
||||||
- `airflow/requirements.txt` — зависимости, которые ставятся внутри контейнеров Airflow.
|
|
||||||
- `sql/` — DDL и вспомогательные SQL (например, `sql/ddl_gp.sql`).
|
|
||||||
- `docker-compose.yml` — Greenplum, Airflow, Postgres (мета-БД).
|
|
||||||
- `Makefile` — удобные команды для локальной работы.
|
|
||||||
- `.env(.example)` — настройки окружения (реальные секреты не коммитим).
|
|
||||||
|
|
||||||
## Команды (основные)
|
|
||||||
- `make up` — поднять весь стек.
|
|
||||||
- `make airflow-init` — инициализировать мета-БД Airflow и создать пользователя.
|
|
||||||
- `make logs` — логи webserver и scheduler.
|
|
||||||
- `make ddl-gp` — применить DDL к Greenplum.
|
|
||||||
- `make gp-psql` — открыть `psql` в контейнере Greenplum от `gpadmin`.
|
|
||||||
- `make down` — остановить и удалить тома (данные будут потеряны).
|
|
||||||
|
|
||||||
Пример: `make up && make airflow-init`, затем открыть `http://localhost:8080`.
|
|
||||||
|
|
||||||
## Локальное Python‑окружение
|
|
||||||
- Используем `uv`: достаточно `uv sync` (или `make dev-sync`) — подтянет Python, создаст `.venv`, установит зависимости.
|
|
||||||
- `make dev-setup` полезен при смене версии Python (выполнит `uv python install` + `uv python pin` перед `uv sync`).
|
|
||||||
- Проверки: `make test`, `make lint`, `make fmt` (выполняются через `uv run`).
|
|
||||||
- Не используем `pip install --user`; если что‑то попало в user‑site — удалить `pip uninstall <package>` и проверить `pip list --user`.
|
|
||||||
- В IDE выбираем интерпретатор из `.venv`.
|
|
||||||
|
|
||||||
## Стиль кода
|
|
||||||
- Python: PEP 8, 4 пробела, `snake_case`; `dag_id` — `lower_snake_case`.
|
|
||||||
- Импорты: stdlib → third‑party → local, по одному модулю в строке.
|
|
||||||
- SQL: ключевые слова UPPERCASE, идентификаторы `snake_case`, завершаем `;`.
|
|
||||||
- Форматирование: `black` (88 cols) и `isort`. Если не уверены — запустите `make fmt`.
|
|
||||||
- Язык: комментарии, docstring и документацию — на русском; имена идентификаторов — на английском.
|
|
||||||
|
|
||||||
## Тестирование
|
|
||||||
- Тесты лежат в `tests/` (pytest). Запуск: `make test`.
|
|
||||||
- Есть юнит‑тесты для `helpers/greenplum.py` и smoke‑тесты DAG‑структуры (`tests/test_dags_smoke.py`).
|
|
||||||
- Smoke‑тесты DAG автоматически пропускаются, если Airflow не установлен в venv.
|
|
||||||
- Для ручного прогона стенда см. `TESTING.md` (пошаговый чек‑лист для студентов).
|
|
||||||
|
|
||||||
## Pull Requests
|
|
||||||
- Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`. Пример: `feat(dags): load orders to Greenplum`.
|
|
||||||
- Держите изменения минимальными и локальными. Не переименовывайте Make‑таргеты без обновления документации.
|
|
||||||
- В описании PR добавляйте скрин DAG‑графа или логи задач, если менялась логика.
|
|
||||||
- При изменении схемы/поведения — обновляйте `README.md` и `sql/ddl_gp.sql`.
|
|
||||||
|
|
||||||
## Безопасность и конфигурация
|
|
||||||
- Все настройки — через `.env`; креды в коде не хардкодим. Частые переменные: `GP_*`, `PG_*`, `AIRFLOW_*`, `CSV_*`.
|
|
||||||
- `make down` удаляет тома — предупреждайте студентов, что данные пропадут.
|
|
||||||
|
|
||||||
## Для агента (особенности аудитории)
|
|
||||||
- Пишите простыми словами. Добавляйте короткие комментарии к нетривиальной логике.
|
|
||||||
- Избегайте больших рефакторингов и сложных паттернов — студенты только начинают.
|
|
||||||
- Ошибки и логи — дружелюбные и понятные (лучше с подсказкой «что сделать дальше»).
|
|
||||||
- Перед релевантными правками валидируйте локально: `make up && make airflow-init`, затем откройте DAG в UI и/или прогоните `make test`.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
SHELL := /bin/bash
|
|
||||||
UV := uv
|
|
||||||
PYTHON_VERSION := 3.11
|
|
||||||
|
|
||||||
.PHONY: up down airflow-init logs gp-psql ddl-gp dev-setup dev-sync dev-lock test lint fmt clean-venv
|
|
||||||
|
|
||||||
up:
|
|
||||||
docker compose -f docker-compose.yml up -d
|
|
||||||
|
|
||||||
down:
|
|
||||||
docker compose -f docker-compose.yml down -v
|
|
||||||
|
|
||||||
airflow-init:
|
|
||||||
docker compose -f docker-compose.yml run --rm airflow-init
|
|
||||||
|
|
||||||
logs:
|
|
||||||
docker compose -f docker-compose.yml logs -f airflow-webserver airflow-scheduler
|
|
||||||
|
|
||||||
gp-psql:
|
|
||||||
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -p 5432 -d gpadmin'"
|
|
||||||
|
|
||||||
ddl-gp:
|
|
||||||
docker compose -f docker-compose.yml exec greenplum bash -c "su - gpadmin -c '/usr/local/greenplum-db/bin/psql -d gpadmin -f /sql/ddl_gp.sql'"
|
|
||||||
|
|
||||||
dev-setup:
|
|
||||||
$(UV) python install $(PYTHON_VERSION)
|
|
||||||
$(UV) python pin $(PYTHON_VERSION)
|
|
||||||
$(UV) sync
|
|
||||||
|
|
||||||
dev-sync:
|
|
||||||
$(UV) sync
|
|
||||||
|
|
||||||
dev-lock:
|
|
||||||
$(UV) lock --upgrade
|
|
||||||
|
|
||||||
test:
|
|
||||||
$(UV) run pytest -q
|
|
||||||
|
|
||||||
lint:
|
|
||||||
$(UV) run black --check airflow tests
|
|
||||||
$(UV) run isort --check-only airflow tests
|
|
||||||
|
|
||||||
fmt:
|
|
||||||
$(UV) run black airflow tests
|
|
||||||
$(UV) run isort airflow tests
|
|
||||||
|
|
||||||
clean-venv:
|
|
||||||
python -c "import shutil; shutil.rmtree('.venv', ignore_errors=True)"
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
# DE Starter Kit — Airflow + Greenplum + CSV
|
|
||||||
|
|
||||||
Добро пожаловать в учебный стенд для изучения основ Data Engineering! Этот проект поможет вам освоить ключевые инструменты современных data pipeline: **Airflow** для оркестрации, **pandas/CSV** для подготовки данных и **Greenplum** как аналитическую базу данных.
|
|
||||||
|
|
||||||
## 🎯 Что вы узнаете
|
|
||||||
|
|
||||||
- Как настроить локальный стек данных с помощью Docker
|
|
||||||
- Как Airflow управляет workflow и координирует задачи
|
|
||||||
- Как генерировать датасеты через pandas и сохранять их в CSV
|
|
||||||
- Как загружать данные в Greenplum пакетами и избегать дублей
|
|
||||||
- Как проверять качество данных в автоматизированных pipeline
|
|
||||||
- Основы проектирования ETL/ELT процессов
|
|
||||||
|
|
||||||
## 👩🎓 Для студентов (10‑минутный чек‑лист)
|
|
||||||
|
|
||||||
- Установите Docker Desktop и Git.
|
|
||||||
- Скопируйте настройки: `cp .env.example .env`.
|
|
||||||
- Поднимите стенд: `docker compose up -d` и инициализируйте Airflow: `docker compose run --rm airflow-init`.
|
|
||||||
- Откройте UI: http://localhost:8080 (admin/admin).
|
|
||||||
- Включите и запустите DAG `csv_to_greenplum`. Дождитесь Success.
|
|
||||||
- Проверьте данные: `make gp-psql` → `SELECT COUNT(*) FROM public.orders;`.
|
|
||||||
- Дополнительно: запустите `greenplum_data_quality` — все проверки должны быть зелёные.
|
|
||||||
|
|
||||||
Если что‑то не работает — смотрите «Типичные проблемы» и «Быстрый reset» ниже.
|
|
||||||
|
|
||||||
## Локальное окружение разработчика
|
|
||||||
|
|
||||||
Локальным окружением управляет [uv](https://docs.astral.sh/uv/) — он скачивает нужный Python и создаёт `.venv` на основе `pyproject.toml` / `uv.lock`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync
|
|
||||||
```
|
|
||||||
|
|
||||||
`uv sync` сам подтянет версию Python из `.python-version`/`pyproject.toml`, создаст `.venv` и установит зависимости. Для тех же действий можно использовать `make dev-sync`. Цель `make dev-setup` (или вручную `uv python install` + `uv python pin`) нужна только когда вы меняете версию Python или прогреваете кэш.
|
|
||||||
|
|
||||||
> Если требуется «классическое» активированное окружение, после `uv sync` выполните `.\.venv\Scripts\Activate.ps1` в PowerShell или `source .venv/bin/activate` в Unix-терминале.
|
|
||||||
|
|
||||||
Проверки и форматирование выполняем через uv:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test # uv run pytest -q
|
|
||||||
make lint # black/isort в режиме проверки
|
|
||||||
make fmt # автоформатирование black + isort
|
|
||||||
```
|
|
||||||
|
|
||||||
### Быстрый старт с uv
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync
|
|
||||||
uv run pytest -q
|
|
||||||
uv run black --check airflow tests
|
|
||||||
```
|
|
||||||
|
|
||||||
> Не устанавливайте пакеты напрямую через `pip install --user ...`. Если что-то уже попало в user-site, удалите `pip uninstall <package>` и проверьте `pip list --user`.
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Быстрый старт (для новичков)
|
|
||||||
|
|
||||||
### Шаг 1: Подготовка окружения
|
|
||||||
|
|
||||||
**Требования:**
|
|
||||||
- Docker Desktop (Windows/Mac) или Docker Engine 24+ (Linux)
|
|
||||||
- Git для клонирования репозитория
|
|
||||||
|
|
||||||
> 💡 **Совет:** Если у вас Windows, рекомендуем использовать WSL (Windows Subsystem for Linux) для лучшей совместимости.
|
|
||||||
|
|
||||||
### Шаг 2: Настройка проекта
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Скопируйте файл настроек
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
# Запустите стек (это может занять 2-3 минуты при первом запуске)
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Инициализируйте Airflow
|
|
||||||
docker compose run --rm airflow-init
|
|
||||||
```
|
|
||||||
|
|
||||||
### Шаг 3: Первый запуск pipeline
|
|
||||||
|
|
||||||
1. Откройте Airflow UI: **http://localhost:8080** (логин/пароль: admin/admin)
|
|
||||||
2. Найдите DAG с названием **csv_to_greenplum**
|
|
||||||
3. Нажмите на переключатель слева от названия DAG, чтобы включить его
|
|
||||||
4. Нажмите кнопку **Trigger** (значок воспроизведения ▶️)
|
|
||||||
|
|
||||||
🎉 **Поздравляем!** Вы только что запустили свой первый data pipeline:
|
|
||||||
- Система сгенерировала 1000 тестовых заказов при помощи pandas
|
|
||||||
- Датасет сохранился в CSV-файл в каталоге `./data`
|
|
||||||
- Airflow загрузил данные из CSV в Greenplum без дублей по `order_id`
|
|
||||||
|
|
||||||
### Шаг 4: Проверка результатов
|
|
||||||
|
|
||||||
**Проверка вручную:**
|
|
||||||
```bash
|
|
||||||
# Подключитесь к Greenplum и проверьте данные
|
|
||||||
docker compose exec greenplum bash -c "su - gpadmin -c 'psql -p 5432 -d gpadmin'"
|
|
||||||
|
|
||||||
# Внутри psql выполните:
|
|
||||||
\dt # Показать таблицы
|
|
||||||
SELECT count(*) FROM public.orders; # Посчитать записи
|
|
||||||
|
|
||||||
# Посмотреть несколько строк
|
|
||||||
SELECT * FROM public.orders LIMIT 5;
|
|
||||||
```
|
|
||||||
|
|
||||||
CSV-файлы после выполнения DAG остаются в директории `./data`. Их можно открыть любым редактором или изучить через pandas.
|
|
||||||
|
|
||||||
### Быстрый reset
|
|
||||||
|
|
||||||
Если после изменений что‑то «сломалось»:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make down # Остановить и стереть данные в контейнерах
|
|
||||||
make up && make airflow-init
|
|
||||||
```
|
|
||||||
|
|
||||||
Это помогает, когда Greenplum не стартует из‑за «грязной» остановки и внутренних файлов.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Подробная настройка (для уверенных пользователей)
|
|
||||||
|
|
||||||
### Установка Make (опционально)
|
|
||||||
|
|
||||||
Для удобства работы с проектом рекомендуем установить `make`:
|
|
||||||
|
|
||||||
- **Linux (Debian/Ubuntu):** `sudo apt install -y make`
|
|
||||||
- **macOS:** `brew install make`
|
|
||||||
- **Windows:**
|
|
||||||
- WSL: `sudo apt install -y make`
|
|
||||||
- Chocolatey: `choco install make`
|
|
||||||
- Scoop: `scoop install make`
|
|
||||||
|
|
||||||
С `make` команды становятся короче:
|
|
||||||
```bash
|
|
||||||
make up && make airflow-init # Запуск стека
|
|
||||||
make logs # Просмотр логов
|
|
||||||
make gp-psql # Подключение к Greenplum
|
|
||||||
```
|
|
||||||
|
|
||||||
### Настройка подключения к Greenplum в Airflow
|
|
||||||
|
|
||||||
По умолчанию DAG использует переменные окружения, но вы можете создать Airflow Connection:
|
|
||||||
|
|
||||||
1. Airflow UI → **Admin → Connections → Add a new record**
|
|
||||||
2. Заполните поля:
|
|
||||||
- **Conn Id:** `greenplum_conn`
|
|
||||||
- **Conn Type:** `Postgres`
|
|
||||||
- **Host:** `greenplum`
|
|
||||||
- **Schema:** `gpadmin`
|
|
||||||
- **Login:** `gpadmin`
|
|
||||||
- **Password:** `gpadmin`
|
|
||||||
- **Port:** `5432`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Что входит в стенд
|
|
||||||
|
|
||||||
### Основные компоненты
|
|
||||||
- **Greenplum** — аналитическая база данных для хранения и анализа данных
|
|
||||||
- **Airflow** — оркестратор workflow и задач
|
|
||||||
- **Postgres** — база метаданных для Airflow
|
|
||||||
- **pandas** — библиотека для генерации и анализа данных в формате CSV
|
|
||||||
|
|
||||||
### Готовые DAG (workflow)
|
|
||||||
- **csv_to_greenplum** — базовый pipeline: pandas → CSV → Greenplum
|
|
||||||
- **greenplum_data_quality** — проверки качества данных (наличие таблицы, схема, дубликаты)
|
|
||||||
|
|
||||||
### Полезные команды
|
|
||||||
```bash
|
|
||||||
# Основные команды
|
|
||||||
make up # Запустить весь стенд
|
|
||||||
make down # Остановить и удалить данные
|
|
||||||
make airflow-init # Инициализировать Airflow
|
|
||||||
make ddl-gp # Применить DDL к Greenplum
|
|
||||||
make gp-psql # Подключиться к Greenplum через psql
|
|
||||||
|
|
||||||
# Проверка данных
|
|
||||||
make logs # Следить за логами Airflow
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ Настройка через переменные окружения
|
|
||||||
|
|
||||||
Все настройки находятся в файле `.env`. Основные параметры:
|
|
||||||
|
|
||||||
### Greenplum
|
|
||||||
- `GP_USER` — пользователь (по умолчанию: gpadmin)
|
|
||||||
- `GP_PASSWORD` — пароль (по умолчанию: gpadmin)
|
|
||||||
- `GP_DB` — база данных (по умолчанию: gpadmin)
|
|
||||||
- `GP_PORT` — порт (по умолчанию: 5432)
|
|
||||||
|
|
||||||
### CSV pipeline
|
|
||||||
- `CSV_DIR` — путь к каталогу с CSV внутри контейнеров Airflow (по умолчанию: `/opt/airflow/data`)
|
|
||||||
- `CSV_ROWS` — количество строк, генерируемых DAG (по умолчанию: 1000)
|
|
||||||
|
|
||||||
### Airflow
|
|
||||||
- `GP_CONN_ID` — ID подключения (по умолчанию: greenplum_conn)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Продвинутые темы
|
|
||||||
|
|
||||||
### Архитектура pipeline
|
|
||||||
|
|
||||||
**Поток данных в DAG `csv_to_greenplum`:**
|
|
||||||
1. `create_orders_table` — создаёт таблицу `public.orders` в Greenplum
|
|
||||||
2. `generate_csv` — генерирует датасет при помощи pandas и сохраняет CSV в `CSV_DIR`
|
|
||||||
3. `preview_csv` — выводит предпросмотр и статистику по данным
|
|
||||||
4. `load_csv_to_greenplum` — загружает CSV во временную таблицу и переносит новые строки в `public.orders`
|
|
||||||
|
|
||||||
> 💡 **Безопасность повторного запуска:** Pipeline защищен от дубликатов, поэтому его можно запускать многократно.
|
|
||||||
|
|
||||||
### Проверка качества данных
|
|
||||||
|
|
||||||
Запустите DAG `greenplum_data_quality` для автоматической проверки:
|
|
||||||
- Наличие таблицы в базе
|
|
||||||
- Соответствие схемы ожидаемой структуре
|
|
||||||
- Объем загруженных данных
|
|
||||||
- Отсутствие дубликатов записей
|
|
||||||
|
|
||||||
### Ограничения учебного стенда
|
|
||||||
|
|
||||||
- **Greenplum** запущен в single-node режиме (для обучения)
|
|
||||||
- В продакшене Greenplum обычно разворачивают кластером на нескольких серверах
|
|
||||||
- Используется Greenplum 6 (широко доступная версия), хотя Greenplum 7 предлагает больше возможностей
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🆘 Типичные проблемы и решения
|
|
||||||
|
|
||||||
| Проблема | Решение |
|
|
||||||
|----------|---------|
|
|
||||||
| Airflow UI не открывается | Дождитесь сообщения `Listening at: http://0.0.0.0:8080` в логах (`make logs`) |
|
|
||||||
| Ошибка подключения к Greenplum | Убедитесь, что контейнер `greenplum` стал статусом `healthy` (проверьте `docker compose ps`) |
|
|
||||||
| Нет файла в `./data` после запуска DAG | Проверьте логи задачи `generate_csv`, убедитесь, что `CSV_DIR` смонтирован в docker-compose |
|
|
||||||
| Команда `make` не найдена | Используйте полные команды `docker compose` или установите make |
|
|
||||||
| Greenplum не стартует/падает при старте | Выполните `make down`, затем `make up && make airflow-init` (очищает тома и поднимает заново) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 Структура проекта
|
|
||||||
|
|
||||||
```
|
|
||||||
├── docker-compose.yml # Описание всех сервисов
|
|
||||||
├── .env.example # Шаблон настроек
|
|
||||||
├── Makefile # Удобные команды для работы
|
|
||||||
├── airflow/
|
|
||||||
│ └── dags/ # Файлы workflow (DAG)
|
|
||||||
│ ├── csv_to_greenplum.py
|
|
||||||
│ └── data_quality_greenplum.py
|
|
||||||
└── sql/
|
|
||||||
└── ddl_gp.sql # Создание таблицы в Greenplum
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 Советы для дальнейшего обучения
|
|
||||||
|
|
||||||
1. **Поэкспериментируйте с DAG** — измените параметры генерации данных или размер батча
|
|
||||||
2. **Добавьте свои проверки** — расширьте DAG `data_quality_greenplum.py`
|
|
||||||
3. **Попробуйте другие источники** — замените генератор данных на чтение из файла или API
|
|
||||||
4. **Изучите Airflow deeper** — добавьте зависимости между задачами, настройте расписания
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Тестирование
|
|
||||||
|
|
||||||
- Локальные проверки: `make test` (pytest). Для форматирования — `make fmt`, для проверки — `make lint`.
|
|
||||||
- Пошаговый сценарий с Docker (включая негативные кейсы и reset) — см. `TESTING.md`.
|
|
||||||
|
|
||||||
Удачи в изучении Data Engineering! 🚀
|
|
||||||
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# План тестирования (для студентов)
|
|
||||||
|
|
||||||
Этот документ — пошаговый чек‑лист, как проверить, что всё работает: от «быстрых локальных проверок» до запуска стенда в Docker и просмотра данных в Greenplum. Подходит начинающим: просто выполняйте шаги по порядку.
|
|
||||||
|
|
||||||
Если что‑то пошло не так, смотрите раздел «Быстрый reset» ниже.
|
|
||||||
|
|
||||||
## 1. Быстрая проверка окружения
|
|
||||||
- `uv sync` — подтягиваем Python и зависимости из `pyproject.toml`/`uv.lock`.
|
|
||||||
- Проверяем версию uv: `uv --version` (ожидаем ≥ 0.9).
|
|
||||||
- Убедитесь, что `docker compose version` доступна и Docker запущен.
|
|
||||||
|
|
||||||
## 2. Локальные автоматические проверки (без Docker)
|
|
||||||
- `make test` — короткие unit-тесты (`tests/test_greenplum_helpers.py`, `tests/test_dags_smoke.py`).
|
|
||||||
- Smoke-тесты DAG автоматически `skip`, если Airflow не установлен в venv, поэтому прогонится за миллисекунды.
|
|
||||||
- `make lint` — black/isort в режиме проверки. Сейчас упадёт из‑за форматирования DAG-файлов.
|
|
||||||
- `make fmt` — автоисправление форматирования; после этого `make lint` должен пройти.
|
|
||||||
- (опционально) `uv run pytest -q -k dags_smoke` — только DAG smoke.
|
|
||||||
|
|
||||||
## 3. Подготовка Docker-стенда
|
|
||||||
- `cp .env.example .env` (если файла ещё нет) и проверьте переменные:
|
|
||||||
- `GP_PORT` не конфликтует с локальным PostgreSQL.
|
|
||||||
- `GP_USE_AIRFLOW_CONN=true` при желании использовать Airflow Connection; `false` — fallback на ENV.
|
|
||||||
- `make up` — поднимаем все сервисы. Важно дождаться статуса `healthy` у `pgmeta` и `greenplum` (`docker compose ps`).
|
|
||||||
- `make airflow-init` — миграции мета-БД и создание пользователя Airflow; занимает ~1–2 минуты.
|
|
||||||
- `make logs` — следим, пока webserver и scheduler не перейдут в рабочее состояние (`Listening at: http://0.0.0.0:8080`).
|
|
||||||
|
|
||||||
## 4. Smoke тесты DAG в Airflow UI
|
|
||||||
1. Открыть http://localhost:8080 (admin/admin).
|
|
||||||
2. DAG `csv_to_greenplum`:
|
|
||||||
- Включить переключатель.
|
|
||||||
- Нажать «Trigger DAG».
|
|
||||||
- Контроль: все таски Success, в `data/` появился CSV, в логах `load_csv_to_greenplum` видно `INSERT`.
|
|
||||||
- В Greenplum (см. п.5) убедиться в наличии строк `(SELECT COUNT(*) ...)`.
|
|
||||||
3. DAG `greenplum_data_quality`:
|
|
||||||
- Запустить вручную после первого DAG.
|
|
||||||
- Проверить, что все 5 задач Success и логи содержат `Проверка пройдена`.
|
|
||||||
|
|
||||||
## 5. Проверка данных в Greenplum
|
|
||||||
- `make gp-psql` — запустить psql в контейнере от имени `gpadmin`.
|
|
||||||
- Команды внутри psql:
|
|
||||||
- `\dt public.*` — таблицы схему public.
|
|
||||||
- `SELECT COUNT(*) FROM public.orders;` — оценка объёма.
|
|
||||||
- `SELECT * FROM public.orders LIMIT 5;` — визуальная проверка.
|
|
||||||
- `SELECT order_id FROM public.orders GROUP BY 1 HAVING COUNT(*) > 1;` — поиск дублей.
|
|
||||||
- Завершить `\q`.
|
|
||||||
|
|
||||||
## 6. Негативные сценарии и fallback
|
|
||||||
- **Пустая таблица**: запустить `greenplum_data_quality` до `csv_to_greenplum`. Ожидается ошибка на таске `check_orders_has_rows`.
|
|
||||||
- **Проблемы с подключением**: временно изменить `GP_HOST` или `GP_PORT` на несуществующий, перезапустить `make up`, убедиться, что DAG падает с понятной ошибкой (`psycopg2.OperationalError`).
|
|
||||||
- **Fallback без Airflow Connection**: установить `GP_USE_AIRFLOW_CONN=false`, перезапустить стек (`make down && make up && make airflow-init`), удостовериться, что загрузка и DQ работают через ENV.
|
|
||||||
- **Дубликаты**: дважды вызвать `csv_to_greenplum` — ожидаем, что количество строк в `public.orders` не увеличится на размер CSV, а DAG `greenplum_data_quality` не найдёт дублей.
|
|
||||||
|
|
||||||
## 7. Быстрый reset (если «что-то сломалось»)
|
|
||||||
- Перезапустить стенд с очисткой данных:
|
|
||||||
- `make down` — остановит контейнеры и удалит тома.
|
|
||||||
- `make up && make airflow-init` — заново поднимет всё и проинициализирует Airflow.
|
|
||||||
- Иногда Greenplum не стартует после «грязных» остановок (из‑за старых внутренних файлов). Лечение: всегда делайте `make down` перед повторным `make up`.
|
|
||||||
|
|
||||||
## 8. Снятие метрик и мониторинг
|
|
||||||
- Контейнеры: `docker compose ps`, `docker stats` (по желанию).
|
|
||||||
- Логи задач: в Airflow UI → конкретный таск → Log.
|
|
||||||
- Хостовые CSV: каталог `data/` (можно открыть любой файл и убедиться в структуре).
|
|
||||||
|
|
||||||
## 9. Завершение работы
|
|
||||||
- `make down` — выключает сервисы и удаляет тома (перезапишет данные в Greenplum!).
|
|
||||||
- При необходимости сохранить данные: скопировать CSV из `data/` и дампы из контейнера до `make down`.
|
|
||||||
|
|
||||||
## Текущий статус (пример успешного прогона)
|
|
||||||
- `uv run pytest -q` — 11 passed, 2 smoke-теста DAG пропущены (Airflow не установлен в venv).
|
|
||||||
- `make lint` — падает, потому что `airflow/dags/*.py` не отформатированы black/isort. После `make fmt` проблема уйдёт.
|
|
||||||
- Docker-стенд не запускался в рамках этой сессии; ожидается, что инструкции выше обеспечат полноценную проверку.
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
import pandas as pd
|
|
||||||
from airflow import DAG
|
|
||||||
from airflow.operators.python import PythonOperator
|
|
||||||
from helpers.greenplum import get_gp_conn
|
|
||||||
|
|
||||||
CSV_DIR = Path(os.getenv("CSV_DIR", "/opt/airflow/data"))
|
|
||||||
CSV_ROWS = int(os.getenv("CSV_ROWS", "1000"))
|
|
||||||
|
|
||||||
|
|
||||||
def _create_table() -> None:
|
|
||||||
"""Создаёт таблицу public.orders, если она ещё не существует."""
|
|
||||||
ddl = """
|
|
||||||
CREATE TABLE IF NOT EXISTS public.orders (
|
|
||||||
order_id BIGINT,
|
|
||||||
order_ts TIMESTAMP NOT NULL,
|
|
||||||
customer_id BIGINT NOT NULL,
|
|
||||||
amount NUMERIC(12,2) NOT NULL
|
|
||||||
)
|
|
||||||
WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1)
|
|
||||||
DISTRIBUTED BY (order_id);
|
|
||||||
"""
|
|
||||||
with get_gp_conn() as conn, conn.cursor() as cur:
|
|
||||||
cur.execute(ddl)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_csv(rows: int, csv_dir: Path) -> str:
|
|
||||||
"""Генерирует CSV c заказами с помощью pandas и сохраняет на диск."""
|
|
||||||
csv_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
||||||
csv_path = csv_dir / f"orders_{timestamp}.csv"
|
|
||||||
|
|
||||||
# Генерируем данные в pandas-стиле
|
|
||||||
base_order_id = int(datetime.utcnow().timestamp() * 1_000)
|
|
||||||
|
|
||||||
# Создаём DataFrame с использованием pandas методов
|
|
||||||
df = pd.DataFrame({
|
|
||||||
# Уникальные order_id начиная с базового значения
|
|
||||||
"order_id": pd.Series(range(base_order_id, base_order_id + rows), dtype="int64"),
|
|
||||||
|
|
||||||
# Временные метки с интервалом в 1 секунду в обратном порядке
|
|
||||||
"order_ts": pd.date_range(
|
|
||||||
end=datetime.utcnow(),
|
|
||||||
periods=rows,
|
|
||||||
freq="1S"
|
|
||||||
).sort_values(ascending=False),
|
|
||||||
|
|
||||||
# Случайные customer_id от 1 до 1000
|
|
||||||
"customer_id": pd.Series(
|
|
||||||
random.choices(range(1, 1001), k=rows),
|
|
||||||
dtype="int64"
|
|
||||||
),
|
|
||||||
|
|
||||||
# Случайные суммы от 10 до 500 с округлением до 2 знаков
|
|
||||||
"amount": pd.Series(
|
|
||||||
[round(random.uniform(10, 500), 2) for _ in range(rows)],
|
|
||||||
dtype="float64"
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
# Сохраняем CSV без индекса
|
|
||||||
df.to_csv(csv_path, index=False)
|
|
||||||
logging.info("CSV сохранён: %s (строк: %s)", csv_path, len(df))
|
|
||||||
return str(csv_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _preview_csv(csv_path: str, sample_rows: int = 5) -> None:
|
|
||||||
"""Отображает предпросмотр CSV через pandas (head и describe)."""
|
|
||||||
df = pd.read_csv(csv_path)
|
|
||||||
df["order_ts"] = pd.to_datetime(df["order_ts"], errors="coerce")
|
|
||||||
logging.info("Первые %s строк:\n%s", sample_rows, df.head(sample_rows).to_string(index=False))
|
|
||||||
numeric_summary = df.describe(include="number")
|
|
||||||
logging.info("Числовая статистика:\n%s", numeric_summary.to_string())
|
|
||||||
if df["order_ts"].notna().any():
|
|
||||||
logging.info(
|
|
||||||
"Диапазон order_ts: %s → %s",
|
|
||||||
df["order_ts"].min().isoformat(),
|
|
||||||
df["order_ts"].max().isoformat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_csv(csv_path: str) -> None:
|
|
||||||
"""Загружает CSV в Greenplum через временную таблицу и anti-join."""
|
|
||||||
csv_file = Path(csv_path)
|
|
||||||
if not csv_file.exists():
|
|
||||||
raise FileNotFoundError(f"CSV не найден: {csv_file}")
|
|
||||||
|
|
||||||
with get_gp_conn() as conn, conn.cursor() as cur, csv_file.open("r", encoding="utf-8") as f:
|
|
||||||
cur.execute("CREATE TEMP TABLE tmp_orders (LIKE public.orders INCLUDING DEFAULTS) ON COMMIT DROP;")
|
|
||||||
cur.copy_expert(
|
|
||||||
"COPY tmp_orders (order_id, order_ts, customer_id, amount) FROM STDIN WITH CSV HEADER",
|
|
||||||
f,
|
|
||||||
)
|
|
||||||
|
|
||||||
cur.execute("SELECT COUNT(*) FROM tmp_orders")
|
|
||||||
tmp_rows = cur.fetchone()[0]
|
|
||||||
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO public.orders(order_id, order_ts, customer_id, amount)
|
|
||||||
SELECT t.order_id, t.order_ts, t.customer_id, t.amount
|
|
||||||
FROM tmp_orders t
|
|
||||||
LEFT JOIN public.orders o ON o.order_id = t.order_id
|
|
||||||
WHERE o.order_id IS NULL
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
inserted = cur.rowcount if cur.rowcount != -1 else 0
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
logging.info("Загружено строк: %s (прочитано из CSV: %s)", inserted, tmp_rows)
|
|
||||||
|
|
||||||
|
|
||||||
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
|
|
||||||
|
|
||||||
with DAG(
|
|
||||||
dag_id="csv_to_greenplum",
|
|
||||||
start_date=datetime(2024, 1, 1),
|
|
||||||
schedule=None,
|
|
||||||
catchup=False,
|
|
||||||
default_args=default_args,
|
|
||||||
tags=["demo", "greenplum", "csv"],
|
|
||||||
) as dag:
|
|
||||||
create_table = PythonOperator(
|
|
||||||
task_id="create_orders_table",
|
|
||||||
python_callable=_create_table,
|
|
||||||
)
|
|
||||||
|
|
||||||
generate_csv = PythonOperator(
|
|
||||||
task_id="generate_csv",
|
|
||||||
python_callable=_generate_csv,
|
|
||||||
op_kwargs={"rows": CSV_ROWS, "csv_dir": CSV_DIR},
|
|
||||||
)
|
|
||||||
|
|
||||||
preview_csv = PythonOperator(
|
|
||||||
task_id="preview_csv",
|
|
||||||
python_callable=_preview_csv,
|
|
||||||
op_kwargs={
|
|
||||||
"csv_path": "{{ ti.xcom_pull(task_ids='generate_csv') }}",
|
|
||||||
"sample_rows": 5,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
load_csv = PythonOperator(
|
|
||||||
task_id="load_csv_to_greenplum",
|
|
||||||
python_callable=_load_csv,
|
|
||||||
op_kwargs={
|
|
||||||
"csv_path": "{{ ti.xcom_pull(task_ids='generate_csv') }}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
create_table >> generate_csv >> preview_csv >> load_csv
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
from airflow import DAG
|
|
||||||
from airflow.operators.python import PythonOperator
|
|
||||||
|
|
||||||
from helpers.greenplum import (
|
|
||||||
assert_orders_have_rows,
|
|
||||||
assert_orders_no_duplicates,
|
|
||||||
assert_orders_schema,
|
|
||||||
assert_orders_table_exists,
|
|
||||||
get_gp_conn,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_check(check_callable):
|
|
||||||
"""
|
|
||||||
Оборачивает проверку качества данных в контекст подключения к Greenplum.
|
|
||||||
|
|
||||||
Этот DAG предназначен для автоматической проверки качества данных в таблице orders:
|
|
||||||
1. Проверяет существование таблицы
|
|
||||||
2. Проверяет соответствие схемы
|
|
||||||
3. Проверяет наличие данных
|
|
||||||
4. Проверяет отсутствие дубликатов
|
|
||||||
|
|
||||||
Args:
|
|
||||||
check_callable: Функция проверки, принимающая подключение к БД
|
|
||||||
"""
|
|
||||||
# Получаем имя функции для логов
|
|
||||||
check_name = check_callable.__name__.replace("assert_", "")
|
|
||||||
logging.info("🚀 Запуск проверки: %s", check_name)
|
|
||||||
|
|
||||||
with get_gp_conn() as conn:
|
|
||||||
check_callable(conn)
|
|
||||||
|
|
||||||
logging.info("✅ Проверка пройдена: %s", check_name)
|
|
||||||
|
|
||||||
|
|
||||||
def _log_dq_summary():
|
|
||||||
"""
|
|
||||||
Логирует итоговую сводку по качеству данных.
|
|
||||||
Эта задача выполняется после всех проверок и показывает общий результат.
|
|
||||||
"""
|
|
||||||
logging.info("🎉 Все проверки качества данных пройдены успешно!")
|
|
||||||
logging.info("📊 Качество данных в таблице orders соответствует требованиям.")
|
|
||||||
|
|
||||||
|
|
||||||
default_args = {"owner": "airflow", "retries": 1, "retry_delay": timedelta(seconds=30)}
|
|
||||||
|
|
||||||
with DAG(
|
|
||||||
dag_id="greenplum_data_quality",
|
|
||||||
start_date=datetime(2024, 1, 1),
|
|
||||||
schedule=None,
|
|
||||||
catchup=False,
|
|
||||||
default_args=default_args,
|
|
||||||
tags=["demo", "greenplum", "quality"],
|
|
||||||
description="Автоматизированные проверки качества данных в Greenplum",
|
|
||||||
) as dag:
|
|
||||||
# Задача 1: Проверка существования таблицы
|
|
||||||
check_exists = PythonOperator(
|
|
||||||
task_id="check_orders_table_exists",
|
|
||||||
python_callable=_run_check,
|
|
||||||
op_args=[assert_orders_table_exists],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Задача 2: Проверка соответствия схемы таблицы
|
|
||||||
check_schema = PythonOperator(
|
|
||||||
task_id="check_orders_schema",
|
|
||||||
python_callable=_run_check,
|
|
||||||
op_args=[assert_orders_schema],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Задача 3: Проверка наличия данных
|
|
||||||
check_has_rows = PythonOperator(
|
|
||||||
task_id="check_orders_has_rows",
|
|
||||||
python_callable=_run_check,
|
|
||||||
op_args=[assert_orders_have_rows],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Задача 4: Проверка отсутствия дубликатов
|
|
||||||
check_no_duplicates = PythonOperator(
|
|
||||||
task_id="check_order_duplicates",
|
|
||||||
python_callable=_run_check,
|
|
||||||
op_args=[assert_orders_no_duplicates],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Задача 5: Итоговая сводка
|
|
||||||
dq_summary = PythonOperator(
|
|
||||||
task_id="data_quality_summary",
|
|
||||||
python_callable=_log_dq_summary,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Определяем последовательность выполнения задач
|
|
||||||
check_exists >> check_schema >> check_has_rows >> check_no_duplicates >> dq_summary
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from typing import List, Sequence, Tuple
|
|
||||||
|
|
||||||
import psycopg2
|
|
||||||
|
|
||||||
# Настройки для подключения к Greenplum. По умолчанию используем Airflow Connection,
|
|
||||||
# но при проблемах можно переключиться на ENV-подключение, установив GP_USE_AIRFLOW_CONN=false.
|
|
||||||
GP_CONN_ID = os.getenv("GP_CONN_ID", "greenplum_conn")
|
|
||||||
GP_USE_AIRFLOW_CONN = os.getenv("GP_USE_AIRFLOW_CONN", "true").lower() in ("1", "true", "yes")
|
|
||||||
|
|
||||||
# Ожидаемая схема таблицы orders для проверки качества данных
|
|
||||||
EXPECTED_ORDERS_SCHEMA: List[Tuple[str, str]] = [
|
|
||||||
("order_id", "bigint"),
|
|
||||||
("order_ts", "timestamp without time zone"),
|
|
||||||
("customer_id", "bigint"),
|
|
||||||
("amount", "numeric"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_gp_conn():
|
|
||||||
"""
|
|
||||||
Возвращает psycopg2 connection к Greenplum.
|
|
||||||
|
|
||||||
Приоритет подключения:
|
|
||||||
1. Через Airflow Connection (если настроено и доступно)
|
|
||||||
2. Прямое подключение по переменным окружения (фоллбек)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
psycopg2 connection object
|
|
||||||
"""
|
|
||||||
if GP_USE_AIRFLOW_CONN:
|
|
||||||
try:
|
|
||||||
from airflow.providers.postgres.hooks.postgres import PostgresHook
|
|
||||||
|
|
||||||
hook = PostgresHook(postgres_conn_id=GP_CONN_ID)
|
|
||||||
conn = hook.get_conn()
|
|
||||||
logging.info("✅ Подключение через Airflow Connection успешно")
|
|
||||||
return conn
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning("⚠️ Не удалось подключиться через Airflow Connection: %s", e)
|
|
||||||
logging.info("🔄 Переключаемся на прямое подключение по ENV переменным")
|
|
||||||
# Фоллбек на прямое подключение по переменным окружения.
|
|
||||||
|
|
||||||
# Прямое подключение по переменным окружения
|
|
||||||
conn_params = {
|
|
||||||
"dbname": os.getenv("GP_DB", "gpadmin"),
|
|
||||||
"user": os.getenv("GP_USER", "gpadmin"),
|
|
||||||
"password": os.getenv("GP_PASSWORD", ""),
|
|
||||||
"host": os.getenv("GP_HOST", "greenplum"),
|
|
||||||
"port": int(os.getenv("GP_PORT", "5432")),
|
|
||||||
}
|
|
||||||
logging.info("🔗 Подключение к Greenplum: %s:%s", conn_params["host"], conn_params["port"])
|
|
||||||
return psycopg2.connect(**conn_params)
|
|
||||||
|
|
||||||
|
|
||||||
def assert_orders_table_exists(conn) -> None:
|
|
||||||
"""
|
|
||||||
Проверяет наличие таблицы orders в схеме public.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Подключение к Greenplum
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: Если таблица не найдена
|
|
||||||
"""
|
|
||||||
logging.info("🔍 Проверяем существование таблицы public.orders...")
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT 1
|
|
||||||
FROM pg_catalog.pg_tables
|
|
||||||
WHERE schemaname = 'public' AND tablename = 'orders'
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
if cur.fetchone() is None:
|
|
||||||
raise ValueError("❌ Таблица public.orders не найдена; запусти DAG csv_to_greenplum.")
|
|
||||||
logging.info("✅ Таблица public.orders существует")
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_orders_schema(conn) -> Sequence[Tuple[str, str]]:
|
|
||||||
"""
|
|
||||||
Получает схему таблицы orders из information_schema.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Подключение к Greenplum
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Список кортежей (имя_колонки, тип_данных)
|
|
||||||
"""
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT column_name, data_type
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public' AND table_name = 'orders'
|
|
||||||
ORDER BY ordinal_position
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return cur.fetchall()
|
|
||||||
|
|
||||||
|
|
||||||
def assert_orders_schema(conn) -> None:
|
|
||||||
"""
|
|
||||||
Проверяет, что схема таблицы orders соответствует ожидаемой.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Подключение к Greenplum
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: Если схема не соответствует ожидаемой
|
|
||||||
"""
|
|
||||||
logging.info("📋 Проверяем схему таблицы orders...")
|
|
||||||
schema = fetch_orders_schema(conn)
|
|
||||||
logging.info("📊 Фактическая схема: %s", list(schema))
|
|
||||||
logging.info("📊 Ожидаемая схема: %s", EXPECTED_ORDERS_SCHEMA)
|
|
||||||
|
|
||||||
if list(schema) != EXPECTED_ORDERS_SCHEMA:
|
|
||||||
raise ValueError(f"❌ Неожиданная схема orders: {schema}. Ожидали {EXPECTED_ORDERS_SCHEMA}.")
|
|
||||||
logging.info("✅ Схема таблицы orders соответствует ожиданиям")
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_orders_count(conn) -> int:
|
|
||||||
"""
|
|
||||||
Получает количество строк в таблице orders.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Подключение к Greenplum
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Количество строк в таблице
|
|
||||||
"""
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute("SELECT COUNT(*) FROM public.orders")
|
|
||||||
return cur.fetchone()[0]
|
|
||||||
|
|
||||||
|
|
||||||
def assert_orders_have_rows(conn) -> None:
|
|
||||||
"""
|
|
||||||
Проверяет, что таблица orders не пустая.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Подключение к Greenplum
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: Если таблица пустая
|
|
||||||
"""
|
|
||||||
logging.info("📊 Проверяем наличие данных в таблице orders...")
|
|
||||||
row_count = fetch_orders_count(conn)
|
|
||||||
logging.info("📈 Количество строк в orders: %s", row_count)
|
|
||||||
|
|
||||||
if row_count <= 0:
|
|
||||||
raise ValueError("❌ Таблица public.orders пустая — запусти DAG csv_to_greenplum перед проверкой.")
|
|
||||||
logging.info("✅ Таблица orders содержит данные (%s строк)", row_count)
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_orders_duplicates(conn) -> int:
|
|
||||||
"""
|
|
||||||
Подсчитывает количество дубликатов по order_id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Подключение к Greenplum
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Количество дублирующихся order_id
|
|
||||||
"""
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*) FROM (
|
|
||||||
SELECT order_id
|
|
||||||
FROM public.orders
|
|
||||||
GROUP BY order_id
|
|
||||||
HAVING COUNT(*) > 1
|
|
||||||
) d
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
return cur.fetchone()[0]
|
|
||||||
|
|
||||||
|
|
||||||
def assert_orders_no_duplicates(conn) -> None:
|
|
||||||
"""
|
|
||||||
Проверяет, что в таблице нет дублей по order_id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
conn: Подключение к Greenplum
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: Если обнаружены дубликаты
|
|
||||||
"""
|
|
||||||
logging.info("🔍 Проверяем отсутствие дубликатов по order_id...")
|
|
||||||
duplicates = fetch_orders_duplicates(conn)
|
|
||||||
logging.info("📊 Найдено дубликатов: %s", duplicates)
|
|
||||||
|
|
||||||
if duplicates:
|
|
||||||
raise ValueError(f"❌ Обнаружены дубли по order_id ({duplicates} шт.) — проверь загрузку данных.")
|
|
||||||
logging.info("✅ Дубликаты не обнаружены")
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
psycopg2-binary==2.9.9
|
|
||||||
pandas==2.1.4
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
services:
|
|
||||||
# Postgres только для Airflow метаданных
|
|
||||||
pgmeta:
|
|
||||||
image: postgres:16
|
|
||||||
# container_name: gp_pgmeta
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${PG_USER}
|
|
||||||
POSTGRES_PASSWORD: ${PG_PASSWORD}
|
|
||||||
POSTGRES_DB: ${PG_DB}
|
|
||||||
ports:
|
|
||||||
- "5433:5432"
|
|
||||||
volumes:
|
|
||||||
- pgmeta:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${PG_USER} -d ${PG_DB}"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
|
|
||||||
greenplum:
|
|
||||||
image: woblerr/greenplum:6.27.1
|
|
||||||
# container_name: gp_single
|
|
||||||
# hostname: gpdbsne
|
|
||||||
environment:
|
|
||||||
GREENPLUM_USER: ${GP_USER:-gpadmin}
|
|
||||||
GREENPLUM_PASSWORD: ${GP_PASSWORD:-gpadmin}
|
|
||||||
GREENPLUM_DATABASE_NAME: ${GP_DB:-gpadmin}
|
|
||||||
# GP_PORT: ${GP_PORT:-5432}
|
|
||||||
# Порты: внешний 5432
|
|
||||||
ports:
|
|
||||||
- "${GP_PORT}:5432"
|
|
||||||
volumes:
|
|
||||||
- ./sql:/sql:ro
|
|
||||||
- greenplum_data:/data
|
|
||||||
# Простая проверка доступности: psql откликается
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U ${GP_USER:-gpadmin} -d ${GP_DB:-gpadmin} || echo 1"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 30
|
|
||||||
|
|
||||||
airflow-webserver:
|
|
||||||
image: apache/airflow:2.9.2
|
|
||||||
container_name: gp_airflow_web
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
|
|
||||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
|
|
||||||
command: >
|
|
||||||
bash -lc "pip install --no-cache-dir -r /opt/airflow/requirements.txt &&
|
|
||||||
airflow webserver"
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- ./airflow/dags:/opt/airflow/dags
|
|
||||||
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
|
||||||
- ./data:/opt/airflow/data
|
|
||||||
depends_on:
|
|
||||||
pgmeta:
|
|
||||||
condition: service_healthy
|
|
||||||
greenplum:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
airflow-scheduler:
|
|
||||||
image: apache/airflow:2.9.2
|
|
||||||
container_name: gp_airflow_sch
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
|
|
||||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
|
|
||||||
command: >
|
|
||||||
bash -lc "pip install --no-cache-dir -r /opt/airflow/requirements.txt &&
|
|
||||||
airflow scheduler"
|
|
||||||
volumes:
|
|
||||||
- ./airflow/dags:/opt/airflow/dags
|
|
||||||
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
|
||||||
- ./data:/opt/airflow/data
|
|
||||||
depends_on:
|
|
||||||
pgmeta:
|
|
||||||
condition: service_healthy
|
|
||||||
greenplum:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
airflow-init:
|
|
||||||
image: apache/airflow:2.9.2
|
|
||||||
# container_name: gp_airflow_init
|
|
||||||
env_file: .env
|
|
||||||
environment:
|
|
||||||
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
|
|
||||||
AIRFLOW__DATABASE__SQL_ALCHEMY_CONN: postgresql+psycopg2://${PG_USER}:${PG_PASSWORD}@pgmeta:5432/${PG_DB}
|
|
||||||
volumes:
|
|
||||||
- ./airflow/dags:/opt/airflow/dags
|
|
||||||
- ./airflow/requirements.txt:/opt/airflow/requirements.txt
|
|
||||||
- ./data:/opt/airflow/data
|
|
||||||
command: >
|
|
||||||
bash -lc "
|
|
||||||
set -e;
|
|
||||||
pip install --no-cache-dir -r /opt/airflow/requirements.txt;
|
|
||||||
# Дожидаемся готовности БД ретрая миграции
|
|
||||||
for i in {1..30}; do
|
|
||||||
airflow db migrate && break || echo 'waiting for pgmeta' && sleep 3;
|
|
||||||
done;
|
|
||||||
# Создаём админа; при повторном запуске не падаем
|
|
||||||
airflow users create --username ${AIRFLOW_USER} --password ${AIRFLOW_PASSWORD} --firstname Admin --lastname User --role Admin --email admin@example.org || true
|
|
||||||
"
|
|
||||||
depends_on:
|
|
||||||
pgmeta:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
pgmeta:
|
|
||||||
greenplum_data:
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[project]
|
|
||||||
name = "airflow-greenplum"
|
|
||||||
version = "0.1.0"
|
|
||||||
requires-python = ">=3.11,<3.12"
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[dependency-groups]
|
|
||||||
dev = [
|
|
||||||
"apache-airflow==2.9.2",
|
|
||||||
"apache-airflow-providers-postgres==5.11.1",
|
|
||||||
"black==24.4.2",
|
|
||||||
"isort>=7.0.0",
|
|
||||||
"pandas==2.1.4",
|
|
||||||
"psycopg2-binary==2.9.9",
|
|
||||||
"pytest==7.4.4",
|
|
||||||
]
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
-- Greenplum DDL (GPDB 6 совместимо)
|
|
||||||
-- Колонночная таблица (append-optimized) и распределение по ключу.
|
|
||||||
-- Внимание: append-optimized таблицы не поддерживают UNIQUE/PRIMARY KEY,
|
|
||||||
-- поэтому контроль дублей выполняем в DAG при загрузке.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.orders (
|
|
||||||
order_id BIGINT,
|
|
||||||
order_ts TIMESTAMP NOT NULL,
|
|
||||||
customer_id BIGINT NOT NULL,
|
|
||||||
amount NUMERIC(12,2) NOT NULL
|
|
||||||
)
|
|
||||||
WITH (appendonly=true, orientation=row, compresstype=zlib, compresslevel=1)
|
|
||||||
DISTRIBUTED BY (order_id);
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from types import ModuleType
|
|
||||||
from typing import Type
|
|
||||||
|
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
if str(PROJECT_ROOT) not in sys.path:
|
|
||||||
sys.path.append(str(PROJECT_ROOT))
|
|
||||||
|
|
||||||
if "airflow" not in sys.modules:
|
|
||||||
airflow_module = ModuleType("airflow")
|
|
||||||
airflow_module.__path__ = [str(PROJECT_ROOT / "airflow")]
|
|
||||||
sys.modules["airflow"] = airflow_module
|
|
||||||
|
|
||||||
providers_module = ModuleType("airflow.providers")
|
|
||||||
providers_module.__path__ = []
|
|
||||||
sys.modules["airflow.providers"] = providers_module
|
|
||||||
airflow_module.providers = providers_module
|
|
||||||
|
|
||||||
postgres_module = ModuleType("airflow.providers.postgres")
|
|
||||||
postgres_module.__path__ = []
|
|
||||||
sys.modules["airflow.providers.postgres"] = postgres_module
|
|
||||||
providers_module.postgres = postgres_module
|
|
||||||
|
|
||||||
hooks_module = ModuleType("airflow.providers.postgres.hooks")
|
|
||||||
hooks_module.__path__ = []
|
|
||||||
sys.modules["airflow.providers.postgres.hooks"] = hooks_module
|
|
||||||
postgres_module.hooks = hooks_module
|
|
||||||
|
|
||||||
if "psycopg2" not in sys.modules:
|
|
||||||
psycopg2_stub = ModuleType("psycopg2")
|
|
||||||
psycopg2_stub.connect = lambda **_: None # type: ignore[assignment]
|
|
||||||
sys.modules["psycopg2"] = psycopg2_stub
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_stub_module(full_name: str) -> ModuleType:
|
|
||||||
"""
|
|
||||||
Ensure that module placeholders exist for a dotted path and return leaf module.
|
|
||||||
"""
|
|
||||||
parts = full_name.split(".")
|
|
||||||
module: ModuleType | None = None
|
|
||||||
path = ""
|
|
||||||
for part in parts:
|
|
||||||
path = f"{path}.{part}" if path else part
|
|
||||||
if path not in sys.modules:
|
|
||||||
new_module = ModuleType(path)
|
|
||||||
if module is not None:
|
|
||||||
setattr(module, part, new_module)
|
|
||||||
sys.modules[path] = new_module
|
|
||||||
module = new_module
|
|
||||||
else:
|
|
||||||
module = sys.modules[path]
|
|
||||||
assert isinstance(module, ModuleType)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
def patch_postgres_hook(monkeypatch, hook_cls: Type) -> None:
|
|
||||||
"""
|
|
||||||
Patch PostgresHook so that helpers.greenplum can be exercised without real Airflow.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
module = importlib.import_module("airflow.providers.postgres.hooks.postgres")
|
|
||||||
except ModuleNotFoundError:
|
|
||||||
module = _ensure_stub_module("airflow.providers.postgres.hooks.postgres")
|
|
||||||
monkeypatch.setattr(module, "PostgresHook", hook_cls, raising=False)
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def _airflow_available() -> bool:
|
|
||||||
try:
|
|
||||||
af = importlib.import_module("airflow")
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
# Real Airflow exposes DAG at top-level
|
|
||||||
return hasattr(af, "DAG")
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.skipif(not _airflow_available(), reason="Airflow is not installed for DAG smoke tests")
|
|
||||||
|
|
||||||
|
|
||||||
def _load_dag(module_name: str):
|
|
||||||
mod = importlib.import_module(module_name)
|
|
||||||
assert hasattr(mod, "dag"), f"{module_name} must expose variable 'dag'"
|
|
||||||
return getattr(mod, "dag")
|
|
||||||
|
|
||||||
|
|
||||||
def test_csv_to_greenplum_dag_structure():
|
|
||||||
dag = _load_dag("airflow.dags.csv_to_greenplum")
|
|
||||||
|
|
||||||
# tasks
|
|
||||||
expected_tasks = {
|
|
||||||
"create_orders_table",
|
|
||||||
"generate_csv",
|
|
||||||
"preview_csv",
|
|
||||||
"load_csv_to_greenplum",
|
|
||||||
}
|
|
||||||
assert expected_tasks.issubset(dag.task_dict.keys())
|
|
||||||
|
|
||||||
# linear dependencies
|
|
||||||
t1 = dag.get_task("create_orders_table")
|
|
||||||
t2 = dag.get_task("generate_csv")
|
|
||||||
t3 = dag.get_task("preview_csv")
|
|
||||||
t4 = dag.get_task("load_csv_to_greenplum")
|
|
||||||
|
|
||||||
assert t2 in t1.get_direct_relatives("downstream")
|
|
||||||
assert t3 in t2.get_direct_relatives("downstream")
|
|
||||||
assert t4 in t3.get_direct_relatives("downstream")
|
|
||||||
|
|
||||||
|
|
||||||
def test_data_quality_greenplum_dag_structure():
|
|
||||||
dag = _load_dag("airflow.dags.data_quality_greenplum")
|
|
||||||
|
|
||||||
expected_tasks = {
|
|
||||||
"check_orders_table_exists",
|
|
||||||
"check_orders_schema",
|
|
||||||
"check_orders_has_rows",
|
|
||||||
"check_order_duplicates",
|
|
||||||
"data_quality_summary",
|
|
||||||
}
|
|
||||||
assert expected_tasks.issubset(dag.task_dict.keys())
|
|
||||||
|
|
||||||
e = dag.get_task("check_orders_table_exists")
|
|
||||||
s = dag.get_task("check_orders_schema")
|
|
||||||
h = dag.get_task("check_orders_has_rows")
|
|
||||||
d = dag.get_task("check_order_duplicates")
|
|
||||||
q = dag.get_task("data_quality_summary")
|
|
||||||
|
|
||||||
assert s in e.get_direct_relatives("downstream")
|
|
||||||
assert h in s.get_direct_relatives("downstream")
|
|
||||||
assert d in h.get_direct_relatives("downstream")
|
|
||||||
assert q in d.get_direct_relatives("downstream")
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, List, Sequence
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import airflow.dags.helpers.greenplum as greenplum
|
|
||||||
from tests.conftest import patch_postgres_hook
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FakeCursor:
|
|
||||||
fetchone_value: Any = None
|
|
||||||
fetchall_value: Sequence[Any] | None = None
|
|
||||||
rowcount: int | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
self.queries: List[Any] = []
|
|
||||||
|
|
||||||
def execute(self, query: str, params: Any | None = None) -> None:
|
|
||||||
self.queries.append((query, params))
|
|
||||||
|
|
||||||
def fetchone(self) -> Any:
|
|
||||||
return self.fetchone_value
|
|
||||||
|
|
||||||
def fetchall(self) -> Sequence[Any] | None:
|
|
||||||
return self.fetchall_value
|
|
||||||
|
|
||||||
def __enter__(self) -> FakeCursor:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc, tb) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class FakeConn:
|
|
||||||
def __init__(self, cursors: Sequence[FakeCursor]) -> None:
|
|
||||||
self._cursors = list(cursors)
|
|
||||||
self._index = 0
|
|
||||||
self.commits = 0
|
|
||||||
|
|
||||||
def cursor(self) -> FakeCursor:
|
|
||||||
cursor = self._cursors[self._index]
|
|
||||||
self._index += 1
|
|
||||||
return cursor
|
|
||||||
|
|
||||||
def commit(self) -> None:
|
|
||||||
self.commits += 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_gp_conn_uses_airflow_hook(monkeypatch) -> None:
|
|
||||||
class FakeHook:
|
|
||||||
def __init__(self, postgres_conn_id: str) -> None:
|
|
||||||
self.postgres_conn_id = postgres_conn_id
|
|
||||||
|
|
||||||
def get_conn(self) -> str:
|
|
||||||
return "hook_connection"
|
|
||||||
|
|
||||||
patch_postgres_hook(monkeypatch, FakeHook)
|
|
||||||
monkeypatch.setattr(greenplum, "GP_CONN_ID", "demo_conn", raising=False)
|
|
||||||
monkeypatch.setattr(greenplum, "GP_USE_AIRFLOW_CONN", True, raising=False)
|
|
||||||
|
|
||||||
conn = greenplum.get_gp_conn()
|
|
||||||
|
|
||||||
assert conn == "hook_connection"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_gp_conn_fallback_to_psycopg(monkeypatch) -> None:
|
|
||||||
class BrokenHook:
|
|
||||||
def __init__(self, postgres_conn_id: str) -> None:
|
|
||||||
self.postgres_conn_id = postgres_conn_id
|
|
||||||
|
|
||||||
def get_conn(self):
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
|
|
||||||
patch_postgres_hook(monkeypatch, BrokenHook)
|
|
||||||
monkeypatch.setattr(greenplum, "GP_USE_AIRFLOW_CONN", True, raising=False)
|
|
||||||
monkeypatch.setattr(greenplum, "GP_CONN_ID", "demo_conn", raising=False)
|
|
||||||
monkeypatch.setenv("GP_DB", "demo_db")
|
|
||||||
monkeypatch.setenv("GP_USER", "demo_user")
|
|
||||||
monkeypatch.setenv("GP_PASSWORD", "secret")
|
|
||||||
monkeypatch.setenv("GP_HOST", "greenplum-host")
|
|
||||||
monkeypatch.setenv("GP_PORT", "5434")
|
|
||||||
|
|
||||||
captured_kwargs = {}
|
|
||||||
|
|
||||||
def fake_connect(**kwargs):
|
|
||||||
captured_kwargs.update(kwargs)
|
|
||||||
return "psycopg_connection"
|
|
||||||
|
|
||||||
monkeypatch.setattr(greenplum.psycopg2, "connect", fake_connect)
|
|
||||||
|
|
||||||
conn = greenplum.get_gp_conn()
|
|
||||||
|
|
||||||
assert conn == "psycopg_connection"
|
|
||||||
assert captured_kwargs == {
|
|
||||||
"dbname": "demo_db",
|
|
||||||
"user": "demo_user",
|
|
||||||
"password": "secret",
|
|
||||||
"host": "greenplum-host",
|
|
||||||
"port": 5434,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_gp_conn_without_airflow(monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr(greenplum, "GP_USE_AIRFLOW_CONN", False, raising=False)
|
|
||||||
monkeypatch.setenv("GP_DB", "demo_db")
|
|
||||||
monkeypatch.setenv("GP_USER", "demo_user")
|
|
||||||
monkeypatch.setenv("GP_PASSWORD", "secret")
|
|
||||||
monkeypatch.setenv("GP_HOST", "greenplum-host")
|
|
||||||
monkeypatch.setenv("GP_PORT", "5435")
|
|
||||||
|
|
||||||
captured_kwargs = {}
|
|
||||||
|
|
||||||
def fake_connect(**kwargs):
|
|
||||||
captured_kwargs.update(kwargs)
|
|
||||||
return "direct_psycopg"
|
|
||||||
|
|
||||||
monkeypatch.setattr(greenplum.psycopg2, "connect", fake_connect)
|
|
||||||
|
|
||||||
conn = greenplum.get_gp_conn()
|
|
||||||
|
|
||||||
assert conn == "direct_psycopg"
|
|
||||||
assert captured_kwargs["port"] == 5435
|
|
||||||
|
|
||||||
|
|
||||||
def test_assert_orders_table_exists_ok() -> None:
|
|
||||||
conn = FakeConn([FakeCursor(fetchone_value=(1,))])
|
|
||||||
|
|
||||||
greenplum.assert_orders_table_exists(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def test_assert_orders_table_exists_missing() -> None:
|
|
||||||
conn = FakeConn([FakeCursor(fetchone_value=None)])
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
greenplum.assert_orders_table_exists(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def test_assert_orders_schema_ok() -> None:
|
|
||||||
expected = list(greenplum.EXPECTED_ORDERS_SCHEMA)
|
|
||||||
conn = FakeConn([FakeCursor(fetchall_value=expected)])
|
|
||||||
|
|
||||||
greenplum.assert_orders_schema(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def test_assert_orders_schema_mismatch() -> None:
|
|
||||||
conn = FakeConn([FakeCursor(fetchall_value=[("order_id", "bigint")])])
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
greenplum.assert_orders_schema(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def test_assert_orders_have_rows_ok() -> None:
|
|
||||||
conn = FakeConn([FakeCursor(fetchone_value=(5,))])
|
|
||||||
|
|
||||||
greenplum.assert_orders_have_rows(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def test_assert_orders_have_rows_empty() -> None:
|
|
||||||
conn = FakeConn([FakeCursor(fetchone_value=(0,))])
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
greenplum.assert_orders_have_rows(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def test_assert_orders_no_duplicates_ok() -> None:
|
|
||||||
conn = FakeConn([FakeCursor(fetchone_value=(0,))])
|
|
||||||
|
|
||||||
greenplum.assert_orders_no_duplicates(conn)
|
|
||||||
|
|
||||||
|
|
||||||
def test_assert_orders_no_duplicates_detected() -> None:
|
|
||||||
conn = FakeConn([FakeCursor(fetchone_value=(3,))])
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
greenplum.assert_orders_no_duplicates(conn)
|
|
||||||
Generated
-2063
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,651 @@
|
|||||||
|
# **Data Vault 2.0: как собрать хранилище из конструктора**
|
||||||
|
|
||||||
|
## 📚 Оглавление
|
||||||
|
|
||||||
|
1. [Зачем вообще нужен Data Vault?](#1-зачем-вообще-нужен-data-vault)
|
||||||
|
2. [Интуиция: DV как конструктор Lego](#2-интуиция-dv-как-конструктор-lego)
|
||||||
|
3. [Три типа таблиц в Data Vault 2.0](#3-три-типа-таблиц-в-data-vault-20)
|
||||||
|
4. [Типы сателлитов в DV 2.0](#4-типы-сателлитов-в-dv-20)
|
||||||
|
5. [Raw Vault и Business Vault](#5-raw-vault-и-business-vault)
|
||||||
|
6. [Пример: клиент и заказы в DV-стиле](#6-пример-клиент-и-заказы-в-dv-стиле)
|
||||||
|
7. [Как это живёт в пайплайне загрузки](#7-как-это-живёт-в-пайплайне-загрузки)
|
||||||
|
8. [Плюсы и минусы Data Vault](#8-плюсы-и-минусы-data-vault)
|
||||||
|
9. [Когда DV стоит использовать, а когда нет](#9-когда-dv-стоит-использовать-а-когда-нет)
|
||||||
|
10. [Шпаргалка для собеседования](#10-шпаргалка-для-собеседования)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Зачем вообще нужен Data Vault?
|
||||||
|
|
||||||
|
Большинство знакомятся с хранилищами через две модели:
|
||||||
|
|
||||||
|
* **3NF** (Инмон) — нормализованное ядро: много таблиц, строгие связи, минимум дублирования.
|
||||||
|
* **Звезда (Star Schema)** (Кимбалл) — витрины под отчёты: факт + несколько «плоских» измерений.
|
||||||
|
|
||||||
|
Этого хватает для:
|
||||||
|
|
||||||
|
* 2–5 источников,
|
||||||
|
* относительно стабильных схем,
|
||||||
|
* задач типа «сделать отчёт для маркетинга/финансов».
|
||||||
|
|
||||||
|
Проблемы начинаются, когда:
|
||||||
|
|
||||||
|
* источников **становится десяток и больше** (CRM, биллинг, ERP, сайт, мобильное приложение, партнёры, скоринги…);
|
||||||
|
* схемы **постоянно меняются**: добавляются поля, сущности, новые связи;
|
||||||
|
* появляются требования по **аудиту и трассировке** (то есть умению по шагам показать, откуда взялись данные и как они менялись): «покажите, откуда взялся вот этот показатель, по шагам».
|
||||||
|
|
||||||
|
Вот тут обычная 3NF/Звезда начинает скрипеть:
|
||||||
|
|
||||||
|
* любое изменение источника → больно по ядру и витринам;
|
||||||
|
* история размазана по разным местам (где-то SCD, где-то лог-таблицы, где-то вообще нет истории);
|
||||||
|
* добавление нового источника превращается в мини-проект на месяц.
|
||||||
|
|
||||||
|
**Data Vault 2.0** отвечает именно на эту боль:
|
||||||
|
|
||||||
|
> Как сделать так, чтобы **новый источник** → это не «ремонт всего дома», а просто «докрутить ещё один модуль»?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Интуиция: DV как конструктор Lego
|
||||||
|
|
||||||
|
Классическая метафора DV — это **конструктор из трёх типов деталей**:
|
||||||
|
|
||||||
|
* 🔴 **Hub (Хаб)** — *«кто/что это»*
|
||||||
|
Сущности: клиент, заказ, договор, счёт.
|
||||||
|
Внутри: бизнес‑ключ (например, customer_id или contract_number) и техполя.
|
||||||
|
|
||||||
|
* ⚪ **Link (Линк)** — *«как они связаны»*
|
||||||
|
«Клиент сделал заказ», «договор относится к счёту».
|
||||||
|
Внутри: ссылки на хабы и техполя.
|
||||||
|
|
||||||
|
* 🟡 **Satellite (Сателлит)** — *«какие у них свойства и как они менялись»*
|
||||||
|
Атрибуты сущности (имя, email, статус, тариф) плюс история изменений.
|
||||||
|
|
||||||
|
Главная идея:
|
||||||
|
|
||||||
|
> **Идентичность, связи и атрибуты живут отдельно.**
|
||||||
|
> Тогда изменения в одном не ломают другое.
|
||||||
|
|
||||||
|
На картинке это можно показать так:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
HUB_CUSTOMER ||--o{ SAT_CUSTOMER_INFO : имеет_атрибуты
|
||||||
|
HUB_CUSTOMER ||--o{ LINK_ORDER_CUSTOMER : участвует_в
|
||||||
|
HUB_ORDER ||--o{ LINK_ORDER_CUSTOMER : создан
|
||||||
|
LINK_ORDER_CUSTOMER ||--o{ SAT_ORDER_STATUS : имеет_историю_статусов
|
||||||
|
|
||||||
|
HUB_CUSTOMER {
|
||||||
|
varchar customer_bk PK
|
||||||
|
varchar record_source
|
||||||
|
timestamp load_dttm
|
||||||
|
}
|
||||||
|
|
||||||
|
HUB_ORDER {
|
||||||
|
varchar order_id PK
|
||||||
|
varchar record_source
|
||||||
|
timestamp load_dttm
|
||||||
|
}
|
||||||
|
|
||||||
|
LINK_ORDER_CUSTOMER {
|
||||||
|
varchar link_key PK
|
||||||
|
varchar order_id FK
|
||||||
|
varchar customer_bk FK
|
||||||
|
varchar record_source
|
||||||
|
timestamp load_dttm
|
||||||
|
}
|
||||||
|
|
||||||
|
SAT_CUSTOMER_INFO {
|
||||||
|
varchar customer_bk FK
|
||||||
|
varchar hashdiff
|
||||||
|
varchar name
|
||||||
|
varchar email
|
||||||
|
varchar city
|
||||||
|
varchar record_source
|
||||||
|
timestamp load_dttm
|
||||||
|
}
|
||||||
|
|
||||||
|
SAT_ORDER_STATUS {
|
||||||
|
varchar link_key FK
|
||||||
|
varchar hashdiff
|
||||||
|
varchar status
|
||||||
|
varchar record_source
|
||||||
|
timestamp load_dttm
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Как это читать:
|
||||||
|
|
||||||
|
* 🔴 HUB_CUSTOMER / HUB_ORDER — «этот клиент существует», «этот заказ существует»;
|
||||||
|
* ⚪ LINK_ORDER_CUSTOMER — «именно этот заказ сделал именно этот клиент»;
|
||||||
|
* 🟡 SAT_… — как менялись атрибуты (email, статус и т.п.) во времени.
|
||||||
|
|
||||||
|
На схеме выше для наглядности показаны бизнес-ключи (`customer_bk`, `order_id`, `link_key`), а в DDL-примерах дальше используются уже хэш-ключи (`hk_*`) — это два уровня детализации одной и той же модели.
|
||||||
|
|
||||||
|
## 3. Три типа таблиц в Data Vault 2.0
|
||||||
|
|
||||||
|
Чуть менее «сказочно», чуть более технично.
|
||||||
|
|
||||||
|
### 3.1. Hub: сущность и её бизнес-ключ
|
||||||
|
|
||||||
|
**Hub** содержит:
|
||||||
|
|
||||||
|
* бизнес‑ключ (customer_bk, order_id, contract_number);
|
||||||
|
* техническую информацию:
|
||||||
|
* record_source — из какой системы пришла первая запись;
|
||||||
|
* load_dttm — когда запись попала в DV;
|
||||||
|
* иногда — хэш бизнес‑ключа (hk_customer).
|
||||||
|
|
||||||
|
Главные правила:
|
||||||
|
|
||||||
|
* один бизнес‑ключ — один хаб (одна строка на сущность, без истории);
|
||||||
|
* хаб не знает про атрибуты (имя, email) — только идентичность.
|
||||||
|
|
||||||
|
Простейший DDL‑скелет:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE hub_customer (
|
||||||
|
hk_customer BYTEA PRIMARY KEY, -- хэш от BK
|
||||||
|
customer_bk VARCHAR(50) NOT NULL, -- business key
|
||||||
|
record_source VARCHAR(50) NOT NULL,
|
||||||
|
load_dttm TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Конкретные типы данных (`BYTEA`, длины `VARCHAR`, детали `hashdiff`) и реализации хэш‑ключей можно не запоминать: на старте важнее понять саму идею — у сущностей есть стабильные ключи, а все изменения атрибутов мы записываем отдельными версиями в сателлитах.
|
||||||
|
|
||||||
|
### 3.2. Link: связи между сущностями
|
||||||
|
|
||||||
|
**Link** описывает факт связи, например:
|
||||||
|
|
||||||
|
* заказ ↔ клиент;
|
||||||
|
* договор ↔ счёт;
|
||||||
|
* карта ↔ клиент.
|
||||||
|
|
||||||
|
Примеры бизнес‑смыслов:
|
||||||
|
|
||||||
|
* link_order_customer — «этот заказ принадлежит этому клиенту»;
|
||||||
|
* link_contract_account — «этот договор привязан к этому счёту».
|
||||||
|
|
||||||
|
DDL‑эскиз:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE link_order_customer (
|
||||||
|
hk_order_customer BYTEA PRIMARY KEY,
|
||||||
|
hk_order BYTEA NOT NULL,
|
||||||
|
hk_customer BYTEA NOT NULL,
|
||||||
|
record_source VARCHAR(50) NOT NULL,
|
||||||
|
load_dttm TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3. Satellite: атрибуты и история
|
||||||
|
|
||||||
|
**Satellite** хранит:
|
||||||
|
|
||||||
|
* атрибуты хаба или линка;
|
||||||
|
* историю изменений этих атрибутов.
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
* sat_customer_info — имя, email, город клиента;
|
||||||
|
* sat_customer_segment — сегмент, категория, риск‑профиль;
|
||||||
|
* sat_order_status — статус заказа.
|
||||||
|
|
||||||
|
Типичные поля:
|
||||||
|
|
||||||
|
* ссылка на HUB или LINK (hk_customer, hk_order_customer);
|
||||||
|
* атрибуты (email, city, status и т.п.);
|
||||||
|
* hashdiff — хэш от всех атрибутов, чтобы понять, изменилась ли строка;
|
||||||
|
* record_source, load_dttm — источник и момент загрузки версии.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE sat_customer_info (
|
||||||
|
hk_customer BYTEA NOT NULL,
|
||||||
|
hashdiff BYTEA NOT NULL,
|
||||||
|
name VARCHAR(100),
|
||||||
|
email VARCHAR(100),
|
||||||
|
city VARCHAR(50),
|
||||||
|
record_source VARCHAR(50) NOT NULL,
|
||||||
|
load_dttm TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Главная мысль: DV заставляет явно разделять идентичность, связи и атрибуты с историей. Это делает модель сложнее на вид, но гораздо устойчивее к изменениям источников.
|
||||||
|
|
||||||
|
Когда нужны именно бизнес-периоды действия («с/по»), их удобнее моделировать не в базовом сателлите, а отдельными effectivity-сателлитами или через PIT-таблицы в Business Vault.
|
||||||
|
|
||||||
|
## 4. Типы сателлитов в DV 2.0
|
||||||
|
|
||||||
|
> Если вы только знакомитесь с DV, этот раздел можно прочитать по диагонали: для собеседования важно скорее знать, что такие роли бывают, чем разбираться во всех нюансах.
|
||||||
|
|
||||||
|
В DV 2.0 появилось разделение по «ролям» сателлитов. Главное, что стоит знать:
|
||||||
|
|
||||||
|
* **Descriptive Satellites** — обычные атрибуты (имя, адрес, тариф) с историей.
|
||||||
|
* **Effectivity Satellites** — фокус на периодах действия (`valid_from` / `valid_to`), очень похоже на SCD2.
|
||||||
|
* **Multi-Active Satellites** — когда у сущности несколько одновременных значений (например, три активных телефона клиента).
|
||||||
|
* **Transactional Satellites** — события, привязанные к одному хабу/линку (например, журнал изменений статуса).
|
||||||
|
|
||||||
|
На практике это разные DDL-«шаблоны» поверх одной и той же идеи:
|
||||||
|
**атрибуты + время → отдельная табличка.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Raw Vault и Business Vault
|
||||||
|
|
||||||
|
Обычно под «Data Vault» люди смешивают два слоя:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
SRC[Источники] --> STG[STG / ODS]
|
||||||
|
STG --> RAW[Raw Vault
|
||||||
|
Hubs, Links, Sats]
|
||||||
|
RAW --> BV[Business Vault
|
||||||
|
PIT, Bridge, Derived]
|
||||||
|
BV --> DM[Data Marts
|
||||||
|
Star Schema]
|
||||||
|
DM --> BI[BI / ML]
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Raw Vault** — это про приём и хранение данных «как есть», но уже в форме Hub / Link / Satellite.
|
||||||
|
* **Business Vault** — это про приведение этих данных в более «деловой» вид: с бизнес-правилами, PIT/Bridge и подготовленными представлениями.
|
||||||
|
|
||||||
|
### 5.1. Raw Vault: «всё прилетевшее, аккуратно разложенное по ящичкам»
|
||||||
|
|
||||||
|
Raw DV — первый слой поверх STG / ODS:
|
||||||
|
|
||||||
|
* выравниваем ключи;
|
||||||
|
* разбираем сущности по Hub / Link / Sat;
|
||||||
|
* сохраняем всю историю изменений, не решая ещё, что такое «активный клиент» или «успешный заказ».
|
||||||
|
|
||||||
|
Характерные черты Raw Vault:
|
||||||
|
|
||||||
|
* минимум бизнес-логики:
|
||||||
|
|
||||||
|
* никаких правил вроде «клиент активен, если была хотя бы одна покупка за 90 дней»;
|
||||||
|
* все источники показываются «как есть», только приведены к общим ключам;
|
||||||
|
* структура стабильна: добавился новый источник → появился новый Satellite к тому же Hub.
|
||||||
|
|
||||||
|
### 5.2. Business Vault: «там, где из Lego собирают модули»
|
||||||
|
|
||||||
|
Business Vault (BV) — следующий слой над Raw DV:
|
||||||
|
|
||||||
|
* здесь применяются бизнес-правила (что считать активным клиентом, как трактовать статусы);
|
||||||
|
* здесь строятся вспомогательные структуры:
|
||||||
|
|
||||||
|
* PIT-таблицы,
|
||||||
|
* Bridge-таблицы,
|
||||||
|
* агрегаты и derived-таблицы.
|
||||||
|
|
||||||
|
Именно из BV чаще всего строятся витрины в формате Звезды, к которым подключаются BI и отчётность.
|
||||||
|
|
||||||
|
Если сильно упростить:
|
||||||
|
|
||||||
|
* Raw DV → «мы всё собрали»;
|
||||||
|
* Business Vault → «мы это привели в вид, с которым удобно жить»;
|
||||||
|
* DM → «мы вынесли это на витрину в понятной форме».
|
||||||
|
|
||||||
|
#### 5.2.1. PIT-таблицы (Point-in-Time)
|
||||||
|
|
||||||
|
> Раздел для любопытных: PIT-таблицы — уже продвинутая тема, на первом знакомстве с DV её можно смело пропустить и вернуться позже.
|
||||||
|
|
||||||
|
PIT (Point-in-Time) решает очень конкретную боль:
|
||||||
|
|
||||||
|
> «Покажи, как объект выглядел **на дату X**, но так, чтобы запрос был простым».
|
||||||
|
|
||||||
|
Если у нас есть несколько сателлитов с историей (например, `sat_customer_info`, `sat_customer_segment`, `sat_customer_risk`), то без PIT любой запрос превращается в пачку условий
|
||||||
|
`as_of_date >= valid_from AND (valid_to IS NULL OR as_of_date < valid_to)` (в effectivity-сателлитах или любых таблицах с периодами действия) или оконных функций.
|
||||||
|
|
||||||
|
**Идея PIT:**
|
||||||
|
|
||||||
|
Мы заранее считаем «словарь» вида:
|
||||||
|
|
||||||
|
> для каждой пары (объект, дата) — какие версии сателлитов на эту дату актуальны.
|
||||||
|
|
||||||
|
Упрощённый пример структуры:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE pit_customer_daily (
|
||||||
|
hk_customer BYTEA,
|
||||||
|
as_of_date DATE,
|
||||||
|
hk_sat_info BYTEA, -- sat_customer_info на эту дату
|
||||||
|
hk_sat_segment BYTEA, -- sat_customer_segment на эту дату
|
||||||
|
load_dttm TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Внутри логически это выглядит так:
|
||||||
|
|
||||||
|
| hk_customer | as_of_date | hk_sat_info | hk_sat_segment |
|
||||||
|
| ----------- | ---------- | ------------ | -------------- |
|
||||||
|
| 101 | 2023-06-10 | hash_info_v1 | hash_seg_v1 |
|
||||||
|
| 101 | 2023-06-11 | hash_info_v2 | hash_seg_v1 |
|
||||||
|
| 101 | 2023-06-12 | hash_info_v2 | hash_seg_v2 |
|
||||||
|
|
||||||
|
Дальше витрина продаж вместо сложных `BETWEEN` делает простой JOIN:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
s.sale_date,
|
||||||
|
s.amount,
|
||||||
|
seg.segment,
|
||||||
|
info.city
|
||||||
|
FROM fact_sales s
|
||||||
|
JOIN pit_customer_daily pit
|
||||||
|
ON pit.hk_customer = s.hk_customer
|
||||||
|
AND pit.as_of_date = s.sale_date
|
||||||
|
JOIN sat_customer_segment seg
|
||||||
|
ON seg.hk_customer = pit.hk_customer
|
||||||
|
AND seg.hashdiff = pit.hk_sat_segment
|
||||||
|
JOIN sat_customer_info info
|
||||||
|
ON info.hk_customer = pit.hk_customer
|
||||||
|
AND info.hashdiff = pit.hk_sat_info;
|
||||||
|
```
|
||||||
|
|
||||||
|
Мы **один раз** дорого посчитали PIT (по расписанию),
|
||||||
|
и дальше все витрины просто используют эту «справочную таблицу».
|
||||||
|
|
||||||
|
Коротко: **PIT — это таблица, где заранее записано, какая версия данных была актуальна на дату X.**
|
||||||
|
|
||||||
|
#### 5.2.2. Bridge-таблицы
|
||||||
|
|
||||||
|
> Тоже продвинутый приём: Bridge-таблицы чаще нужны в боевых хранилищах, чем на первых собеседованиях.
|
||||||
|
|
||||||
|
Bridge-таблицы отвечают на другой вопрос:
|
||||||
|
|
||||||
|
> «Какие объекты **в итоге** связаны между собой через длинную цепочку Links?»
|
||||||
|
|
||||||
|
В Data Vault связь между двумя сущностями редко бывает «одним JOIN’ом». Чаще это цепочка:
|
||||||
|
|
||||||
|
* клиент → договор → счёт → карта → транзакция;
|
||||||
|
* подразделение → филиал → торговая точка → чек;
|
||||||
|
* компания → дочерняя компания → проект → контракт → платёж.
|
||||||
|
|
||||||
|
Если каждый раз в витринах писать все эти JOIN’ы, запросы становятся:
|
||||||
|
|
||||||
|
* длинными и хрупкими;
|
||||||
|
* плохо читаемыми;
|
||||||
|
* дублируются во множестве отчётов.
|
||||||
|
|
||||||
|
**Идея Bridge:**
|
||||||
|
|
||||||
|
Сделать отдельную таблицу, где заранее развернуть «финальные» пары:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE bridge_customer_trx (
|
||||||
|
hk_customer BYTEA,
|
||||||
|
hk_trx BYTEA
|
||||||
|
-- опционально: даты действия связи, тип связи и т.п.
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Такую таблицу мы считаем **в ETL**, один раз по расписанию, а не в каждом запросе.
|
||||||
|
Условный псевдокод построения:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO bridge_customer_trx (hk_customer, hk_trx)
|
||||||
|
SELECT DISTINCT
|
||||||
|
c.hk_customer,
|
||||||
|
t.hk_trx
|
||||||
|
FROM hub_customer c
|
||||||
|
JOIN link_customer_contract lcc ON lcc.hk_customer = c.hk_customer
|
||||||
|
JOIN hub_contract ct ON ct.hk_contract = lcc.hk_contract
|
||||||
|
JOIN link_contract_account lca ON lca.hk_contract = ct.hk_contract
|
||||||
|
JOIN hub_account acc ON acc.hk_account = lca.hk_account
|
||||||
|
JOIN link_account_card lac ON lac.hk_account = acc.hk_account
|
||||||
|
JOIN hub_card card ON card.hk_card = lac.hk_card
|
||||||
|
JOIN link_card_trx lct ON lct.hk_card = card.hk_card
|
||||||
|
JOIN hub_trx t ON t.hk_trx = lct.hk_trx;
|
||||||
|
```
|
||||||
|
|
||||||
|
После этого витрина может использовать уже готовый Bridge:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
c.customer_bk,
|
||||||
|
SUM(t.amount) AS total_amount
|
||||||
|
FROM hub_customer c
|
||||||
|
JOIN bridge_customer_trx bct
|
||||||
|
ON bct.hk_customer = c.hk_customer
|
||||||
|
JOIN hub_trx t
|
||||||
|
ON t.hk_trx = bct.hk_trx
|
||||||
|
WHERE t.trx_date >= CURRENT_DATE - INTERVAL '30 day'
|
||||||
|
GROUP BY c.customer_bk;
|
||||||
|
```
|
||||||
|
|
||||||
|
Сложный путь по Links мы спрятали внутрь bridge‑таблицы,
|
||||||
|
а витрины работают с простой связкой «клиент ↔ транзакция».
|
||||||
|
|
||||||
|
Коротко:
|
||||||
|
|
||||||
|
* **PIT** — про «какая версия атрибутов была на дату X»;
|
||||||
|
* **Bridge** — про «какие объекты в итоге связаны друг с другом по длинному маршруту».
|
||||||
|
|
||||||
|
Bridge — это **не обязательный элемент DV**, а инструмент оптимизации.
|
||||||
|
Он нужен тогда, когда цепочки Links становятся длинными и повторяются во многих отчётах.
|
||||||
|
|
||||||
|
#### 5.2.3. Business-правила и derived-таблицы
|
||||||
|
|
||||||
|
> Этот раздел полезен, чтобы увидеть, как DV помогает «прятать» повторяющуюся бизнес-логику, но для базового понимания Data Vault его можно оставить «на потом».
|
||||||
|
|
||||||
|
В BV логично размещать бизнес-логику, которая:
|
||||||
|
|
||||||
|
* повторяется во многих отчётах;
|
||||||
|
* достаточно стабильна.
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
* флаг «активный клиент», который вычисляется на основе истории покупок и логинов;
|
||||||
|
* «основной тариф», выбранный по набору правил из нескольких источников;
|
||||||
|
* «чистый статус заказа», свёрнутый из цепочки статусов (created → paid → shipped → delivered / cancelled).
|
||||||
|
|
||||||
|
Это могут быть как отдельные Satellites / Links, так и логические таблицы BV с уже посчитанными флагами и агрегатами.
|
||||||
|
|
||||||
|
### 5.3. Граница между Business Vault и витринами (DM)
|
||||||
|
|
||||||
|
Важно проговорить границу:
|
||||||
|
|
||||||
|
* Business Vault — ещё про данные и историю;
|
||||||
|
* DM (Data Marts) — уже про конкретные бизнес-сценарии и удобство BI.
|
||||||
|
|
||||||
|
Витрины можно переделывать, не трогая BV, пока вы не меняете фундаментальные бизнес-правила.
|
||||||
|
|
||||||
|
### 5.4. Как выглядит связка Raw DV → BV → DM на примере
|
||||||
|
|
||||||
|
Мини-пример интернет-магазина:
|
||||||
|
|
||||||
|
1. Raw Vault:
|
||||||
|
|
||||||
|
* `hub_customer`, `hub_order`;
|
||||||
|
* `link_order_customer`;
|
||||||
|
* `sat_customer_info`, `sat_order_status`, `sat_customer_segment`.
|
||||||
|
|
||||||
|
2. Business Vault:
|
||||||
|
|
||||||
|
* `pit_customer_daily` — срез клиента по дням;
|
||||||
|
* `bv_customer_flags` — активность, VIP-статусы, сегменты;
|
||||||
|
* `bridge_customer_order` — связи клиент ↔ заказ с удобными ключами.
|
||||||
|
|
||||||
|
3. DM / Star Schema:
|
||||||
|
|
||||||
|
* `dm.fact_sales` — факт продаж;
|
||||||
|
* `dm.dim_customer` — уже плоское измерение с нужными полями (email_current, segment, is_active_90d и т.п.);
|
||||||
|
* `dm.dim_date`, `dm.dim_product` и другие измерения.
|
||||||
|
|
||||||
|
В результате:
|
||||||
|
|
||||||
|
* Raw DV — технически корректный, историчный и некрасивый слой;
|
||||||
|
* BV — рабочий слой для инженеров и продвинутых аналитиков;
|
||||||
|
* DM — привычная Звезда для всех остальных.
|
||||||
|
|
||||||
|
## 6. Пример: клиент и заказы в DV-стиле
|
||||||
|
|
||||||
|
Возьмём мини‑пример всё того же интернет‑магазина.
|
||||||
|
|
||||||
|
У нас есть:
|
||||||
|
|
||||||
|
* клиент с `customer_id = 101`, у которого иногда меняется email и город;
|
||||||
|
* два заказа: `order_id = 5001` и `order_id = 5002`;
|
||||||
|
* статусы заказов: `created → paid → shipped → delivered`.
|
||||||
|
|
||||||
|
### Что появляется в Raw Vault
|
||||||
|
|
||||||
|
В Data Vault это раскладывается на несколько таблиц:
|
||||||
|
|
||||||
|
* `hub_customer` — по одной строке на каждого клиента (BK = `customer_id`).
|
||||||
|
* `hub_order` — по одной строке на каждый заказ (BK = `order_id`).
|
||||||
|
* `link_order_customer` — связь «какой заказ сделал какой клиент».
|
||||||
|
* `sat_customer_info` — история атрибутов клиента (имя, email, город).
|
||||||
|
* `sat_order_status` — история статусов заказа.
|
||||||
|
|
||||||
|
Примерно так это выглядит логически:
|
||||||
|
|
||||||
|
```text
|
||||||
|
HUB_CUSTOMER
|
||||||
|
--------------------------------------
|
||||||
|
customer_bk | record_source | load_dttm
|
||||||
|
--------------------------------------
|
||||||
|
101 | CRM | 2023-01-10 10:00
|
||||||
|
|
||||||
|
SAT_CUSTOMER_INFO
|
||||||
|
customer_bk | load_dttm | hashdiff | email | city
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
101 | 2023-01-10 10:00 | ... | a@example.com | Moscow
|
||||||
|
101 | 2023-06-01 09:00 | ... | alice@newmail.com | Moscow
|
||||||
|
|
||||||
|
HUB_ORDER
|
||||||
|
--------------------------------------
|
||||||
|
order_id | record_source | load_dttm
|
||||||
|
--------------------------------------
|
||||||
|
5001 | SHOP | 2023-06-10 12:00
|
||||||
|
5002 | SHOP | 2023-06-11 09:30
|
||||||
|
|
||||||
|
SAT_ORDER_STATUS
|
||||||
|
order_id | load_dttm | hashdiff | status
|
||||||
|
--------------------------------------------------------------
|
||||||
|
5001 | 2023-06-10 12:00 | ... | created
|
||||||
|
5001 | 2023-06-10 12:05 | ... | paid
|
||||||
|
5001 | 2023-06-11 09:00 | ... | shipped
|
||||||
|
... | ... | ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Ключевая идея: **любое изменение** (email, статус) — это **новая строка** в соответствующем Satellite.
|
||||||
|
|
||||||
|
### Как из этого получить витрину продаж
|
||||||
|
|
||||||
|
Дальше нам нужно привычное измерение `dim_customer` и факт `fact_sales` в формате Звезды.
|
||||||
|
|
||||||
|
Обычно цепочка выглядит так:
|
||||||
|
|
||||||
|
1. В Business Vault строим `PIT`‑таблицу по клиентам:
|
||||||
|
|
||||||
|
* для каждой даты (или дня, или часа) знаем, какая строка из `sat_customer_info` была актуальна.
|
||||||
|
2. Строим витрину `dm.dim_customer`:
|
||||||
|
|
||||||
|
* на выбранную дату берём нужную версию из `sat_customer_info`;
|
||||||
|
* добавляем флаги/сегменты из других Satellites/BV‑таблиц.
|
||||||
|
3. Строим факт `dm.fact_sales`, где каждая строка — заказ или позиция заказа.
|
||||||
|
|
||||||
|
Итог: на витрине мы видим «плоского» клиента (одна строка → текущее имя/город/email на момент заказа),
|
||||||
|
хотя внутри DV лежит полная, аккуратно разложенная история.
|
||||||
|
|
||||||
|
## 7. Как это живёт в пайплайне загрузки
|
||||||
|
|
||||||
|
Посмотрим теперь, как DV вписывается в обычный ETL/ELT‑пайплайн.
|
||||||
|
|
||||||
|
Типичный поток выглядит так:
|
||||||
|
|
||||||
|
1. **STG / ODS — приём и первичная обработка**
|
||||||
|
|
||||||
|
* Подключаемся к источникам (CRM, биллинг, сайт, партнёрские выгрузки).
|
||||||
|
* Забираем инкременты (CDC, выгрузки по расписанию, API).
|
||||||
|
* Приводим типы данных, чистим очевидный мусор, нормализуем форматы дат и т.п.
|
||||||
|
|
||||||
|
2. **Raw Vault — приземление в Hub / Link / Satellite**
|
||||||
|
|
||||||
|
* Из STG/ODS считаем хэш‑ключи для бизнес‑ключей (HK для Hubs).
|
||||||
|
* Создаём/обновляем **Hubs** — если бизнес‑ключ новый, заводим запись.
|
||||||
|
* Создаём/обновляем **Links** — фиксируем связи между сущностями.
|
||||||
|
* Для **Satellites** считаем `hashdiff` по атрибутам и добавляем новые строки
|
||||||
|
только если что‑то реально изменилось.
|
||||||
|
|
||||||
|
3. **Business Vault — бизнес‑правила и ускорители**
|
||||||
|
|
||||||
|
* Строим PIT‑таблицы, чтобы быстро получать срез «на дату X».
|
||||||
|
* Строим Bridge‑таблицы для сложных цепочек связей.
|
||||||
|
* Вычисляем стабильные бизнес‑флаги и derived‑атрибуты (активность, сегменты, статусы).
|
||||||
|
|
||||||
|
4. **DM / Star Schema — витрины для отчётов и аналитики**
|
||||||
|
|
||||||
|
* На основе BV собираем факты и измерения в формате Звезды.
|
||||||
|
* Под это уже настраиваются BI‑инструменты, отчётность, дашборды.
|
||||||
|
|
||||||
|
Чем это отличается от классического «STG → ODS → DDS → DM»:
|
||||||
|
|
||||||
|
* слой DDS в DV‑подходе часто фактически превращается в **Raw+Business Vault**;
|
||||||
|
* вместо одной «большой» нормализованной схемы ядра у нас набор Lego‑модулей (Hubs/Links/Sats), которые за счёт хэш‑ключей и независимой загрузки доменов проще масштабировать и развивать/грузить параллельно.
|
||||||
|
|
||||||
|
DV не отменяет STG/ODS/DM — он скорее **раскладывает слой DDS на более мелкие и управляемые детали**.
|
||||||
|
|
||||||
|
## 8. Плюсы и минусы Data Vault
|
||||||
|
|
||||||
|
### 8.1. Плюсы
|
||||||
|
|
||||||
|
* 📜 **История «из коробки»**
|
||||||
|
Каждое изменение — отдельная запись в сателлите. Ничего не перезатирается.
|
||||||
|
|
||||||
|
* 🧩 **Лёгкое добавление источников**
|
||||||
|
Новый источник с теми же сущностями = новые сателлиты к тем же хабам.
|
||||||
|
|
||||||
|
* 🔎 **Трассировка и аудит**
|
||||||
|
Видно, из какого источника, когда и с какими атрибутами прилетела каждая строка.
|
||||||
|
|
||||||
|
* 👥 **Параллельная работа команд**
|
||||||
|
Разные домены в DV практически не блокируют друг друга.
|
||||||
|
|
||||||
|
### 8.2. Минусы
|
||||||
|
|
||||||
|
* 🧠 **Высокий порог входа**
|
||||||
|
Нужно понимать SCD, хэш‑ключи, нагрузку на JOIN, паттерны загрузки.
|
||||||
|
|
||||||
|
* 📈 **Больше таблиц и JOIN’ов**
|
||||||
|
Даже простой запрос превращается в «HUB + LINK + 2–3 SAT + PIT».
|
||||||
|
|
||||||
|
* 🛠 **Нужна дисциплина**
|
||||||
|
Забыли заполнить `record_source`/`load_dttm` — потеряли часть аудита.
|
||||||
|
|
||||||
|
* ⏳ **Плохо подходит для MVP**
|
||||||
|
Для 2–3 источников DV обычно дороже, чем классическая 3NF/Звезда.
|
||||||
|
|
||||||
|
## 9. Когда DV стоит использовать, а когда нет
|
||||||
|
|
||||||
|
### Подходит, если:
|
||||||
|
|
||||||
|
* у вас **зоопарк источников** (5+ систем, которые ещё и меняются);
|
||||||
|
* важна **полная история и аудит** (финтех, гос, телеком, крупный банк);
|
||||||
|
* команда нацелена на долгую жизнь DWH, а не одноразовый отчёт;
|
||||||
|
* есть люди, готовые жить в этой модели (архитектор, data engineer’ы).
|
||||||
|
|
||||||
|
### Лучше не начинать с DV, если:
|
||||||
|
|
||||||
|
* это **первый DWH в компании**;
|
||||||
|
* 1–3 источника и нет жёстких требований по аудиту;
|
||||||
|
* команда малая (1–2 инженера + аналитик) и сроки жмут;
|
||||||
|
* задача звучит как «дайте отчёты к кварталу», а не «построим платформу на 5 лет».
|
||||||
|
|
||||||
|
В таких случаях честнее (и дешевле) начать с:
|
||||||
|
|
||||||
|
> `stg → ods → dds (3NF/простая Звезда с SCD2) → dm (Звезда)`
|
||||||
|
|
||||||
|
А DV оставить как следующий шаг, когда появятся реальные боли, которые он решает.
|
||||||
|
|
||||||
|
## 10. Шпаргалка для собеседования
|
||||||
|
|
||||||
|
Если нужно быстро объяснить, что такое Data Vault:
|
||||||
|
|
||||||
|
- Data Vault — это модель хранилища, которая разделяет идентичность (Hubs), связи (Links) и атрибуты с историей (Satellites), чтобы проще переживать изменения источников.
|
||||||
|
- В DV есть три типа таблиц: `Hub` (бизнес-ключи сущностей), `Link` (связи между сущностями) и `Satellite` (атрибуты и их история).
|
||||||
|
- Raw Vault — слой, где данные складываются «как есть» в виде Hub/Link/Sat, Business Vault — слой с бизнес-правилами, PIT/Bridge и подготовленными представлениями для витрин.
|
||||||
|
- История в DV хранится «из коробки»: каждое изменение атрибутов — новая строка в сателлите, прошлые значения не затираются.
|
||||||
|
- DV хорошо подходит, когда много источников, они часто меняются и важен аудит; для маленького, простого DWH обычно хватает 3NF/Звезды.
|
||||||
|
- Для собеседования важно уметь связать всё вместе: объяснить Hub/Link/Satellite, отличия Raw и Business Vault и то, что хэш-ключи и независимые сателлиты позволяют параллельно загружать разные сущности, не ломая историю и аудит.
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
# Домашка: статусы клиента от STG до DDS (и немного DM)
|
||||||
|
|
||||||
|
Небольшое практическое задание на 1–2 вечера: по данным о смене статусов клиента (CRM) построить цепочку слоёв `STG → ODS → DDS (SCD2)` и, по желанию, небольшую витрину в `dm`.
|
||||||
|
|
||||||
|
Цель — потренировать **руками**:
|
||||||
|
|
||||||
|
- работу со слоями DWH (stg / ods / dds / dm);
|
||||||
|
- проектирование и загрузку **измерения с историей (SCD Type 2)**;
|
||||||
|
- аккуратную работу со временем (`event_ts`, `valid_from`, `valid_to`).
|
||||||
|
|
||||||
|
Исходим из того, что вы уже прошли основную статью `dwh-modeling/README.md` и познакомились с примером интернет‑магазина.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Данные: события смены статуса клиента
|
||||||
|
|
||||||
|
Представьте, что в CRM для каждого клиента хранится история статусов:
|
||||||
|
|
||||||
|
- `new` — только что зарегистрировался;
|
||||||
|
- `active` — делал покупки недавно;
|
||||||
|
- `vip` — часто покупает и много тратит;
|
||||||
|
- `churned` — давно ничего не делал, считаем «отвалившимся».
|
||||||
|
|
||||||
|
Эта информация приходит в DWH в виде **событий** (events): «у клиента X в момент времени Y статус стал Z».
|
||||||
|
|
||||||
|
В репозитории в каталоге `dwh-modeling/data` лежит файл:
|
||||||
|
|
||||||
|
- `customer_status_events.csv`
|
||||||
|
|
||||||
|
Структура файла:
|
||||||
|
|
||||||
|
```text
|
||||||
|
customer_id,status,event_ts,_load_id,_load_ts
|
||||||
|
101,new,2024-01-01 09:00:00,batch_20240101_1000,2024-01-01 10:00:00
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Колонки:
|
||||||
|
|
||||||
|
- `customer_id` — бизнес-ключ клиента (тот же, что и в основном примере — 101, 102, 103);
|
||||||
|
- `status` — статус клиента в CRM (`new`, `active`, `vip`, `churned`);
|
||||||
|
- `event_ts` — момент, когда статус сменился в CRM;
|
||||||
|
- `_load_id` — идентификатор батча загрузки;
|
||||||
|
- `_load_ts` — момент, когда данные попали в DWH.
|
||||||
|
|
||||||
|
Файл содержит несколько клиентов и несколько смен статуса по каждому — этого достаточно, чтобы отработать SCD2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Целевая схема: какие таблицы уже есть
|
||||||
|
|
||||||
|
Чтобы не тратить время на DDL, структуры таблиц для домашки уже подготовлены в `dwh-modeling/sql`:
|
||||||
|
|
||||||
|
- `07_ddl_hw_customer_status.sql` — создаёт дополнительные таблицы:
|
||||||
|
- `stg.customer_status_raw` — сырые события о статусе клиента;
|
||||||
|
- `ods.customer_status` — очищенные и типизированные события;
|
||||||
|
- `dds.dim_customer_status` — измерение статусов клиента в формате **SCD Type 2**.
|
||||||
|
- `08_dml_hw_customer_status_template.sql` — шаблон DML-скрипта с подсказками и заготовками блоков.
|
||||||
|
|
||||||
|
Перед началом работы:
|
||||||
|
|
||||||
|
1. Поднимите demo‑Postgres по инструкции из корневого `README.md`.
|
||||||
|
2. Выполните базовые скрипты DWH:
|
||||||
|
- `01_ddl_stg-dds.sql`
|
||||||
|
- `02_dml_stg-dds.sql` (нужен как минимум для `dds.dim_date`)
|
||||||
|
- `05_ddl_dm.sql` (создаёт схему `dm` для витрин)
|
||||||
|
3. Выполните DDL для домашки:
|
||||||
|
- `07_ddl_hw_customer_status.sql`
|
||||||
|
|
||||||
|
После этого схемы `stg`, `ods`, `dds`, `dm` уже существуют, а дополнительные таблицы для статусов созданы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Часть 1: STG → ODS (обязательно)
|
||||||
|
|
||||||
|
**Задача:** загрузить CSV в STG и переложить данные в ODS с приведением типов.
|
||||||
|
|
||||||
|
### 3.1. STG: загрузка CSV
|
||||||
|
|
||||||
|
Есть два варианта — выберите любой. Для первого прохождения рекомендуем **вариант A** (самый простой).
|
||||||
|
|
||||||
|
#### Вариант A (рекомендуемый): вставить данные в STG через `INSERT`
|
||||||
|
|
||||||
|
Откройте SQL‑клиент (DBeaver или `psql`) и вставьте данные текстом:
|
||||||
|
|
||||||
|
> 💡 Если вы уже загружали данные в `stg.customer_status_raw` и делаете повторный запуск — начните с `TRUNCATE stg.customer_status_raw;`.
|
||||||
|
>
|
||||||
|
> 💡 В примере ниже показан минимальный набор для клиента 101. Чтобы получить несколько клиентов и больше событий — используйте вариант B (CSV) или добавьте строки из файла `dwh-modeling/data/customer_status_events.csv`.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO stg.customer_status_raw (customer_id, status, event_ts, _load_id, _load_ts) VALUES
|
||||||
|
('101','new','2024-01-01 09:00:00','batch_20240101_1000','2024-01-01 10:00:00'),
|
||||||
|
('101','active','2024-02-15 10:30:00','batch_20240215_1100','2024-02-15 11:00:00'),
|
||||||
|
('101','vip','2024-05-10 11:00:00','batch_20240510_1200','2024-05-10 12:00:00'),
|
||||||
|
('101','churned','2024-09-01 12:15:00','batch_20240901_1300','2024-09-01 13:00:00');
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 Здесь `_load_ts` — это время загрузки.
|
||||||
|
|
||||||
|
#### Вариант B: загрузить CSV
|
||||||
|
|
||||||
|
Можно загрузить файл `dwh-modeling/data/customer_status_events.csv` в таблицу `stg.customer_status_raw`:
|
||||||
|
|
||||||
|
- **Через DBeaver**: Import Data → CSV → `stg.customer_status_raw`.
|
||||||
|
- **Через `psql` в контейнере (`./psql_sh`)**: без установки `psql` на хост.
|
||||||
|
|
||||||
|
Способ: передайте CSV в `psql` через STDIN и выполните `\copy ... FROM STDIN`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./postgres-bookings/psql_sh -c "TRUNCATE stg.customer_status_raw;"
|
||||||
|
cat dwh-modeling/data/customer_status_events.csv | ./postgres-bookings/psql_sh -c \
|
||||||
|
"\\copy stg.customer_status_raw(customer_id,status,event_ts,_load_id,_load_ts) FROM STDIN WITH (FORMAT csv, HEADER true)"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 Для части 5 (инкрементальная загрузка) `TRUNCATE stg.customer_status_raw` делать не нужно — загружайте только новые строки.
|
||||||
|
|
||||||
|
После загрузки убедитесь, что данные на месте:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM stg.customer_status_raw LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. ODS: очистка и типизация
|
||||||
|
|
||||||
|
> 💡 Обратите внимание: в основном примере `ods.customers` хранит **снимок** (одна строка на клиента, PK = `customer_id`), а здесь `ods.customer_status` хранит **все события** (PK = `customer_id + event_ts`). Это не ошибка, а сознательный выбор: источник данных о статусах - поток событий, и ODS сохраняет эту природу. Подробнее - в комментариях к решению.
|
||||||
|
|
||||||
|
В файле `08_dml_hw_customer_status_template.sql` найдите заготовку блока ODS и допишите SQL:
|
||||||
|
|
||||||
|
- привести:
|
||||||
|
- `customer_id` → `INT`,
|
||||||
|
- `status` → `VARCHAR(20)` (можно оставить как есть),
|
||||||
|
- `event_ts` и `_load_ts` → `TIMESTAMP`;
|
||||||
|
- аккуратно обработать возможные пустые значения (если бы они были);
|
||||||
|
- заполнить `_load_id` и `_load_ts` в `ods.customer_status`.
|
||||||
|
|
||||||
|
> 💡 Для простоты сделайте загрузку идемпотентной через `TRUNCATE ods.customer_status;` перед `INSERT` (таблица в ODS имеет первичный ключ `(customer_id, event_ts)`).
|
||||||
|
|
||||||
|
Проверьте, что в `ods.customer_status` данные выглядят аккуратно:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT *
|
||||||
|
FROM ods.customer_status
|
||||||
|
ORDER BY customer_id, event_ts;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Часть 2: ODS → DDS (SCD Type 2, обязательно)
|
||||||
|
|
||||||
|
**Задача:** по событиям в `ods.customer_status` построить измерение `dds.dim_customer_status`, где каждая строка — период действия статуса.
|
||||||
|
|
||||||
|
Целевая таблица уже создана (см. `07_ddl_hw_customer_status.sql`):
|
||||||
|
|
||||||
|
- `customer_bk` — бизнес-ключ клиента (тот же, что `customer_id` в ODS);
|
||||||
|
- `status` — статус клиента;
|
||||||
|
- `hashdiff` — хэш от атрибутов (здесь достаточно самого `status`);
|
||||||
|
- `valid_from` / `valid_to` — период, когда статус был актуален;
|
||||||
|
- `created_at` / `updated_at` — технические поля.
|
||||||
|
|
||||||
|
### 4.1. Начальная загрузка SCD2
|
||||||
|
|
||||||
|
В шаблоне `08_dml_hw_customer_status_template.sql` допишите блок начальной загрузки:
|
||||||
|
|
||||||
|
1. Сформируйте промежуточный набор:
|
||||||
|
|
||||||
|
- `customer_bk`,
|
||||||
|
- `status`,
|
||||||
|
- `event_ts` (как «время начала действия статуса»),
|
||||||
|
- `hashdiff` (например, `md5(status)`; можно вынести расчёт в отдельную функцию по аналогии с `dds.customer_hash` для клиентов).
|
||||||
|
|
||||||
|
2. Для каждого клиента отсортируйте события по `event_ts` и с помощью `LEAD()` посчитайте (в учебном варианте считаем, что обновление DWH идёт раз в день, поэтому используем `DATE`):
|
||||||
|
|
||||||
|
- `valid_from` — `event_ts::date`,
|
||||||
|
- `valid_to` — следующий `event_ts::date` (а у последней версии `valid_to = NULL`).
|
||||||
|
|
||||||
|
> 💡 Упрощение для домашки: считаем, что у клиента не бывает двух разных смен статуса в один и тот же день.
|
||||||
|
> Если такое бывает — удобнее строить периоды в `TIMESTAMP` (или вводить дополнительное правило сортировки), но это уже усложнение.
|
||||||
|
|
||||||
|
> 💡 Если в событиях встречаются повторы одного и того же статуса подряд, можно отфильтровать “не-изменения” через `LAG(status)` (или `LAG(hashdiff)`) перед расчётом `LEAD()`.
|
||||||
|
|
||||||
|
3. Вставьте получившиеся строки в `dds.dim_customer_status`. Актуальная строка для клиента — та, где `valid_to IS NULL`.
|
||||||
|
|
||||||
|
> 💡 Для первой версии решения можно сделать full refresh: перед вставкой очистить таблицу (`TRUNCATE dds.dim_customer_status;`), как в основном примере с `dds.dim_customer`.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO dds.dim_customer_status (
|
||||||
|
customer_bk, status, hashdiff,
|
||||||
|
valid_from, valid_to,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверьте результат:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT *
|
||||||
|
FROM dds.dim_customer_status
|
||||||
|
ORDER BY customer_bk, valid_from;
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаемое поведение:
|
||||||
|
|
||||||
|
- у клиента 101 несколько строк с разными статусами и непересекающимися периодами;
|
||||||
|
- `valid_to IS NULL` только у самой свежей строки для каждого клиента.
|
||||||
|
|
||||||
|
### 4.2. Проверка себя
|
||||||
|
|
||||||
|
Примеры проверочных запросов (можно придумать свои):
|
||||||
|
|
||||||
|
- «Какой статус был у клиента 101 на дату `2024-06-01`?»
|
||||||
|
→ одна строка с нужным статусом.
|
||||||
|
- «Сколько клиентов были в статусе `active` на `2024-04-10`?»
|
||||||
|
→ несколько строк, если статус *активен* для диапазона дат.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Часть 3: инкрементальная загрузка (по желанию)
|
||||||
|
|
||||||
|
Если хочется потренироваться глубже:
|
||||||
|
|
||||||
|
1. Добавьте ещё несколько событий смены статуса (например, переход части клиентов из `churned` обратно в `active`).
|
||||||
|
- Можно дописать в исходный CSV самостоятельно.
|
||||||
|
- Либо взять готовую порцию “для инкремента” из файла `dwh-modeling/data/customer_status_events_increment.csv`.
|
||||||
|
2. Загрузите **только новые строки** в `stg.customer_status_raw` (не делайте `TRUNCATE`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat dwh-modeling/data/customer_status_events_increment.csv | ./postgres-bookings/psql_sh -c \
|
||||||
|
"\\copy stg.customer_status_raw(customer_id,status,event_ts,_load_id,_load_ts) FROM STDIN WITH (FORMAT csv, HEADER true)"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Напишите логику инкрементального обновления `dds.dim_customer_status`:
|
||||||
|
|
||||||
|
- ориентируйтесь на пример из `03_demo_increment.sql` для `dds.dim_customer`;
|
||||||
|
- важно:
|
||||||
|
- корректно «закрыть» старую актуальную строку (заполнить `valid_to` датой начала новой версии);
|
||||||
|
- вставить новую строку с `valid_to = NULL`.
|
||||||
|
|
||||||
|
Эта часть особенно полезна, если вы хотите почувствовать, как SCD2 живёт в реальном DWH.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Часть 4: витрина в DM (по желанию)
|
||||||
|
|
||||||
|
Опциональное задание для закрепления: собрать небольшую витрину с количеством клиентов по статусам на каждую дату.
|
||||||
|
|
||||||
|
DDL витрины уже создан в `07_ddl_hw_customer_status.sql` (таблица `dm.mart_customer_status_daily`).
|
||||||
|
|
||||||
|
Идея:
|
||||||
|
|
||||||
|
- использовать `dds.dim_date` как календарь;
|
||||||
|
- для каждой `date_actual` найти, какой статус был у клиента в этот день
|
||||||
|
(через `JOIN` на `dds.dim_customer_status` по диапазону `valid_from/valid_to`);
|
||||||
|
- агрегировать по `status`.
|
||||||
|
|
||||||
|
Пример запроса к витрине:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
date_actual,
|
||||||
|
status,
|
||||||
|
customers_cnt
|
||||||
|
FROM dm.mart_customer_status_daily
|
||||||
|
WHERE date_actual BETWEEN '2024-04-01' AND '2024-04-30'
|
||||||
|
ORDER BY date_actual, status;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Как вписать эту домашку в обучение
|
||||||
|
|
||||||
|
Рекомендуемое место в дорожке:
|
||||||
|
|
||||||
|
1. Пройти основную теорию по DWH и SCD:
|
||||||
|
- `dwh-modeling/README.md`
|
||||||
|
- `dwh-modeling/SCD.md`
|
||||||
|
2. Разобрать базовый пример интернет‑магазина (скрипты `01_`–`06_`).
|
||||||
|
3. Выполнить **эту домашку** как первую попытку «самостоятельного» моделирования и ETL:
|
||||||
|
- познакомиться с ещё одним измерением с историей (`dim_customer_status`);
|
||||||
|
- потренироваться аккуратно работать с датами и периодами;
|
||||||
|
- при желании — собрать простую витрину в `dm`.
|
||||||
|
|
||||||
|
Если что‑то не получается — можно разбирать решения по шагам вместе с ментором: от простого `SELECT` из STG до полноценного SCD2 в DDS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Эталонное решение
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Показать ссылку на решение</summary>
|
||||||
|
|
||||||
|
Когда выполните домашку и захотите сверить результат — готовое решение лежит в файле [`09_dml_hw_customer_status_solution.sql`](sql/09_dml_hw_customer_status_solution.sql).
|
||||||
|
|
||||||
|
Постарайтесь не подглядывать до того, как напишете свой вариант — основная ценность задания именно в самостоятельном разборе.
|
||||||
|
|
||||||
|
</details>
|
||||||
@@ -0,0 +1,868 @@
|
|||||||
|
# Хранилище данных: как устроена аналитика «под капотом»
|
||||||
|
*Для тех, кто знает SQL, но хочет понять, как хранить данные не в Excel, а по-взрослому*
|
||||||
|
|
||||||
|
## Оглавление
|
||||||
|
|
||||||
|
- [Что вы уже умеете, и что узнаете здесь](#что-вы-уже-умеете-и-что-узнаете-здесь)
|
||||||
|
- [1. Введение: почему нельзя просто SELECT из базы заказов?](#1-введение-почему-нельзя-просто-select-из-базы-заказов)
|
||||||
|
- [2. Учебный пример: интернет-магазин](#2-учебный-пример-интернет-магазин)
|
||||||
|
- [3. Зачем делить DWH на слои?](#3-зачем-делить-dwh-на-слои)
|
||||||
|
- [4. Путешествие данных: от STG до DM](#4-путешествие-данных-от-stg-до-dm)
|
||||||
|
- [5. Базовые понятия: факты, измерения, SCD](#5-базовые-понятия-факты-измерения-scd)
|
||||||
|
- [6. Модели данных для слоя DDS: 4 подхода, и когда какой выбрать](#6-модели-данных-для-слоя-dds-4-подхода-и-когда-какой-выбрать)
|
||||||
|
- [7. Практикум: как собрать первую витрину](#7-практикум-как-собрать-первую-витрину)
|
||||||
|
- [8. Как выбрать модель данных? Советы от практиков](#8-как-выбрать-модель-данных-советы-от-практиков)
|
||||||
|
- [9. Эксплуатация: качество данных, это не «опция»](#9-эксплуатация-качество-данных-это-не-опция)
|
||||||
|
- [10. Заключение: главное, понимать «почему»](#10-заключение-главное-понимать-почему)
|
||||||
|
- [Приложения](#приложения)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что вы уже умеете, и что узнаете здесь
|
||||||
|
|
||||||
|
✅ Уже знаете:
|
||||||
|
|
||||||
|
- `SELECT`, `JOIN`, `GROUP BY`;
|
||||||
|
- как посчитать сумму/среднее/количество по таблице.
|
||||||
|
|
||||||
|
🆕 Узнаете в этой статье:
|
||||||
|
|
||||||
|
- **слои хранилища** (STG → ODS → DDS → DM) и *зачем они нужны*;
|
||||||
|
- **факты и измерения** — основные кирпичики аналитики;
|
||||||
|
- **SCD Type 2** — как хранить историю изменений клиента (например, смену email или города);
|
||||||
|
- **суррогатные ключи (SK)** и чем они отличаются от обычных `id`;
|
||||||
|
- **четыре модели данных**: 3NF, Звезда (Star), Data Vault, Anchor Modeling — и когда какую использовать.
|
||||||
|
|
||||||
|
⛔ **Не будем говорить** здесь о:
|
||||||
|
|
||||||
|
- физическом хранении (партиции, индексы, ClickHouse-движки);
|
||||||
|
- распределённых кластерах (Kafka, Spark, Airflow — это отдельный курс);
|
||||||
|
- настройке производительности (`EXPLAIN`, кэши и т.п.).
|
||||||
|
Это — про *логику*, структуру и здравый смысл.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Введение: почему нельзя просто SELECT из базы заказов?
|
||||||
|
|
||||||
|
Представьте: вы — аналитик в интернет-магазине. Вам нужно ответить на вопрос:
|
||||||
|
> **«Сколько заказов сделал клиент с email `a@ex.com` за 2023 год, и сколько он потратил?»**
|
||||||
|
|
||||||
|
Вы идёте в базу заказов — и… не находите email. Он в CRM. Идёте в CRM — там нет сумм заказов. Возвращаетесь в заказы — сумма есть, но *только текущая цена товара*. А в 2023 году цена была другой!
|
||||||
|
|
||||||
|
Знакомо? Это — **проблема OLTP-систем** (оперативного учёта):
|
||||||
|
|
||||||
|
- **CRM**, **склад**, **платёжка** — это разные базы;
|
||||||
|
- каждая оптимизирована под *быструю запись операций* («добавить заказ», «списать товар»);
|
||||||
|
- историю там не хранят — email меняется «в лоб»: старое значение перезаписывается.
|
||||||
|
|
||||||
|
Такие системы называют **OLTP** (*Online Transaction Processing* — обработка транзакций в реальном времени).
|
||||||
|
А для аналитики нужна **OLAP** (*Online Analytical Processing* — обработка запросов на анализ).
|
||||||
|
|
||||||
|
➡️ **Хранилище данных (Data Warehouse, DWH)** — это как «единая карта сокровищ», куда собирают данные из всех источников, *сохраняя историю*, *выравнивая термины* и *готовя их к анализу*.
|
||||||
|
|
||||||
|
И вот главный секрет его успеха: **слоистая архитектура**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Учебный пример: интернет-магазин
|
||||||
|
|
||||||
|
Чтобы всё было на пальцах — разберём простой, но живой пример.
|
||||||
|
|
||||||
|
У нас есть 6 таблиц из трёх источников:
|
||||||
|
|
||||||
|
| Таблица | Источник | Что содержит |
|
||||||
|
|---------|----------|--------------|
|
||||||
|
| `customers` | CRM | Клиенты: `customer_id`, `email`, `phone`, `city` |
|
||||||
|
| `orders`, `order_items` | Заказы | Заказы и позиции в них |
|
||||||
|
| `products` | Склад | Товары: `product_id`, `name` |
|
||||||
|
| `prices` | Склад | История цен: `product_id`, `valid_from`, `valid_to`, `price` |
|
||||||
|
| `promos` | Маркетинг | Акции: `promo_id`, `code` |
|
||||||
|
|
||||||
|
⚠️ Обратите внимание:
|
||||||
|
|
||||||
|
- `customer_id = 101` в одном месяце — `a@ex.com`, в другом — `b@ex.com`;
|
||||||
|
- цена на товар `9001` (Phone) в январе — 100 ₽, в феврале — 110 ₽;
|
||||||
|
- `order_items` содержит `price_at_sale` — *цену в момент покупки*, а не текущую.
|
||||||
|
|
||||||
|
Это уже **намёк**: чтобы посчитать выручку 2023 года, нам нужна не текущая цена, а *та, что была в день заказа*.
|
||||||
|
|
||||||
|
(ER-диаграмма и DDL-примеры — в конце статьи, в разделе «Для практики».)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Зачем делить DWH на слои?
|
||||||
|
|
||||||
|
Представьте, что вы строите дом. Вы же не будете сразу вбивать гвозди в стены — сначала:
|
||||||
|
1. Привезли стройматериалы (песок, доски, кирпич) — **сырьё**;
|
||||||
|
2. Очистили, просеяли, нарезали — **обработка**;
|
||||||
|
3. Собрали каркас, провели коммуникации — **интеграция**;
|
||||||
|
4. Сделали отделку под конкретную квартиру — **готовое решение**.
|
||||||
|
|
||||||
|
В DWH — то же самое. Каждый слой отвечает за *одну задачу*:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Sources["Источники"]
|
||||||
|
A["CRM"]
|
||||||
|
B["Заказы"]
|
||||||
|
C["Склад"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph STG["STG — «Сырьё»"]
|
||||||
|
D["Таблицы-дубликаты</br>в формате источника"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph ODS["ODS — «Очистка»"]
|
||||||
|
E["Типы:</br>даты → DATE,</br>числа → INT/DECIMAL</br>Валидация: email, phone"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DDS["DDS — «Интеграция»"]
|
||||||
|
F["Общие сущности:</br>клиент, товар, дата</br>История (SCD),</br>суррогатные ключи"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DM["DM — «Готовые решения»"]
|
||||||
|
G["Витрина продаж: дата, товар, клиент, сумма</br>+ агрегаты (выручка/день)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
A --> STG
|
||||||
|
B --> STG
|
||||||
|
C --> STG
|
||||||
|
|
||||||
|
STG --> ODS
|
||||||
|
ODS --> DDS
|
||||||
|
DDS --> DM
|
||||||
|
DM --> BI["BI-системы</br>(Power BI, Tableau,</br>Metabase)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
👉 **Почему так лучше, чем «одна большая таблица»?**
|
||||||
|
1. **Управляемость**: если в `customers` пришёл битый `email` — ошибка локализована в STG/ODS, DDS не пострадает.
|
||||||
|
2. **Прозрачность**: можно посмотреть: «а как выглядел исходник?», «а как мы его почистили?».
|
||||||
|
3. **Производительность**: в DDS и DM — только то, что нужно для анализа. Никаких `JSON`-полей, `TEXT` без причины.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Путешествие данных: от STG до DM
|
||||||
|
|
||||||
|
Давайте проследим, как превращается строка заказа.
|
||||||
|
|
||||||
|
### **STG (Staging / Bronze)**: «как пришло»
|
||||||
|
|
||||||
|
- Таблицы: `stg.orders_raw`, `stg.customers_raw`;
|
||||||
|
- Структура — *точно как в источнике* (может быть `VARCHAR` даже у дат);
|
||||||
|
- Добавлены технические поля:
|
||||||
|
- `_load_id` — идентификатор загрузки;
|
||||||
|
- `_load_ts` — время получения данных;
|
||||||
|
- Главное правило: **неизменяемость**. Если пришла новая порция — либо добавляем новые строки, либо *полностью перезагружаем* слой (идемпотентность).
|
||||||
|
|
||||||
|
> 💡 *Пример:* `stg.orders_raw` содержит `"2024-01-10"` как строку — это нормально. Главное — не потерять оригинал.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **ODS (Operational Data Store / Silver)**: «почистили, но не трогали смысл»
|
||||||
|
|
||||||
|
- Таблицы: `ods.orders`, `ods.customers`;
|
||||||
|
- Здесь:
|
||||||
|
- привели `order_date` к типу `DATE`;
|
||||||
|
- убрали заказы без клиента (`customer_id IS NULL` → ошибка или флаг);
|
||||||
|
- привели телефоны к формату `79991112233`;
|
||||||
|
- проверили email на валидность (регуляркой или простой проверкой).
|
||||||
|
- **Но!** Не объединяем клиента из CRM и клиента из заказов — это будет позже.
|
||||||
|
- Пока — никакой бизнес-логики. Только *техническая* очистка.
|
||||||
|
- Дедупликация: если два раза пришёл один и тот же заказ — оставляем один (по `order_id + _load_ts`).
|
||||||
|
|
||||||
|
> 🎯 Цель ODS — дать «надёжную платформу» для следующего слоя. Как сухое, чистое бревно перед сборкой дома.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **DDS (Data Delivery Store / Core / Conformed)**: «интеграция + история»
|
||||||
|
|
||||||
|
Здесь рождается *единая бизнес-модель*.
|
||||||
|
Появляются понятия: **измерения**, **факты**, **суррогатные ключи**, **SCD**.
|
||||||
|
|
||||||
|
Например:
|
||||||
|
|
||||||
|
| Таблица | Назначение |
|
||||||
|
|---------|------------|
|
||||||
|
| `dds.dim_customer` | Измерение «Клиент» с историей (SCD Type 2) |
|
||||||
|
| `dds.dim_product` | Измерение «Товар» |
|
||||||
|
| `dds.dim_date` | Готовый календарь на 5 лет вперёд (день/неделя/месяц/квартал) |
|
||||||
|
| `dds.fact_sales` | Факт «Продажа» — строка заказа с суммой и количеством |
|
||||||
|
|
||||||
|
💡 **Суррогатный ключ (Surrogate Key, SK)** — это `BIGINT`, который мы генерируем сами (например, `customer_sk = 1001`).
|
||||||
|
**Бизнес-ключ (Business Key, BK)** — это `customer_id = 101` из источника.
|
||||||
|
Мы храним и то, и другое — чтобы можно было и джойнить, и понимать, откуда строка.
|
||||||
|
|
||||||
|
> ✅ Почему не использовать `customer_id` напрямую?
|
||||||
|
> — Потому что в одном источнике `customer_id` — целое число, в другом — строка `CUST-101`.
|
||||||
|
> — Потому что ID могут повторяться (например, в тестовой и продовой базах).
|
||||||
|
> — Потому что нам нужна *связь* с историей: у клиента с BK = `101` может быть 3 версии в `dim_customer`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **DM (Data Mart / Gold / «Витрины»)**: «готово к употреблению»
|
||||||
|
|
||||||
|
Здесь — таблицы и представления для конкретных задач:
|
||||||
|
|
||||||
|
- `dm.mart_daily_sales` — ежедневные продажи по товарам и сегментам;
|
||||||
|
- `dm.mart_customer_360` — полный портрет клиента: сколько потратил, когда заходил, какие товары любит.
|
||||||
|
|
||||||
|
Они часто построены по модели **Звезда (Star Schema)** — потому что BI-инструментам так удобнее всего.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Базовые понятия: факты, измерения, SCD
|
||||||
|
|
||||||
|
Представьте отчёт:
|
||||||
|
> *«10 января 2024 года клиент из Москвы (сегмент Premium) купил Phone за 100 ₽»*.
|
||||||
|
|
||||||
|
В DWH это разложится на:
|
||||||
|
|
||||||
|
- **Факт (Fact)** — событие, которое можно измерить: *покупка*.
|
||||||
|
Хранится в `fact_sales`: `quantity = 1`, `amount = 100`.
|
||||||
|
- **Измерения (Dimensions)** — *контекст* факта:
|
||||||
|
- `dim_date` → 10 января 2024;
|
||||||
|
- `dim_customer` → Москва, Premium;
|
||||||
|
- `dim_product` → Phone.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
dim_date ||--o{ fact_sales : "дата"
|
||||||
|
dim_customer ||--o{ fact_sales : "клиент"
|
||||||
|
dim_product ||--o{ fact_sales : "товар"
|
||||||
|
|
||||||
|
dim_date {
|
||||||
|
int date_key PK "YYYYMMDD"
|
||||||
|
date calendar_date "сама дата"
|
||||||
|
int year
|
||||||
|
int month
|
||||||
|
int day
|
||||||
|
varchar dow "день недели"
|
||||||
|
}
|
||||||
|
|
||||||
|
dim_customer {
|
||||||
|
bigint customer_sk PK "суррогатный ключ"
|
||||||
|
int customer_bk "бизнес-ключ, напр. 101"
|
||||||
|
varchar customer_name
|
||||||
|
varchar email
|
||||||
|
varchar city
|
||||||
|
date valid_from "SCD2: с какой даты запись актуальна"
|
||||||
|
date valid_to "SCD2: по какую дату актуальна (NULL = сейчас)"
|
||||||
|
}
|
||||||
|
|
||||||
|
dim_product {
|
||||||
|
bigint product_sk PK
|
||||||
|
varchar product_bk "код товара / артикул"
|
||||||
|
varchar product_name
|
||||||
|
varchar category
|
||||||
|
}
|
||||||
|
|
||||||
|
fact_sales {
|
||||||
|
bigint sale_id PK
|
||||||
|
int date_key FK "ссылка на dim_date.date_key"
|
||||||
|
bigint customer_sk FK
|
||||||
|
bigint product_sk FK
|
||||||
|
int quantity
|
||||||
|
decimal amount
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SCD Type 2: как хранить историю
|
||||||
|
|
||||||
|
Клиент №101:
|
||||||
|
|
||||||
|
- с 1 янв по 15 мая — `email = a@ex.com`, `city = Москва`;
|
||||||
|
- с 16 мая — `email = b@ex.com`, `city = Москва`;
|
||||||
|
- с 1 окт — `email = b@ex.com`, `city = Санкт-Петербург`.
|
||||||
|
|
||||||
|
В `dim_customer` это будет **три строки**:
|
||||||
|
|
||||||
|
| customer_sk | customer_bk | email | city | valid_from | valid_to |
|
||||||
|
|-------------|-------------|-------|------|------------|----------|
|
||||||
|
| 1001 | 101 | a@ex.com | Москва | 2023-01-01 | 2023-05-16 |
|
||||||
|
| 1002 | 101 | b@ex.com | Москва | 2023-05-16 | 2023-10-01 |
|
||||||
|
| 1003 | 101 | b@ex.com | СПб | 2023-10-01 | NULL |
|
||||||
|
|
||||||
|
Когда мы считаем продажи за **12 января** — джойним `fact_sales` к той строке `dim_customer`, где:
|
||||||
|
```sql
|
||||||
|
fact_sales.order_date >= dim_customer.valid_from
|
||||||
|
AND (dim_customer.valid_to IS NULL OR fact_sales.order_date < dim_customer.valid_to)
|
||||||
|
```
|
||||||
|
и получаем актуальный на тот день email и город.
|
||||||
|
|
||||||
|
> 🔍 Подробнее про SCD — в отдельной статье [Slowly Changing Dimensions](SCD.md) (сравнение Type 1/2/3, паттерны обновления).
|
||||||
|
|
||||||
|
Теперь, когда мы разобрались, что такое факты, измерения и SCD, давайте посмотрим, как именно можно устроить слой DDS внутри — есть несколько вариантов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Модели данных для слоя DDS: 4 подхода, и когда какой выбрать
|
||||||
|
|
||||||
|
В DDS мы можем хранить данные по-разному. Это не «правильно/неправильно», а **выбор под задачу**.
|
||||||
|
|
||||||
|
### 1. 3NF (третья нормальная форма)
|
||||||
|
*Источник: Билл Инмон (Bill Inmon)*
|
||||||
|
|
||||||
|
Если упростить, 3NF - это когда данные о разных бизнес-сущностях хранятся в отдельных таблицах и связываются ключами: клиент, заказ, город, регион, страна и т.д. Вместо одной большой таблицы с большим числом дублирующихся данных мы получаем цепочку таблиц, связанных ключами: `Заказ → Клиент → Город → Регион → Страна`. JOIN-ов становится больше, зато одно и то же свойство (например, название города) хранится в одном месте, а не дублируется в каждой строке заказа.
|
||||||
|
|
||||||
|
Исторически подход с ядром в 3NF чаще связывают с Биллом Инмоном (Bill Inmon): сначала проектируют корпоративную модель данных ядра в 3NF (сущности, атрибуты, связи), а уже поверх неё строят витрины.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
* Данные о сущностях разнесены по отдельным таблицам: клиент, заказ, продукт.
|
||||||
|
* Минимум дублирования: общие атрибуты хранятся в одном месте, таблицы связаны ключами.
|
||||||
|
|
||||||
|
#### Как выглядел бы наш магазин в 3NF
|
||||||
|
|
||||||
|
В нашем примере `city` лежит прямо в `dim_customer`. В 3NF город стал бы отдельной таблицей, чтобы название хранилось в одном месте:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
dim_city ||--o{ dim_customer : "город"
|
||||||
|
dim_customer ||--o{ fact_sales : "клиент"
|
||||||
|
|
||||||
|
dim_city {
|
||||||
|
int city_id PK
|
||||||
|
varchar city_name "Москва, СПб, ..."
|
||||||
|
}
|
||||||
|
|
||||||
|
dim_customer {
|
||||||
|
bigint customer_sk PK
|
||||||
|
int customer_bk
|
||||||
|
varchar email
|
||||||
|
int city_id FK "ссылка на dim_city"
|
||||||
|
date valid_from
|
||||||
|
date valid_to
|
||||||
|
}
|
||||||
|
|
||||||
|
fact_sales {
|
||||||
|
bigint sale_id PK
|
||||||
|
bigint customer_sk FK
|
||||||
|
int date_key FK
|
||||||
|
int quantity
|
||||||
|
decimal amount
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Теперь, чтобы узнать **«сколько потратил клиент из Москвы за январь 2024?»**, нужно пройти по цепочке:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 3NF: три JOIN, чтобы добраться до города
|
||||||
|
SELECT SUM(f.amount)
|
||||||
|
FROM dds.fact_sales f
|
||||||
|
JOIN dds.dim_customer c ON f.customer_sk = c.customer_sk
|
||||||
|
JOIN dds.dim_city ct ON c.city_id = ct.city_id
|
||||||
|
JOIN dds.dim_date d ON f.date_key = d.date_key
|
||||||
|
WHERE ct.city_name = 'Москва'
|
||||||
|
AND d.year = 2024 AND d.month = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Запрос читаемый, но JOIN-ов уже три - и это для простого вопроса. В реальном ядре цепочка может быть длиннее: `Клиент → Город → Регион → Страна`.
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
|
||||||
|
* Удобно поддерживать **единую «карту бизнеса»**: где живут «клиент», «заказ», «договор» и как они связаны;
|
||||||
|
* Меньше дублирования: одно и то же свойство хранится в одном месте, проще исправлять ошибки и контролировать качество;
|
||||||
|
* Проще собирать разные витрины поверх ядра: внизу держим детальные данные и связи, наверху показываем «как удобно».
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
|
||||||
|
* Если строить отчёты прямо по ядру, запросы часто получаются тяжёлыми: много `JOIN`-ов и условий;
|
||||||
|
* История (SCD Type 2) увеличивает объём данных и добавляет временной контекст в соединения - запросы становятся сложнее и менее удобными для чтения;
|
||||||
|
* Изменения в бизнес-процессах приходится аккуратно встраивать в существующую модель: чем старше ядро, тем дороже большие переделки.
|
||||||
|
|
||||||
|
Частый паттерн: **ядро в 3NF** (подход ближе к Инмону), витрины - в Звезде.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Звезда (Star Schema)
|
||||||
|
*Источник: Ральф Кимболл (Ralph Kimball)*
|
||||||
|
|
||||||
|
Самый распространённый способ построения таблиц для слоя витрин. Именно эту модель мы использовали в [разделе 5](#5-базовые-понятия-факты-измерения-scd): схема `fact_sales` + `dim_date` / `dim_customer` / `dim_product` - это и есть Звезда.
|
||||||
|
|
||||||
|
Структура:
|
||||||
|
|
||||||
|
* в центре - таблица фактов (события и метрики);
|
||||||
|
* вокруг - измерения, обычно денормализованные («плоские») - широкие таблицы со всеми атрибутами сущности. Мы сознательно избегаем цепочек справочников ради простоты запросов.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Методология Ральфа Кимбалла (Ralph Kimball) как раз делает упор на такие звёздные схемы: витрины, которые максимально просты для чтения и понятны аналитикам и BI-инструментам.
|
||||||
|
|
||||||
|
#### Тот же вопрос - в Звезде
|
||||||
|
|
||||||
|
В Звезде `city` лежит прямо в `dim_customer` (денормализовано). Тот же отчёт выглядит проще:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Звезда: два JOIN, город - прямо в измерении
|
||||||
|
SELECT SUM(f.amount)
|
||||||
|
FROM dds.fact_sales f
|
||||||
|
JOIN dds.dim_customer c ON f.customer_sk = c.customer_sk
|
||||||
|
JOIN dds.dim_date d ON f.date_key = d.date_key
|
||||||
|
WHERE c.city = 'Москва'
|
||||||
|
AND d.year = 2024 AND d.month = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
На один JOIN меньше, и не нужно знать, где именно хранится город: он лежит прямо в карточке клиента. Для аналитика или BI-инструмента это большая разница.
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
|
||||||
|
* проста для понимания: аналитикам и BI-инструментам удобно работать с такой моделью;
|
||||||
|
* меньше `JOIN`-ов - запросы обычно проще и быстрее;
|
||||||
|
* хорошо подходит для витрин под конкретные задачи.
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
|
||||||
|
* измерения денормализованы, поэтому атрибуты дублируются (например, название города повторяется у всех клиентов из этого города);
|
||||||
|
* изменения атрибутов могут требовать обновлять много строк в измерении.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Data Vault 2.0: «конструктор Lego» для больших DWH
|
||||||
|
|
||||||
|
*Идея: Дэн Линстедт (Dan Linstedt). Цель — так организовать хранилище, чтобы можно было спокойно добавлять новые источники и хранить историю, не ломая старую модель.*
|
||||||
|
|
||||||
|
#### В чём идея, по-человечески
|
||||||
|
|
||||||
|
В Data Vault (DV) все сущности разлетаются по трём типам таблиц:
|
||||||
|
|
||||||
|
- **Hub (Хаб)** — *«кто/что это»*.
|
||||||
|
Только бизнес-ключ и технические поля: клиент, заказ, договор.
|
||||||
|
|
||||||
|
- **Link (Линк)** — *«как они связаны»*.
|
||||||
|
«Клиент сделал заказ», «договор относится к счёту».
|
||||||
|
|
||||||
|
- **Satellite (Сателлит)** — *«какие у них свойства и как они менялись»*.
|
||||||
|
Имя клиента, email, статус заказа, цены — всё с историей изменений.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
💡 **Главная мысль:**
|
||||||
|
идентичность, связи и атрибуты живут **в разных таблицах**, поэтому:
|
||||||
|
|
||||||
|
- историю проще хранить;
|
||||||
|
- новые источники проще прикручивать;
|
||||||
|
- меньше шансов «сломать» старые отчёты.
|
||||||
|
|
||||||
|
Data Vault хорошо подходит там, где много разнородных источников, нужна полная история изменений и прозрачный аудит. За гибкость приходится платить сложностью модели и количеством таблиц — поэтому для небольших проектов (2–5 источников, маленькая команда) DV почти наверняка избыточен.
|
||||||
|
|
||||||
|
> 🔍 Подробнее про Data Vault — сравнение с 3NF/Звездой, Raw и Business Vault, когда внедрять — в отдельной статье [DataVault: как пережить бурную жизнь источников](DataVault.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Anchor Modeling (анкерное моделирование)
|
||||||
|
|
||||||
|
*Источник: Ларс Рёне (Lars Rönnbäck)*
|
||||||
|
|
||||||
|
Anchor Modeling - ещё более атомарный подход к моделированию ядра, чем Data Vault. Если упростить, он «режет» модель на очень мелкие части, чтобы изменения в атрибутах и связях можно было добавлять почти без переделок схемы.
|
||||||
|
|
||||||
|
Основные типы таблиц:
|
||||||
|
|
||||||
|
* **Anchor** - сущности (например, «клиент» или «заказ»).
|
||||||
|
* **Attribute** - отдельный атрибут сущности, обычно с историей (например, email, город, статус - каждый в своей таблице).
|
||||||
|
* **Tie** - связь между сущностями (например, «клиент ↔ заказ»).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Плюс подхода - высокая гибкость: проще добавлять новые атрибуты и варианты связей. Минус - цена этой гибкости: получается очень много таблиц, и запросы (и поддержка модели) обычно заметно сложнее, огромное кол-во `JOIN`. Для обычного DWH-проекта это точно не первый выбор - скорее вариант для очень динамичных предметных областей, где структура данных часто меняется.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Сравнение моделей наглядно
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
quadrantChart
|
||||||
|
title Где какая модель? (интуитивно)
|
||||||
|
x-axis "Низкая сложность → Высокая сло́жность"
|
||||||
|
y-axis "Низкая гибкость → Высокая гибкость"
|
||||||
|
"Звезда": [0.2, 0.3]
|
||||||
|
"3NF": [0.6, 0.5]
|
||||||
|
"Data Vault": [0.8, 0.8]
|
||||||
|
"Anchor": [0.95, 0.95]
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🎯 **Вывод**: нет «лучшей» модели. Есть **подходящая под контекст**.
|
||||||
|
> — Для обучения — **Звезда** (просто, наглядно).
|
||||||
|
> — Для корпоративного DWH — **3NF + Звезда на выходе**.
|
||||||
|
> — Для масштабируемой интеграции — **Data Vault**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Практикум: как собрать первую витрину
|
||||||
|
|
||||||
|
Покажем на примере `mart_daily_sales` — таблицу, которую можно сразу подключить к BI.
|
||||||
|
|
||||||
|
### Этапы сборки
|
||||||
|
|
||||||
|
1. Из STG → ODS:
|
||||||
|
- `stg.orders_raw` → `ods.orders` (привели `order_date` к `DATE`);
|
||||||
|
2. Из ODS → DDS:
|
||||||
|
- `ods.customers` → `dds.dim_customer` (SCD Type 2);
|
||||||
|
- `ods.products` → `dds.dim_product`;
|
||||||
|
- `ods.orders` + `ods.order_items` → `dds.fact_sales`;
|
||||||
|
3. Из DDS → DM:
|
||||||
|
- `fact_sales` + `dim_*` → `mart_daily_sales`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
%% STG
|
||||||
|
STG_PROD[stg.products_raw]
|
||||||
|
STG_CUST[stg.customers_raw]
|
||||||
|
STG_ORD[stg.orders_raw]
|
||||||
|
STG_ITEMS[stg.order_items_raw]
|
||||||
|
|
||||||
|
%% ODS
|
||||||
|
ODS_PROD[ods.products]
|
||||||
|
ODS_CUST[ods.customers]
|
||||||
|
ODS_ORD[ods.orders]
|
||||||
|
ODS_ITEMS[ods.order_items]
|
||||||
|
|
||||||
|
%% DDS
|
||||||
|
DIM_PROD[dds.dim_product]
|
||||||
|
DIM_CUST[dds.dim_customer]
|
||||||
|
DIM_DATE[dds.dim_date]
|
||||||
|
FACT_SALES[dds.fact_sales]
|
||||||
|
|
||||||
|
%% DM / BI
|
||||||
|
DM_SALES[dm.mart_daily_sales]
|
||||||
|
BI[BI / Power BI]
|
||||||
|
|
||||||
|
%% Потоки данных
|
||||||
|
STG_PROD --> ODS_PROD --> DIM_PROD --> DM_SALES
|
||||||
|
STG_CUST --> ODS_CUST --> DIM_CUST --> DM_SALES
|
||||||
|
|
||||||
|
STG_ORD --> ODS_ORD --> FACT_SALES --> DM_SALES
|
||||||
|
STG_ITEMS --> ODS_ITEMS --> FACT_SALES
|
||||||
|
DIM_DATE --> FACT_SALES
|
||||||
|
|
||||||
|
DM_SALES --> BI
|
||||||
|
```
|
||||||
|
|
||||||
|
### Готовые SQL-скрипты
|
||||||
|
|
||||||
|
Все необходимые скрипты для построения хранилища находятся в папке [`sql/`](https://github.com/dementev-dev/de-roadmap/tree/main/dwh-modeling/sql):
|
||||||
|
|
||||||
|
- [`01_ddl_stg-dds.sql`](sql/01_ddl_stg-dds.sql) — создание схем и таблиц (STG, ODS, DDS);
|
||||||
|
- [`02_dml_stg-dds.sql`](sql/02_dml_stg-dds.sql) — первичная загрузка данных и демонстрация SCD2 через полный пересчёт (`full backfill`) из STG;
|
||||||
|
- [`03_demo_increment.sql`](sql/03_demo_increment.sql) — пример инкрементальной загрузки и SCD2 по последнему снимку в ODS;
|
||||||
|
- [`04_validation.sql`](sql/04_validation.sql) — проверки качества данных;
|
||||||
|
- [`05_ddl_dm.sql`](sql/05_ddl_dm.sql) — создание витрин (Data Marts);
|
||||||
|
- [`06_dml_dm.sql`](sql/06_dml_dm.sql) — наполнение витрин данными.
|
||||||
|
|
||||||
|
### Пример SQL-запроса для витрины
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- mart_daily_sales: ежедневные продажи с сегментацией
|
||||||
|
-- Полная пересборка (full refresh) - для простоты; в продакшене бывает incremental.
|
||||||
|
TRUNCATE dm.mart_daily_sales;
|
||||||
|
|
||||||
|
INSERT INTO dm.mart_daily_sales (
|
||||||
|
date_actual, product_name, customer_segment, total_qty, total_revenue
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
d.date_actual,
|
||||||
|
p.product_name,
|
||||||
|
-- Сегмент определяем по сумме строки (в реальности может быть атрибутом клиента)
|
||||||
|
CASE WHEN f.amount >= 200 THEN 'Premium' ELSE 'Basic' END AS customer_segment,
|
||||||
|
SUM(f.quantity) AS total_qty,
|
||||||
|
SUM(f.amount) AS total_revenue
|
||||||
|
FROM dds.fact_sales f
|
||||||
|
JOIN dds.dim_date d
|
||||||
|
ON f.date_key = d.date_key
|
||||||
|
JOIN dds.dim_product p
|
||||||
|
ON f.product_sk = p.product_sk
|
||||||
|
JOIN dds.dim_customer c
|
||||||
|
ON f.customer_sk = c.customer_sk -- факт ссылается на нужную версию SK
|
||||||
|
GROUP BY d.date_actual, p.product_name,
|
||||||
|
CASE WHEN f.amount >= 200 THEN 'Premium' ELSE 'Basic' END;
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 В продакшене витрину иногда оформляют как **MATERIALIZED VIEW** - «кэш» результата запроса, который обновляется по расписанию. В нашем примере используем обычную таблицу с `TRUNCATE` + `INSERT` - для учебных целей это нагляднее.
|
||||||
|
|
||||||
|
✏️ **Попробуйте сами:** [Домашка: статусы клиента от STG до DDS (и немного DM)](Homework_Customer_Status_DDS_DM.md) — пройдёте тот же путь, но самостоятельно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Как выбрать модель данных? Советы от практиков
|
||||||
|
|
||||||
|
Выбор модели — **не техническая задача, а стратегическая**.
|
||||||
|
Это как решать: строить дом из кирпича, дерева или SIP-панелей. У каждой технологии — свои плюсы, но **главное — подходит ли она *вам* сегодня**.
|
||||||
|
|
||||||
|
### 🔹 Главное, что нужно понять новичку:
|
||||||
|
|
||||||
|
> **Не существует «самой правильной» модели.**
|
||||||
|
> Есть **самая подходящая под ваш контекст** — и он у всех разный.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🛑 Что делать **не стоит** (если опыта ещё мало):
|
||||||
|
|
||||||
|
| Что делать не стоит | Почему |
|
||||||
|
|---------------------|--------|
|
||||||
|
| **Брать Data Vault «потому что модно»** | DV требует глубокого понимания интеграции, CDC, идемпотентности. Без этого легко получить «историю», в которой невозможно найти актуальные данные. |
|
||||||
|
| **Строить сложную 3NF «как в книжках» под 10 таблиц** | Если у вас 2–3 источника — вы потратите недели на нормализацию, чтобы потом делать 5 JOIN’ов ради простого отчёта. |
|
||||||
|
| **Пытаться «сделать сразу гибко на 5 лет вперёд»** | Гибкость = сложность. А сложность = баги, задержки, выгорание команды. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Базовые советы: с чего начать, если вы учитесь или делаете первый DWH
|
||||||
|
|
||||||
|
1. **Начните с витрины в формате Звезды (Star Schema).**
|
||||||
|
— Это просто: одна таблица фактов + несколько «плоских» измерений.
|
||||||
|
— Это быстро: отчёт в BI — за 10 минут.
|
||||||
|
— Это понятно: даже менеджер поймёт структуру.
|
||||||
|
|
||||||
|
2. **Стройте DDS только когда это *действительно нужно*.**
|
||||||
|
— Если источников ≤ 3 и они стабильны — можно идти `ods → dm` напрямую.
|
||||||
|
— Если появляются расхождения («email в CRM и в заказах — разные») — тогда заводите `dds.dim_customer` и другие общие сущности.
|
||||||
|
|
||||||
|
3. **Историю (SCD) включайте *постепенно*.**
|
||||||
|
— Сначала — без истории (Type 1: просто обновляете строку).
|
||||||
|
— Потом — только для ключевых сущностей (клиент, товар, договор).
|
||||||
|
— Только потом — думайте про DV или полную историзацию всего.
|
||||||
|
|
||||||
|
4. **Если сомневаетесь — спросите: «А кто будет этим пользоваться?»**
|
||||||
|
|
||||||
|
Выбор модели начинается не с технологий, а с вопроса: **кто будет работать с результатом?**
|
||||||
|
Это как выбрать инструмент в мастерской: для гвоздей — молоток, для саморезов — отвёртка.
|
||||||
|
|
||||||
|
Вот как это выглядит на практике:
|
||||||
|
|
||||||
|
— **Аналитик в Metabase / Looker Studio** → **Звезда (Star Schema)**
|
||||||
|
Почему: ему нужны готовые метрики без сложных JOIN’ов. Звезда даёт понятные таблицы: «продажи по дням и товарам» — без углубления в атомарные сущности.
|
||||||
|
|
||||||
|
— **BI-разработчик в Power BI / Tableau** → **Звезда**
|
||||||
|
Почему: все инструменты визуализации оптимизированы под star schema. Один факт + несколько измерений = быстрые отчёты и простую модель.
|
||||||
|
|
||||||
|
— **Инженер ML (Data Scientist / ML-инженер)** → **3NF или сырые ODS-таблицы**
|
||||||
|
Почему: для фичей нужны атомарные события и детальные атрибуты. Машинное обучение ценит полноту и детализацию данных больше, чем удобство отчётов.
|
||||||
|
|
||||||
|
— **Юрист, финансовый контролёр, аудитор / регулятор** → **3NF с SCD Type 2, иногда + DV в ядре**
|
||||||
|
Почему: им нужна доказуемая история изменений, но не инфраструктурная сложность DV на каждый чих.
|
||||||
|
Обычно достаточно хранить аудит-историю по ключевым сущностям (клиенты, договоры, счета) в формате 3NF + SCD Type 2.
|
||||||
|
**Data Vault имеет смысл только если** у вас 10+ разнородных источников и жёсткие требования по аудиту и трассировке.
|
||||||
|
|
||||||
|
💡 **Золотое правило:**
|
||||||
|
«**Собирай данные как DV (максимально детально), показывай как Звезду (максимально просто).**»
|
||||||
|
|
||||||
|
На начальных этапах вам почти всегда хватит **SCD Type 2 в рамках 3NF/Звезды**.
|
||||||
|
**Data Vault** нужен тогда, когда основная боль — интеграция множества систем и аудит, а не «первый отчёт для маркетинга».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 💡 Ещё один совет от практиков
|
||||||
|
|
||||||
|
> **Лучше сделать простую модель — и вовремя переделать,**
|
||||||
|
> чем сделать «идеальную» — и застрять на этапе проектирования.
|
||||||
|
|
||||||
|
Переделать Звезду → Звезду с SCD Type 2 — относительно легко.
|
||||||
|
Переделать «недоделанный DV» → что-то рабочее — в разы сложнее.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📌 Кратко: что выбрать *сегодня*, если вы только учитесь
|
||||||
|
|
||||||
|
| У вас… | Делайте… |
|
||||||
|
|--------|----------|
|
||||||
|
| Учебный проект, 1–2 CSV | `ods → dm` по модели **Звезда** (без DDS, без истории) |
|
||||||
|
| Первый рабочий DWH, 3–5 источников | `stg → ods → dds (3NF или простая Звезда) → dm (Звезда)` |
|
||||||
|
| Команда из 1 инженера + 1 аналитика | **Не трогайте DV и Anchor** — они «съедят» ваше время без отдачи |
|
||||||
|
|
||||||
|
А когда наберётесь опыта — приходите в DV. Он того стоит. Но *не раньше времени*.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Эксплуатация: качество данных, это не «опция»
|
||||||
|
|
||||||
|
Самая красивая архитектура бессмысленна, если в `mart_daily_sales` — нули.
|
||||||
|
Поэтому в каждом слое — **контроль качества (DQ, Data Quality)**.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A[Данные поступили] --> B{Проверка качества}
|
||||||
|
|
||||||
|
B --> C["Уникальность: order_id — уникален?"]
|
||||||
|
B --> D["Полнота: email не NULL?"]
|
||||||
|
B --> E["Валидность: order_date — дата?"]
|
||||||
|
B --> F["Свежесть: данные за сегодня?"]
|
||||||
|
|
||||||
|
C --> G{OK?}
|
||||||
|
D --> G
|
||||||
|
E --> G
|
||||||
|
F --> G
|
||||||
|
|
||||||
|
G -->|Да| H[Загрузить в следующий слой]
|
||||||
|
G -->|Нет| I[Оповещение + остановка пайплайна]
|
||||||
|
```
|
||||||
|
|
||||||
|
Примеры проверок (на SQL):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Проверка уникальности order_id в ODS
|
||||||
|
SELECT order_id, COUNT(*)
|
||||||
|
FROM ods.orders
|
||||||
|
GROUP BY order_id
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
-- Проверка свежести: есть ли данные за вчера?
|
||||||
|
SELECT 'OK' WHERE EXISTS (
|
||||||
|
SELECT 1 FROM ods.orders
|
||||||
|
WHERE order_date = CURRENT_DATE - INTERVAL '1 day'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
> 🔔 **Совет**: делайте DQ-тесты частью CI/CD — как unit-тесты в коде.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Заключение: главное, понимать «почему»
|
||||||
|
|
||||||
|
Хранилище данных — это не про «крутые технологии», а про **мышление**:
|
||||||
|
|
||||||
|
- **Слои (STG→ODS→DDS→DM)** — это про *разделение ответственности*.
|
||||||
|
Не смешивайте сырые данные и аналитические — иначе не найдёте, где ошибка.
|
||||||
|
|
||||||
|
- **Факты и измерения** — это про *структуру мышления*.
|
||||||
|
События (факты) и контекст (измерения) — две стороны одного процесса.
|
||||||
|
|
||||||
|
- **SCD Type 2** — это про *уважение к истории*.
|
||||||
|
Бизнес меняется — и данные должны это отражать.
|
||||||
|
|
||||||
|
- **Модели (Star/3NF/DV)** — это про *выбор под задачу*.
|
||||||
|
Нет «серебряной пули» — есть компромиссы.
|
||||||
|
|
||||||
|
> 🎁 **Финальный подарок**:
|
||||||
|
> Запомните **5 золотых правил DWH**:
|
||||||
|
> 1. Всегда храните BK (бизнес-ключ) — иначе потеряете связь с источником.
|
||||||
|
> 2. В DDS — только интегрированные, «чистые» сущности.
|
||||||
|
> 3. В DM — только то, что нужно для отчёта.
|
||||||
|
> 4. Проверяйте качество *на каждом слое*.
|
||||||
|
> 5. Собирайте витрины *итеративно*: MVP → доработка → новые метрики.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Приложения
|
||||||
|
|
||||||
|
### Дополнительные материалы и практика
|
||||||
|
|
||||||
|
- [Домашка: статусы клиента от STG до DDS (и немного DM)](Homework_Customer_Status_DDS_DM.md)
|
||||||
|
- [SCD: как хранить историю изменений](SCD.md)
|
||||||
|
- [DataVault: как пережить бурную жизнь источников](DataVault.md)
|
||||||
|
|
||||||
|
### 📚 Мини-глоссарий (RU / EN)
|
||||||
|
|
||||||
|
| Термин | Пояснение |
|
||||||
|
|-------|-----------|
|
||||||
|
| **Слой (Layer)** | Логический уровень в DWH: STG/ODS/DDS/DM |
|
||||||
|
| **Витрина (Data Mart)** | Готовый набор таблиц для конкретной аналитики (например, финансы или маркетинг) |
|
||||||
|
| **Факт (Fact)** | Таблица событий или измерений: продажи, клики, звонки |
|
||||||
|
| **Измерение (Dimension)** | Справочник контекста: клиенты, товары, дата |
|
||||||
|
| **Суррогатный ключ (SK)** | Искусственный `BIGINT`, генерируемый в DWH |
|
||||||
|
| **Бизнес-ключ (BK)** | Естественный идентификатор из источника (`customer_id`, `order_number`) |
|
||||||
|
| **SCD (Slowly Changing Dimension)** | Подход к хранению истории атрибутов измерения |
|
||||||
|
| **CDC (Change Data Capture)** | Техника инкрементальной загрузки «только изменений» |
|
||||||
|
| **Conformed Dimension** | Измерение, единое для нескольких витрин (например, `dim_date`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🧱 Синонимы слоёв в индустрии
|
||||||
|
|
||||||
|
| Название | Синонимы |
|
||||||
|
|----------|----------|
|
||||||
|
| **STG** | Staging, Raw, Bronze, Landing Zone |
|
||||||
|
| **ODS** | Cleaned, Integrated, Silver |
|
||||||
|
| **DDS** | Core, Conformed, Golden Layer, Enterprise Data Model |
|
||||||
|
| **DM** | Data Mart, Semantic Layer, Gold, Analytics Layer |
|
||||||
|
|
||||||
|
> ⚠️ Названия могут отличаться — смотрите на *содержание*, а не на ярлыки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🚫 Антипаттерны (чего избегать)
|
||||||
|
|
||||||
|
| Антипаттерн | Почему плохо |
|
||||||
|
|-------------|--------------|
|
||||||
|
| **«Одна огромная история заказов»** | Запросы тормозят, нет истории атрибутов (клиент сменил email — и всё прошлое «перекрасилось») |
|
||||||
|
| **STG и ODS в одной таблице** | Невозможно понять: ошибка в источнике или при очистке? |
|
||||||
|
| **Факт с текстовыми атрибутами** (`customer_name` в `fact_sales`) | Дублирование, нарушение нормализации, «спрятанная» бизнес-логика |
|
||||||
|
| **SCD без BK** | История «отвязана» от бизнеса: удалили клиента — и вся его история исчезла |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Мини-датасет (для практики)
|
||||||
|
|
||||||
|
Все данные для практики находятся в папке [`data/`](https://github.com/dementev-dev/de-roadmap/tree/main/dwh-modeling/data) — тренируйтесь:
|
||||||
|
|
||||||
|
[`customers.csv`](data/customers.csv):
|
||||||
|
```csv
|
||||||
|
customer_id,email,phone,city,event_ts,_load_id,_load_ts
|
||||||
|
101,a@ex.com,700,Москва,2024-01-01,batch_20240101_0800,2024-01-01 08:00
|
||||||
|
102,c@ex.com,701,СПб,2024-01-01,batch_20240101_0800,2024-01-01 08:00
|
||||||
|
101,b@ex.com,700,Москва,2024-05-16,batch_20240516_0800,2024-05-16 08:00
|
||||||
|
101,b@ex.com,700,Санкт-Петербург,2024-10-01,batch_20241001_0800,2024-10-01 08:00
|
||||||
|
```
|
||||||
|
|
||||||
|
[`orders.csv`](data/orders.csv):
|
||||||
|
```csv
|
||||||
|
order_id,order_date,customer_id
|
||||||
|
5001,2024-01-10,101
|
||||||
|
5002,2024-02-05,102
|
||||||
|
```
|
||||||
|
|
||||||
|
[`order_items.csv`](data/order_items.csv):
|
||||||
|
```csv
|
||||||
|
order_item_id,order_id,product_id,qty,price_at_sale
|
||||||
|
1,5001,9001,2,100.00
|
||||||
|
2,5001,9002,1,50.00
|
||||||
|
3,5002,9001,1,100.00
|
||||||
|
```
|
||||||
|
|
||||||
|
[`products.csv`](data/products.csv):
|
||||||
|
```csv
|
||||||
|
product_id,name
|
||||||
|
9001,Phone
|
||||||
|
9002,Case
|
||||||
|
```
|
||||||
|
|
||||||
|
[`prices.csv`](data/prices.csv):
|
||||||
|
```csv
|
||||||
|
product_id,valid_from,valid_to,price
|
||||||
|
9001,2023-12-01,2024-01-31,100
|
||||||
|
9001,2024-02-01,,110
|
||||||
|
9002,2023-01-01,,50
|
||||||
|
```
|
||||||
|
|
||||||
|
> 📂 Все SQL-скрипты для построения хранилища находятся в папке [`sql/`](https://github.com/dementev-dev/de-roadmap/tree/main/dwh-modeling/sql).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DDL-скелеты (PostgreSQL)
|
||||||
|
|
||||||
|
Полные DDL-скрипты для всех слоёв хранилища находятся в файле [`01_ddl_stg-dds.sql`](sql/01_ddl_stg-dds.sql).
|
||||||
|
|
||||||
|
Пример структуры основных таблиц DDS:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- DDS: измерение клиента (SCD Type 2)
|
||||||
|
CREATE TABLE dds.dim_customer (
|
||||||
|
customer_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
customer_bk INT NOT NULL, -- напр. 101
|
||||||
|
email VARCHAR(100),
|
||||||
|
phone VARCHAR(20),
|
||||||
|
city VARCHAR(50),
|
||||||
|
valid_from DATE NOT NULL,
|
||||||
|
valid_to DATE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- DDS: факт продаж (гранулярность: строка заказа)
|
||||||
|
CREATE TABLE dds.fact_sales (
|
||||||
|
sale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
customer_sk BIGINT NOT NULL REFERENCES dds.dim_customer(customer_sk),
|
||||||
|
product_sk BIGINT NOT NULL,
|
||||||
|
date_key INT NOT NULL, -- YYYYMMDD, ссылка на dim_date.date_key
|
||||||
|
quantity INT NOT NULL CHECK (quantity > 0),
|
||||||
|
amount DECIMAL(18,2) NOT NULL CHECK (amount >= 0)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 `date_key` — это `20240110`, а не `DATE`, чтобы не делать JOIN по диапазону в `fact → dim_date`.
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
# Медленно меняющиеся измерения (SCD): как хранить историю в аналитических базах данных
|
||||||
|
|
||||||
|
> **Для кого эта статья?**
|
||||||
|
> Для тех, кто уже умеет писать базовые SQL-запросы в PostgreSQL, знаком с понятиями таблиц, строк и колонок, и теперь делает первые шаги в аналитике и проектировании хранилищ данных.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Введение: зачем вообще нужны измерения и почему они «медленно меняются»?
|
||||||
|
|
||||||
|
Представьте, что вы строите отчёт по продажам. У вас есть таблица с фактами — например, «продано 10 единиц товара X клиенту Y 15 марта». Но чтобы понять, *кто такой клиент Y* или *что за товар X*, вам нужны **справочники** — таблицы с описанием клиентов, товаров, регионов и т.п.
|
||||||
|
|
||||||
|
В мире аналитики такие справочники называют **измерениями** (*dimensions*), а таблицу с продажами — **фактами** (*facts*).
|
||||||
|
|
||||||
|
А теперь представьте: клиент сменил адрес или перешёл в другую категорию (например, из «обычного» в «VIP»). Если вы просто обновите строку в таблице клиентов, то потеряете информацию о том, **какой статус у клиента был на момент продажи**. А это критично: отчёт «продажи VIP-клиентам в марте» окажется неверным!
|
||||||
|
|
||||||
|
Такие атрибуты — которые **меняются со временем, но не каждый день** — и называются **медленно меняющимися измерениями** (*Slowly Changing Dimensions*, **SCD**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Что такое Slowly Changing Dimensions (SCD)?
|
||||||
|
|
||||||
|
SCD — это подход к хранению изменений в измерениях **с учётом времени**. Он позволяет отвечать на вопросы вроде:
|
||||||
|
|
||||||
|
- Какой адрес у клиента был **на дату заказа**?
|
||||||
|
- Сколько продаж пришлось на товары категории «Электроника» **до того, как её переименовали в «Гаджеты»**?
|
||||||
|
|
||||||
|
Без SCD вы видите только **текущее состояние**, а с ним — **всю историю**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Типы SCD простыми словами
|
||||||
|
|
||||||
|
Существует несколько стандартных стратегий обработки изменений. Рассмотрим самые важные.
|
||||||
|
|
||||||
|
### **Type 0: никогда не меняется**
|
||||||
|
Атрибут фиксирован навсегда. Например, дата рождения клиента.
|
||||||
|
Такие поля не требуют специальной обработки — они просто не обновляются.
|
||||||
|
|
||||||
|
### **Type 1: просто перезаписать**
|
||||||
|
Вы просто делаете `UPDATE`, и старое значение исчезает.
|
||||||
|
|
||||||
|
✅ Просто.
|
||||||
|
❌ История теряется.
|
||||||
|
|
||||||
|
> Подходит, если изменение — это исправление ошибки (например, опечатка в имени).
|
||||||
|
|
||||||
|
### **Type 2: новая строка для новой версии**
|
||||||
|
Каждое изменение порождает **новую строку** в таблице. Старая строка остаётся, но помечается как «устаревшая».
|
||||||
|
|
||||||
|
✅ Полная история.
|
||||||
|
✅ Можно восстановить состояние на любую дату.
|
||||||
|
❌ Больше данных, сложнее запросы.
|
||||||
|
|
||||||
|
> Это **самый распространённый** подход в аналитике.
|
||||||
|
|
||||||
|
### **Type 3: добавить колонку «предыдущее значение»**
|
||||||
|
В таблице появляются поля вроде `previous_category`, `category_change_date`.
|
||||||
|
|
||||||
|
✅ Простая история «до/после».
|
||||||
|
❌ Хранит только **одно** предыдущее значение. Не масштабируется.
|
||||||
|
|
||||||
|
> Используется редко, чаще как компромисс в очень простых системах.
|
||||||
|
|
||||||
|
### **Type 4, 5, 6: продвинутые гибриды**
|
||||||
|
Эти типы существуют, но **встречаются редко** и почти не используются новичками:
|
||||||
|
|
||||||
|
- **Type 4**: история выносится в отдельную таблицу («мини-хранилище» для одного измерения).
|
||||||
|
- **Type 5**: комбинация Type 4 и Type 1 — текущее значение в основной таблице, а история — отдельно.
|
||||||
|
- **Type 6**: объединяет Type 1, 2 и 3 в одной таблице — очень гибко, но сложно.
|
||||||
|
|
||||||
|
> Вам **не нужно запоминать** эти типы сейчас. Достаточно знать, что они бывают — на случай, если встретите их в документации.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Практический пример на PostgreSQL: Type 1 vs Type 2
|
||||||
|
|
||||||
|
Допустим, у нас есть таблица клиентов:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Исходная таблица (до изменений)
|
||||||
|
CREATE TABLE customers (
|
||||||
|
customer_id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL -- например: 'Regular', 'VIP'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type 1: просто обновляем
|
||||||
|
|
||||||
|
Клиент №1 стал VIP:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE customers
|
||||||
|
SET category = 'VIP'
|
||||||
|
WHERE customer_id = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Теперь в таблице только новое значение. Если продажа была сделана **до** этого UPDATE, вы не узнаете, что клиент тогда был «Regular».
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Type 2: сохраняем историю (подробнее)
|
||||||
|
|
||||||
|
Чтобы хранить историю, мы меняем структуру таблицы. Вот ключевые поля:
|
||||||
|
|
||||||
|
- **`customer_key`** — искусственный (surrogate) первичный ключ. Он **уникален для каждой версии** клиента.
|
||||||
|
- **`customer_id`** — бизнес-идентификатор (например, из CRM). Он **не меняется** и связывает все версии одного клиента.
|
||||||
|
- **`valid_from`** — дата, **с которой** эта версия стала актуальной.
|
||||||
|
- **`valid_to`** — дата, **по которую** эта версия была актуальной. Если `NULL` — значит, версия **актуальна сейчас**.
|
||||||
|
|
||||||
|
Пример структуры:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE customers_scd2 (
|
||||||
|
customer_key SERIAL PRIMARY KEY, -- уникальный ID каждой версии
|
||||||
|
customer_id INT NOT NULL, -- неизменный бизнес-ID клиента
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
valid_from DATE NOT NULL, -- с какой даты действует
|
||||||
|
valid_to DATE -- по какую дату действовала (NULL = сейчас)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Шаг 1.** Добавляем начальную запись (клиент зарегистрировался 1 января 2024):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
|
||||||
|
VALUES (1, 'Иван Петров', 'Regular', DATE '2024-01-01', NULL);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Шаг 2.** 15 апреля 2025 клиент становится VIP. Мы делаем **два действия**:
|
||||||
|
|
||||||
|
1. **Закрываем старую запись**: указываем дату начала новой версии (интервал `[valid_from, valid_to)`).
|
||||||
|
2. **Добавляем новую запись**: она начинает действовать **с 15 апреля** и пока актуальна.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. Завершаем предыдущую версию
|
||||||
|
UPDATE customers_scd2
|
||||||
|
SET valid_to = DATE '2025-04-15'
|
||||||
|
WHERE customer_id = 1 AND valid_to IS NULL;
|
||||||
|
|
||||||
|
-- 2. Вставляем новую версию
|
||||||
|
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
|
||||||
|
VALUES (1, 'Иван Петров', 'VIP', DATE '2025-04-15', NULL);
|
||||||
|
```
|
||||||
|
|
||||||
|
Теперь в таблице две строки для одного клиента. И мы можем спросить:
|
||||||
|
|
||||||
|
> Какой была категория клиента **на 10 апреля 2025**?
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT category
|
||||||
|
FROM customers_scd2
|
||||||
|
WHERE customer_id = 1
|
||||||
|
AND DATE '2025-04-10' >= valid_from
|
||||||
|
AND (valid_to IS NULL OR DATE '2025-04-10' < valid_to);
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат: `'Regular'` — правильно!
|
||||||
|
|
||||||
|
> 💡 Почему не `BETWEEN valid_from AND valid_to`?
|
||||||
|
> Потому что у актуальной записи `valid_to IS NULL`. В SQL сравнения с `NULL` не дают `TRUE`, поэтому для “текущей” версии обычно пишут `valid_to IS NULL OR ...`.
|
||||||
|
|
||||||
|
### Type 2 через логику, похожую на UPSERT
|
||||||
|
|
||||||
|
В реальных ETL-процессах часто используют **идемпотентные** операции: запуск скрипта дважды не должен ломать данные. Для этого удобно применять подход, похожий на *upsert* (update + insert), но адаптированный под логику SCD Type 2.
|
||||||
|
|
||||||
|
В PostgreSQL классический `ON CONFLICT` не подходит напрямую, потому что мы **не обновляем существующую строку**, а **добавляем новую при изменении**.
|
||||||
|
|
||||||
|
Поэтому логика выглядит так:
|
||||||
|
|
||||||
|
1. Сравнить входящие данные с последней версией в таблице.
|
||||||
|
2. Если атрибуты изменились — закрыть старую запись и вставить новую.
|
||||||
|
3. Если не изменились — ничего не делать.
|
||||||
|
|
||||||
|
Пример (часто реализуется в Python, dbt, Airflow и т.п.):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Предположим, новая версия: customer_id=1, category='VIP', effective_date='2025-04-15'
|
||||||
|
|
||||||
|
-- Шаг 1: вставляем новую версию, только если есть изменения
|
||||||
|
WITH last_version AS (
|
||||||
|
SELECT * FROM customers_scd2
|
||||||
|
WHERE customer_id = 1 AND valid_to IS NULL
|
||||||
|
)
|
||||||
|
INSERT INTO customers_scd2 (customer_id, name, category, valid_from, valid_to)
|
||||||
|
SELECT 1, 'Иван Петров', 'VIP', DATE '2025-04-15', NULL
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM last_version WHERE category != 'VIP'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Шаг 2: если вставка произошла — закрываем старую запись
|
||||||
|
UPDATE customers_scd2
|
||||||
|
SET valid_to = DATE '2025-04-15'
|
||||||
|
WHERE customer_id = 1 AND valid_to IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM customers_scd2
|
||||||
|
WHERE customer_id = 1 AND category = 'VIP' AND valid_from = DATE '2025-04-15'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
На практике такие логики чаще выносят в **ETL-инструменты** (например, dbt с пакетом `dbt-scd`), потому что чистый SQL быстро становится громоздким.
|
||||||
|
|
||||||
|
> 💡 Главное: **SCD Type 2 — это не одна операция, а процесс**: сравнить → закрыть старое → добавить новое.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
В учебном проекте из папки `dwh-modeling/sql/` эти идеи можно увидеть «вживую»:
|
||||||
|
|
||||||
|
- в [`02_dml_stg-dds.sql`](sql/02_dml_stg-dds.sql) собирается полная история клиентов (SCD2) из всех событий в `stg.customers_raw` — это пример **первичной загрузки** / `full backfill`;
|
||||||
|
- в [`03_demo_increment.sql`](sql/03_demo_increment.sql) реализован **инкрементальный SCD2**: в одной транзакции добавляются новые версии клиентов из снимка `ods.customers` и закрываются предыдущие актуальные строки в `dds.dim_customer`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### А что, если СУБД не позволяет UPDATE? (Trino, Hive, ClickHouse в режиме append-only)
|
||||||
|
|
||||||
|
Некоторые аналитические системы (например, **Hive в формате ORC/Parquet**, **Trino**, **ClickHouse в режиме только вставки**) **не поддерживают UPDATE старых строк**. Как тогда реализовать SCD Type 2?
|
||||||
|
|
||||||
|
Ответ: **никаких UPDATE не нужно** — ведь в Type 2 мы и так **не меняем старые данные**, а только **добавляем новые**!
|
||||||
|
|
||||||
|
Алгоритм загрузки (ETL):
|
||||||
|
1. Сравнить входящие данные с последней версией в таблице.
|
||||||
|
2. Если есть изменения — **пишем новую строку** с новыми `valid_from`.
|
||||||
|
3. Старые строки остаются нетронутыми.
|
||||||
|
|
||||||
|
Пример в Trino/Hive-стиле (только INSERT):
|
||||||
|
|
||||||
|
Давайте разберём этот запрос по шагам, чтобы понять его логику:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- new_customers — staging-таблица с обновлёнными данными
|
||||||
|
-- dim_customers_scd2 — основная таблица (append-only)
|
||||||
|
|
||||||
|
INSERT INTO dim_customers_scd2
|
||||||
|
WITH current_customers AS (
|
||||||
|
SELECT *
|
||||||
|
FROM (
|
||||||
|
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn
|
||||||
|
FROM dim_customers_scd2
|
||||||
|
)
|
||||||
|
WHERE rn = 1 -- отбираем первую, самую свежую, запись
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
uuid() AS customer_key, -- уникальный ID каждой версии
|
||||||
|
n.customer_id, -- неизменный бизнес-ID клиента
|
||||||
|
n.name,
|
||||||
|
n.category,
|
||||||
|
COALESCE(n.effective_date, current_date) AS valid_from -- дата начала действия новой версии
|
||||||
|
FROM new_customers n
|
||||||
|
LEFT JOIN current_customers c ON n.customer_id = c.customer_id
|
||||||
|
WHERE c.customer_id IS NULL OR c.category != n.category; -- условие верно, если какие-то из полей справочника поменялись
|
||||||
|
```
|
||||||
|
|
||||||
|
##### 🎯 Как работает этот запрос: пошаговое объяснение
|
||||||
|
|
||||||
|
###### Шаг 1: Подготовка данных (CTE current_customers)
|
||||||
|
CTE `current_customers` находит **последнюю версию** каждого клиента из таблицы `dim_customers_scd2`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT *, ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn
|
||||||
|
FROM dim_customers_scd2
|
||||||
|
```
|
||||||
|
|
||||||
|
- `PARTITION BY customer_id` — группируем по клиентам
|
||||||
|
- `ORDER BY valid_from DESC` — сортируем версии от самой новой к самой старой
|
||||||
|
- `WHERE rn = 1` — выбираем только самую свежую версию
|
||||||
|
|
||||||
|
###### Шаг 2: Сравнение данных (LEFT JOIN + WHERE)
|
||||||
|
Теперь сравниваем новые данные с текущими:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
FROM new_customers n
|
||||||
|
LEFT JOIN current_customers c ON n.customer_id = c.customer_id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Возможные сценарии после JOIN:**
|
||||||
|
|
||||||
|
| Сценарий | n.customer_id | c.customer_id | Условие WHERE | Результат |
|
||||||
|
|----------|---------------|---------------|---------------|-----------|
|
||||||
|
| Новый клиент | 2 | NULL | ✅ `c.customer_id IS NULL` | Вставляется |
|
||||||
|
| Категория изменилась | 1 | 1 | ✅ `c.category != n.category` | Вставляется |
|
||||||
|
| Без изменений | 3 | 3 | ❌ оба условия ложны | Пропускается |
|
||||||
|
|
||||||
|
###### Шаг 3: Вставка новых версий
|
||||||
|
Для подходящих записей создаём новую версию:
|
||||||
|
|
||||||
|
- `uuid()` — генерируем уникальный ключ для новой версии
|
||||||
|
- `current_date` - функция, возвращающая текущую даты
|
||||||
|
- `COALESCE(n.effective_date, current_date)` — устанавливаем дату начала действия новой версии
|
||||||
|
|
||||||
|
> 💡 **Правильный подход к датам**: В реальных ETL-процессах важно использовать дату из исходных данных, когда она доступна. Мы используем `COALESCE(n.effective_date, current_date)`, что означает:
|
||||||
|
> - Если в `new_customers` есть поле `effective_date` — используем его
|
||||||
|
> - Если нет — используем текущую дату (`current_date`)
|
||||||
|
>
|
||||||
|
> **Почему это важно:**
|
||||||
|
> - `effective_date` отражает реальную дату изменения (например, когда клиент стал VIP)
|
||||||
|
> - `current_date` — это дата загрузки данных, которая может не совпадать с датой изменения
|
||||||
|
> - Использование правильной даты критично для точного исторического анализа
|
||||||
|
>
|
||||||
|
> **Пример правильной структуры исходных данных:**
|
||||||
|
> ```sql
|
||||||
|
> new_customers:
|
||||||
|
> customer_id | name | category | effective_date
|
||||||
|
> 1 | Иван Петров | VIP | 2025-04-15 ← дата реального изменения
|
||||||
|
> ```
|
||||||
|
|
||||||
|
##### Практический пример
|
||||||
|
|
||||||
|
**До выполнения запроса:**
|
||||||
|
```
|
||||||
|
dim_customers_scd2:
|
||||||
|
customer_id | name | category | valid_from
|
||||||
|
1 | Иван Петров | Regular | 2024-01-01
|
||||||
|
```
|
||||||
|
|
||||||
|
**Новые данные:**
|
||||||
|
```
|
||||||
|
new_customers:
|
||||||
|
customer_id | name | category
|
||||||
|
1 | Иван Петров | VIP ← изменилась категория
|
||||||
|
2 | Мария Иванова| Regular ← новый клиент
|
||||||
|
```
|
||||||
|
|
||||||
|
**После выполнения запроса:**
|
||||||
|
```
|
||||||
|
dim_customers_scd2:
|
||||||
|
customer_id | name | category | valid_from
|
||||||
|
1 | Иван Петров | Regular | 2024-01-01 ← старая версия
|
||||||
|
1 | Иван Петров | VIP | 2025-11-04 ← новая версия
|
||||||
|
2 | Мария Иванова| Regular | 2025-11-04 ← новый клиент
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 **Ключевой момент**: В append-only системах мы **не обновляем** старые записи, а только **добавляем новые**. История сохраняется автоматически!
|
||||||
|
|
||||||
|
##### Главный вопрос после загрузки: как же читать эти данные?
|
||||||
|
|
||||||
|
Поскольку мы не можем обновлять `valid_to` у предыдущей версии, стандартный подход с `BETWEEN` не сработает. Вместо этого, для поиска нужной версии мы полагаемся на **оконные функции** или на логику «ближайшей даты, но не позже».
|
||||||
|
|
||||||
|
###### Паттерн 1: Найти последнюю (актуальную) версию на сегодня
|
||||||
|
|
||||||
|
Это самый частый запрос. Мы хотим видеть самую свежую информацию о клиенте.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Вариант с оконной функцией (универсальный и надежный)
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn
|
||||||
|
FROM dim_customers_scd2
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
customer_id, name, category, valid_from
|
||||||
|
FROM ranked
|
||||||
|
WHERE rn = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот запрос берёт строку с самой поздней датой начала действия для каждого клиента.
|
||||||
|
|
||||||
|
###### Паттерн 2: Найти версию, которая была актуальна на конкретную дату
|
||||||
|
|
||||||
|
Это основная цель SCD2. Например, «какой статус клиента был на дату заказа `2025-03-15`?».
|
||||||
|
|
||||||
|
В append-only мире у нас нет `valid_to`, поэтому мы ищем **последнюю версию, у которой `valid_from` ≤ целевой даты**.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Запрос для получения состояния на '2025-03-15'
|
||||||
|
WITH as_of_date AS (
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY valid_from DESC) AS rn
|
||||||
|
FROM dim_customers_scd2
|
||||||
|
WHERE valid_from <= DATE '2025-03-15'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
customer_id, name, category, valid_from
|
||||||
|
FROM as_of_date
|
||||||
|
WHERE rn = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Как это работает:**
|
||||||
|
1. `WHERE valid_from <= DATE '2025-03-15'` отфильтровывает все версии, которые появились **после** нашей целевой даты.
|
||||||
|
2. `ORDER BY valid_from DESC` сортирует оставшиеся версии от самой свежей к самой старой.
|
||||||
|
3. `ROW_NUMBER() ... WHERE rn = 1` выбирает самую свежую из **актуальных на ту дату** версий.
|
||||||
|
|
||||||
|
Это и есть «путешествие во времени» (time travel) в системах без встроенной поддержки этой функции.
|
||||||
|
|
||||||
|
> **Почему не использовать флаг «текущая версия»?**
|
||||||
|
> В append-only системах любой флаг актуальности становится устаревшим сразу после новой вставки. Без `UPDATE` его сложно поддерживать, поэтому в таких архитектурах чаще полагаются на даты и оконные функции — так модель остаётся идемпотентной.
|
||||||
|
|
||||||
|
Таким образом, SCD Type 2 не только совместим с append-only системами, но и является для них **естественным выбором**, так как его логика основана исключительно на добавлении данных, а не на их изменении.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Когда что использовать?
|
||||||
|
|
||||||
|
| Сценарий | Рекомендуемый тип |
|
||||||
|
|--------|------------------|
|
||||||
|
| Исправление опечатки | Type 1 |
|
||||||
|
| Юридически значимые изменения (статус, тариф, регион) | Type 2 |
|
||||||
|
| Очень простая аналитика без требований к истории | Type 1 |
|
||||||
|
| Нужна только «последняя смена» и ничего больше | Type 3 (осторожно!) |
|
||||||
|
|
||||||
|
**Совет новичку**: если сомневаетесь — выбирайте **Type 2**. Лучше иметь историю и не использовать её, чем не иметь и не суметь ответить на важный вопрос.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Подводные камни и советы
|
||||||
|
|
||||||
|
- **Не используйте `customer_id` как первичный ключ в Type 2**. Он повторяется! Вместо этого — `customer_key` (surrogate key).
|
||||||
|
- Всегда задавайте `valid_to` как `NULL` для актуальной записи, если это допустимо в вашей СУБД — это упрощает модель.
|
||||||
|
- Для условий “актуально на дату” используйте паттерн: `d >= valid_from AND (valid_to IS NULL OR d < valid_to)`.
|
||||||
|
- Type 2 увеличивает объём данных — но для аналитики это нормально.
|
||||||
|
- В связке с фактами: в таблице фактов храните **`customer_key`**, а не `customer_id` — иначе не получится соединить с нужной версией.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Заключение
|
||||||
|
|
||||||
|
Медленно меняющиеся измерения — это не «магия», а **практический инструмент** для честной и точной аналитики во времени.
|
||||||
|
|
||||||
|
Начните с понимания разницы между Type 1 и Type 2. Попробуйте реализовать оба подхода в своей БД. Задайте себе вопрос:
|
||||||
|
> «Если бы я построил отчёт по данным на прошлый месяц — дал бы он правильный ответ после сегодняшнего изменения?»
|
||||||
|
|
||||||
|
Если нет — вам нужен SCD Type 2.
|
||||||
|
|
||||||
|
И помните: даже в системах без `UPDATE` вы можете хранить полную историю — достаточно понимать, как правильно читать данные с помощью оконных функций и временных границ.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# TODO: dwh-modeling
|
||||||
|
|
||||||
|
## SCD2 (dim_customer / dim_customer_status)
|
||||||
|
|
||||||
|
- Добавить в `02_dml_stg-dds.sql` (блок SCD2 backfill) и `03_demo_increment.sql` (incremental) явные допущения:
|
||||||
|
- гранулярность `DATE` (daily-grain), интервалы `[valid_from, valid_to)`, current = `valid_to IS NULL`;
|
||||||
|
- предполагаем **не более одного изменения в день** на BK (иначе нужен `TIMESTAMP`/sequence);
|
||||||
|
- `valid_from` берём как **effective date**: `COALESCE(event_ts, _load_ts)::date` (и почему так);
|
||||||
|
- late-arriving/backdated события в демо **не обрабатываются** (что будет “в проде”).
|
||||||
|
- Коротко документировать “effective time vs load time”:
|
||||||
|
- `event_ts` = когда изменение произошло в источнике;
|
||||||
|
- `_load_ts` = когда событие попало в DWH;
|
||||||
|
- `valid_from/valid_to` строим по effective time, а `_load_id/_load_ts` используем для трассировки/аудита.
|
||||||
|
- (Опционально) Добавить микросекцию “как читать CTE” в SCD2-блоках: что делает `src → ordered → changes → framed`.
|
||||||
|
|
||||||
|
## Вариант с TIMESTAMP (advanced, под вопросом)
|
||||||
|
|
||||||
|
- Подумать над отдельным примером SCD2 с `valid_from_ts/valid_to_ts TIMESTAMP`:
|
||||||
|
- кейс “несколько изменений в один день”;
|
||||||
|
- корректная обработка одинаковых `event_ts` (tie-breaker: `_load_ts`/`_load_id`);
|
||||||
|
- влияние на join фактов (условие по `[from,to)`).
|
||||||
|
- Зафиксировать: показываем как “опционально/advanced”, чтобы не пугать на базовом треке.
|
||||||
|
|
||||||
|
## Greenplum (после Postgres-трека)
|
||||||
|
|
||||||
|
- Отдельно проговорить практику для больших объёмов:
|
||||||
|
- обновления SCD2 в GP могут быть дорогими; обсудить паттерны (partitioning/append-only/минимизация UPDATE);
|
||||||
|
- какие поля выбирать для распределения и сортировки таблиц измерений/фактов (на уровне рекомендаций).
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
customer_id,status,event_ts,_load_id,_load_ts
|
||||||
|
101,new,2024-01-01 09:00:00,batch_20240101_1000,2024-01-01 10:00:00
|
||||||
|
101,active,2024-02-15 10:30:00,batch_20240215_1100,2024-02-15 11:00:00
|
||||||
|
101,vip,2024-05-10 11:00:00,batch_20240510_1200,2024-05-10 12:00:00
|
||||||
|
101,churned,2024-09-01 12:15:00,batch_20240901_1300,2024-09-01 13:00:00
|
||||||
|
102,new,2024-03-05 14:00:00,batch_20240305_1500,2024-03-05 15:00:00
|
||||||
|
102,active,2024-04-01 09:45:00,batch_20240401_1000,2024-04-01 10:00:00
|
||||||
|
102,churned,2024-04-20 16:20:00,batch_20240420_1700,2024-04-20 17:00:00
|
||||||
|
103,new,2024-03-10 10:10:00,batch_20240310_1100,2024-03-10 11:00:00
|
||||||
|
@@ -0,0 +1,5 @@
|
|||||||
|
customer_id,status,event_ts,_load_id,_load_ts
|
||||||
|
101,active,2024-11-15 09:00:00,batch_20241115_1000,2024-11-15 10:00:00
|
||||||
|
102,active,2024-05-05 09:30:00,batch_20240505_1000,2024-05-05 10:00:00
|
||||||
|
103,active,2024-03-20 12:00:00,batch_20240320_1300,2024-03-20 13:00:00
|
||||||
|
104,new,2024-06-01 08:00:00,batch_20240601_0900,2024-06-01 09:00:00
|
||||||
|
@@ -0,0 +1,5 @@
|
|||||||
|
customer_id,email,phone,city,event_ts,_load_id,_load_ts
|
||||||
|
101,a@ex.com,700,Москва,2024-01-01,batch_20240101_0800,2024-01-01 08:00
|
||||||
|
102,c@ex.com,701,СПб,2024-01-01,batch_20240101_0800,2024-01-01 08:00
|
||||||
|
101,b@ex.com,700,Москва,2024-05-16,batch_20240516_0800,2024-05-16 08:00
|
||||||
|
101,b@ex.com,700,Санкт-Петербург,2024-10-01,batch_20241001_0800,2024-10-01 08:00
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
order_item_id,order_id,product_id,qty,price_at_sale
|
||||||
|
1,5001,9001,2,100.00
|
||||||
|
2,5001,9002,1,50.00
|
||||||
|
3,5002,9001,1,100.00
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
order_id,order_date,customer_id
|
||||||
|
5001,2024-01-10,101
|
||||||
|
5002,2024-02-05,102
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
product_id,valid_from,valid_to,price
|
||||||
|
9001,2023-12-01,2024-01-31,100.00
|
||||||
|
9001,2024-02-01,,110.00
|
||||||
|
9002,2023-01-01,,50.00
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
product_id,name
|
||||||
|
9001,Phone
|
||||||
|
9002,Case
|
||||||
|
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
@@ -0,0 +1,155 @@
|
|||||||
|
-- ===============================================
|
||||||
|
-- DDL-скрипт: определение структуры хранилища
|
||||||
|
-- Запускается ОДИН РАЗ при инициализации БД
|
||||||
|
-- или при изменении схемы (миграции)
|
||||||
|
-- ===============================================
|
||||||
|
|
||||||
|
-- 1. Схемы
|
||||||
|
DROP SCHEMA IF EXISTS stg CASCADE;
|
||||||
|
DROP SCHEMA IF EXISTS ods CASCADE;
|
||||||
|
DROP SCHEMA IF EXISTS dds CASCADE;
|
||||||
|
|
||||||
|
CREATE SCHEMA stg;
|
||||||
|
CREATE SCHEMA ods;
|
||||||
|
CREATE SCHEMA dds;
|
||||||
|
|
||||||
|
-- 2. STG: сырые данные (как пришли)
|
||||||
|
DROP TABLE IF EXISTS stg.customers_raw;
|
||||||
|
CREATE TABLE stg.customers_raw (
|
||||||
|
customer_id TEXT, -- может быть строкой или числом
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
city TEXT,
|
||||||
|
event_ts TEXT,
|
||||||
|
_load_id TEXT, -- идентификатор загрузки (обязательно!)
|
||||||
|
_load_ts TIMESTAMP DEFAULT NOW() -- время получения в DWH
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE stg.orders_raw (
|
||||||
|
order_id TEXT,
|
||||||
|
order_date TEXT,
|
||||||
|
customer_id TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE stg.order_items_raw (
|
||||||
|
order_item_id TEXT,
|
||||||
|
order_id TEXT,
|
||||||
|
product_id TEXT,
|
||||||
|
qty TEXT,
|
||||||
|
price_at_sale TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE stg.products_raw (
|
||||||
|
product_id TEXT,
|
||||||
|
name TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. ODS: очищенные данные
|
||||||
|
DROP TABLE IF EXISTS ods.customers;
|
||||||
|
CREATE TABLE ods.customers (
|
||||||
|
customer_id INT NOT NULL, -- привели к INT
|
||||||
|
email VARCHAR(100),
|
||||||
|
phone VARCHAR(20),
|
||||||
|
city VARCHAR(50),
|
||||||
|
event_ts TIMESTAMP,
|
||||||
|
_load_id TEXT NOT NULL, -- сохраняем для отладки и SCD
|
||||||
|
_load_ts TIMESTAMP NOT NULL -- время загрузки (копия из STG)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ods.orders (
|
||||||
|
order_id INT,
|
||||||
|
order_date DATE,
|
||||||
|
customer_id INT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ods.order_items (
|
||||||
|
order_item_id INT,
|
||||||
|
order_id INT,
|
||||||
|
product_id INT,
|
||||||
|
qty INT,
|
||||||
|
price_at_sale NUMERIC(10,2)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ods.products (
|
||||||
|
product_id INT,
|
||||||
|
name VARCHAR(100)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Первичные ключи в ODS (для ускорения и валидации)
|
||||||
|
ALTER TABLE ods.customers ADD PRIMARY KEY (customer_id);
|
||||||
|
ALTER TABLE ods.orders ADD PRIMARY KEY (order_id);
|
||||||
|
ALTER TABLE ods.order_items ADD PRIMARY KEY (order_item_id);
|
||||||
|
ALTER TABLE ods.products ADD PRIMARY KEY (product_id);
|
||||||
|
|
||||||
|
-- 4. DDS: интегрированная модель
|
||||||
|
|
||||||
|
-- dim_date: справочник дат (ключ — суррогатный date_key)
|
||||||
|
CREATE TABLE dds.dim_date (
|
||||||
|
date_key INT PRIMARY KEY,
|
||||||
|
date_actual DATE NOT NULL,
|
||||||
|
year SMALLINT,
|
||||||
|
quarter SMALLINT,
|
||||||
|
month SMALLINT,
|
||||||
|
day SMALLINT,
|
||||||
|
weekday_name VARCHAR(10),
|
||||||
|
weekday_num SMALLINT,
|
||||||
|
is_first_week BOOLEAN
|
||||||
|
);
|
||||||
|
|
||||||
|
-- dim_product: измерение "Товар"
|
||||||
|
CREATE TABLE dds.dim_product (
|
||||||
|
product_sk BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
product_bk INT NOT NULL,
|
||||||
|
product_name VARCHAR(100) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- dim_customer: измерение "Клиент" с историей (SCD Type 2)
|
||||||
|
CREATE TABLE dds.dim_customer (
|
||||||
|
customer_sk BIGSERIAL PRIMARY KEY,
|
||||||
|
customer_bk INT NOT NULL, -- бизнес-ключ
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
city TEXT,
|
||||||
|
hashdiff TEXT NOT NULL, -- md5 по нормализованным атрибутам
|
||||||
|
valid_from DATE NOT NULL,
|
||||||
|
valid_to DATE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- одна версия на момент времени (на одну пару BK+valid_from)
|
||||||
|
ALTER TABLE dds.dim_customer
|
||||||
|
ADD CONSTRAINT uq_dim_customer_bk_from UNIQUE (customer_bk, valid_from);
|
||||||
|
|
||||||
|
-- ускорители
|
||||||
|
CREATE INDEX ix_dim_customer_bk_current ON dds.dim_customer (customer_bk) WHERE valid_to IS NULL;
|
||||||
|
CREATE INDEX ix_dim_customer_bk_from_to ON dds.dim_customer (customer_bk, valid_from, valid_to);
|
||||||
|
|
||||||
|
-- fact_sales: факт "Продажи"
|
||||||
|
CREATE TABLE dds.fact_sales (
|
||||||
|
sale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
customer_sk BIGINT NOT NULL,
|
||||||
|
product_sk BIGINT NOT NULL,
|
||||||
|
date_key INT NOT NULL,
|
||||||
|
quantity INT NOT NULL CHECK (quantity > 0),
|
||||||
|
amount NUMERIC(18,2) NOT NULL CHECK (amount >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Внешние ключи (опционально — в продакшене часто отключают ради скорости)
|
||||||
|
ALTER TABLE dds.fact_sales
|
||||||
|
ADD CONSTRAINT fk_fact_customer FOREIGN KEY (customer_sk) REFERENCES dds.dim_customer(customer_sk),
|
||||||
|
ADD CONSTRAINT fk_fact_product FOREIGN KEY (product_sk) REFERENCES dds.dim_product(product_sk),
|
||||||
|
ADD CONSTRAINT fk_fact_date FOREIGN KEY (date_key) REFERENCES dds.dim_date(date_key);
|
||||||
|
|
||||||
|
|
||||||
|
-- Через md5 по нормализованным атрибутам
|
||||||
|
CREATE OR REPLACE FUNCTION dds.customer_hash(email TEXT, phone TEXT, city TEXT)
|
||||||
|
RETURNS TEXT LANGUAGE sql IMMUTABLE AS $$
|
||||||
|
SELECT md5(
|
||||||
|
concat_ws('||',
|
||||||
|
lower(coalesce(trim(email), '')),
|
||||||
|
lower(coalesce(trim(phone), '')),
|
||||||
|
lower(coalesce(trim(city), ''))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
-- ===============================================
|
||||||
|
-- DML-скрипт: загрузка и трансформация данных
|
||||||
|
-- Запускается ПОВТОРНО при каждой загрузке (идемпотентно!)
|
||||||
|
-- ===============================================
|
||||||
|
--
|
||||||
|
-- Карта загрузки в этом скрипте:
|
||||||
|
-- STG → ODS: orders, order_items, products, customers (снимок: одна строка на BK)
|
||||||
|
-- STG → DDS: dim_customer (SCD2, full backfill напрямую из STG — см. комментарий к п.5)
|
||||||
|
-- ODS → DDS: dim_product, fact_sales
|
||||||
|
-- отдельно: dim_date (генерация календаря)
|
||||||
|
--
|
||||||
|
|
||||||
|
-- 1. STG: имитация загрузки из источников (в реальности — COPY или INSERT из Kafka/NiFi)
|
||||||
|
-- ⚠️ В продакшене STG часто очищается перед загрузкой (TRUNCATE), либо используется партицирование по дате
|
||||||
|
|
||||||
|
DELETE FROM stg.customers_raw;
|
||||||
|
DELETE FROM stg.orders_raw;
|
||||||
|
DELETE FROM stg.order_items_raw;
|
||||||
|
DELETE FROM stg.products_raw;
|
||||||
|
|
||||||
|
-- STG (пример вставки с метками времени)
|
||||||
|
INSERT INTO stg.customers_raw (_load_id, _load_ts, event_ts, customer_id, email, phone, city) VALUES
|
||||||
|
('batch_20240101_0800', '2024-01-01 08:00', '2024-01-01', '101','a@ex.com','700','Москва'),
|
||||||
|
('batch_20240101_0800', '2024-01-01 08:00', '2024-01-01', '102','c@ex.com','701','СПб'),
|
||||||
|
('batch_20240516_0800', '2024-05-16 08:00', '2024-05-16', '101','b@ex.com','700','Москва'),
|
||||||
|
('batch_20241001_0800', '2024-10-01 08:00', '2024-10-01', '101','b@ex.com','700','Санкт-Петербург');
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO stg.orders_raw (order_id, order_date, customer_id) VALUES
|
||||||
|
('5001', '2024-01-10', '101'),
|
||||||
|
('5002', '2024-02-05', '102');
|
||||||
|
|
||||||
|
INSERT INTO stg.order_items_raw (order_item_id, order_id, product_id, qty, price_at_sale) VALUES
|
||||||
|
('1', '5001', '9001', '2', '100.00'),
|
||||||
|
('2', '5001', '9002', '1', '50.00'),
|
||||||
|
('3', '5002', '9001', '1', '100.00');
|
||||||
|
|
||||||
|
INSERT INTO stg.products_raw (product_id, name) VALUES
|
||||||
|
('9001', 'Phone'),
|
||||||
|
('9002', 'Case');
|
||||||
|
|
||||||
|
-- 2. ODS: очистка и типизация
|
||||||
|
-- ⚠️ В продакшене используем UPSERT (INSERT ... ON CONFLICT DO UPDATE) или incremental load, не TRUNCATE+INSERT
|
||||||
|
|
||||||
|
TRUNCATE ods.customers, ods.orders, ods.order_items, ods.products;
|
||||||
|
|
||||||
|
INSERT INTO ods.orders (order_id, order_date, customer_id)
|
||||||
|
SELECT
|
||||||
|
order_id::INT,
|
||||||
|
TO_DATE(order_date, 'YYYY-MM-DD'),
|
||||||
|
customer_id::INT
|
||||||
|
FROM stg.orders_raw
|
||||||
|
WHERE order_date IS NOT NULL AND customer_id ~ '^\d+$';
|
||||||
|
|
||||||
|
-- берём по BK самую позднюю запись (по дате события, иначе по дате загрузки)
|
||||||
|
WITH src AS (
|
||||||
|
SELECT
|
||||||
|
s.customer_id::INT AS customer_id,
|
||||||
|
NULLIF(trim(s.email), '') AS email,
|
||||||
|
NULLIF(trim(s.phone), '') AS phone,
|
||||||
|
NULLIF(trim(s.city), '') AS city,
|
||||||
|
NULLIF(s.event_ts, '')::timestamp AS event_ts,
|
||||||
|
s._load_id,
|
||||||
|
s._load_ts,
|
||||||
|
COALESCE(NULLIF(s.event_ts, '')::date, s._load_ts::date) AS eff_date
|
||||||
|
FROM stg.customers_raw s
|
||||||
|
WHERE s.customer_id ~ '^\d+$'
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT
|
||||||
|
customer_id, email, phone, city, event_ts, _load_id, _load_ts,
|
||||||
|
row_number() OVER (PARTITION BY customer_id ORDER BY eff_date DESC, _load_ts DESC) AS rn
|
||||||
|
FROM src
|
||||||
|
)
|
||||||
|
INSERT INTO ods.customers (customer_id, email, phone, city, event_ts, _load_id, _load_ts)
|
||||||
|
SELECT customer_id, email, phone, city, event_ts, _load_id, _load_ts
|
||||||
|
FROM ranked
|
||||||
|
WHERE rn = 1;
|
||||||
|
|
||||||
|
INSERT INTO ods.order_items (order_item_id, order_id, product_id, qty, price_at_sale)
|
||||||
|
SELECT
|
||||||
|
order_item_id::INT,
|
||||||
|
order_id::INT,
|
||||||
|
product_id::INT,
|
||||||
|
NULLIF(qty, '')::INT,
|
||||||
|
NULLIF(price_at_sale, '')::NUMERIC(10,2)
|
||||||
|
FROM stg.order_items_raw
|
||||||
|
WHERE qty ~ '^\d+$' AND price_at_sale ~ '^\d+(\.\d+)?$';
|
||||||
|
|
||||||
|
INSERT INTO ods.products (product_id, name)
|
||||||
|
SELECT
|
||||||
|
product_id::INT,
|
||||||
|
TRIM(name)
|
||||||
|
FROM stg.products_raw
|
||||||
|
WHERE product_id ~ '^\d+$';
|
||||||
|
|
||||||
|
-- 3. DDS: dim_date — генерация календаря (идемпотентно: можно пересоздавать)
|
||||||
|
-- В реальности — делается ОДИН РАЗ, либо дополняется по мере необходимости
|
||||||
|
|
||||||
|
DELETE FROM dds.dim_date;
|
||||||
|
|
||||||
|
WITH RECURSIVE dates AS (
|
||||||
|
SELECT DATE '2023-01-01' AS d
|
||||||
|
UNION ALL
|
||||||
|
SELECT (d + INTERVAL '1 day')::DATE -- ← приведение к DATE
|
||||||
|
FROM dates
|
||||||
|
WHERE d + INTERVAL '1 day' <= DATE '2027-12-31'
|
||||||
|
)
|
||||||
|
INSERT INTO dds.dim_date (
|
||||||
|
date_key, date_actual, year, quarter, month, day,
|
||||||
|
weekday_name, weekday_num, is_first_week
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
CAST(TO_CHAR(d, 'YYYYMMDD') AS INT),
|
||||||
|
d,
|
||||||
|
EXTRACT(YEAR FROM d)::SMALLINT,
|
||||||
|
EXTRACT(QUARTER FROM d)::SMALLINT,
|
||||||
|
EXTRACT(MONTH FROM d)::SMALLINT,
|
||||||
|
EXTRACT(DAY FROM d)::SMALLINT,
|
||||||
|
TRIM(TO_CHAR(d, 'Day')), -- ← TRIM — убрать trailing space
|
||||||
|
EXTRACT(DOW FROM d)::SMALLINT,
|
||||||
|
d BETWEEN DATE_TRUNC('month', d)
|
||||||
|
AND DATE_TRUNC('month', d) + INTERVAL '1 month' - INTERVAL '1 day'
|
||||||
|
AND EXTRACT(DAY FROM d) <= 7
|
||||||
|
FROM dates;
|
||||||
|
|
||||||
|
-- 4. DDS: dim_product — полная перезагрузка (если товары редко меняются)
|
||||||
|
-- В реальности — инкрементальная загрузка по BK
|
||||||
|
|
||||||
|
DELETE FROM dds.dim_product;
|
||||||
|
|
||||||
|
INSERT INTO dds.dim_product (product_bk, product_name)
|
||||||
|
SELECT product_id, name
|
||||||
|
FROM ods.products;
|
||||||
|
|
||||||
|
-- 5. DDS: dim_customer — первичная загрузка SCD2 (full backfill из STG)
|
||||||
|
-- В ЭТОМ ДЕМО: dim_customer строится напрямую из stg.customers_raw, который играет роль
|
||||||
|
-- устойчивого event-лога (все события по клиенту в одном месте).
|
||||||
|
-- Это удобно для учебной первичной загрузки (full backfill), когда мы один раз
|
||||||
|
-- восстанавливаем всю историю клиента.
|
||||||
|
-- В РЕАЛЬНОМ DWH: так делают редко. Исторические измерения обычно строят
|
||||||
|
-- поверх очищенных и нормализованных слоёв (ODS / PSA / Data Vault).
|
||||||
|
-- Для примера инкрементальной заливки SCD2 по снимку из ODS см. 03_demo_increment.sql и SCD.md.
|
||||||
|
--
|
||||||
|
-- Идея SCD2 простыми словами:
|
||||||
|
-- - одна строка = один период, когда атрибуты клиента (email/phone/city) были одинаковыми;
|
||||||
|
-- - valid_from = дата, когда "стало так";
|
||||||
|
-- - valid_to = дата следующего изменения (NULL = текущая версия).
|
||||||
|
--
|
||||||
|
-- Откуда берём дату изменения:
|
||||||
|
-- - если в событии есть event_ts — считаем, что изменение произошло тогда;
|
||||||
|
-- - если event_ts пустой — берём дату загрузки (_load_ts), чтобы не терять историю.
|
||||||
|
--
|
||||||
|
-- Важно для демо: считаем, что у клиента не бывает двух разных изменений в один и тот же день.
|
||||||
|
TRUNCATE dds.dim_customer, dds.fact_sales;
|
||||||
|
|
||||||
|
WITH src AS ( -- 1) Приводим типы, готовим дату изменения (eff_date) и считаем hashdiff атрибутов
|
||||||
|
SELECT
|
||||||
|
s.customer_id::INT AS customer_bk,
|
||||||
|
NULLIF(trim(s.email), '') AS email,
|
||||||
|
NULLIF(trim(s.phone), '') AS phone,
|
||||||
|
NULLIF(trim(s.city), '') AS city,
|
||||||
|
COALESCE(NULLIF(s.event_ts, '')::date, s._load_ts::date) AS eff_date,
|
||||||
|
dds.customer_hash(s.email, s.phone, s.city) AS hashdiff
|
||||||
|
FROM stg.customers_raw s
|
||||||
|
WHERE s.customer_id ~ '^\d+$'
|
||||||
|
),
|
||||||
|
ordered AS ( -- 2) Сортируем по датам и смотрим "какой hashdiff был до этого" (LAG)
|
||||||
|
SELECT *,
|
||||||
|
lag(hashdiff) OVER (PARTITION BY customer_bk ORDER BY eff_date) AS prev_hash
|
||||||
|
FROM src
|
||||||
|
),
|
||||||
|
changes AS ( -- 3) Оставляем только первое состояние и реальные изменения (где hashdiff поменялся)
|
||||||
|
SELECT *
|
||||||
|
FROM ordered
|
||||||
|
WHERE prev_hash IS DISTINCT FROM hashdiff OR prev_hash IS NULL
|
||||||
|
),
|
||||||
|
framed AS ( -- 4) Превращаем изменения в периоды: valid_to = дата следующего изменения (LEAD)
|
||||||
|
SELECT
|
||||||
|
customer_bk, email, phone, city, hashdiff,
|
||||||
|
eff_date AS valid_from,
|
||||||
|
lead(eff_date) OVER (PARTITION BY customer_bk ORDER BY eff_date) AS valid_to
|
||||||
|
FROM changes
|
||||||
|
)
|
||||||
|
INSERT INTO dds.dim_customer (
|
||||||
|
customer_bk, email, phone, city, hashdiff,
|
||||||
|
valid_from, valid_to,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
customer_bk, email, phone, city, hashdiff,
|
||||||
|
valid_from,
|
||||||
|
valid_to,
|
||||||
|
now(), now()
|
||||||
|
FROM framed
|
||||||
|
ORDER BY customer_bk, valid_from;
|
||||||
|
|
||||||
|
-- 6. DDS: fact_sales — загрузка фактов с учётом SCD
|
||||||
|
-- В продакшене — фильтруем по диапазону дат (инкрементально)
|
||||||
|
|
||||||
|
--TRUNCATE dds.fact_sales;
|
||||||
|
|
||||||
|
INSERT INTO dds.fact_sales (customer_sk, product_sk, date_key, quantity, amount)
|
||||||
|
SELECT
|
||||||
|
dc.customer_sk,
|
||||||
|
dp.product_sk,
|
||||||
|
CAST(TO_CHAR(o.order_date, 'YYYYMMDD') AS INT),
|
||||||
|
oi.qty,
|
||||||
|
oi.price_at_sale * oi.qty
|
||||||
|
FROM ods.orders o
|
||||||
|
JOIN ods.order_items oi ON o.order_id = oi.order_id
|
||||||
|
JOIN ods.products p ON oi.product_id = p.product_id
|
||||||
|
JOIN dds.dim_product dp ON p.product_id = dp.product_bk
|
||||||
|
JOIN dds.dim_customer dc
|
||||||
|
ON o.customer_id = dc.customer_bk
|
||||||
|
AND o.order_date >= dc.valid_from
|
||||||
|
AND (dc.valid_to IS NULL OR o.order_date < dc.valid_to);
|
||||||
|
|
||||||
|
-- 7. Проверка — вывод итогов (не часть ETL, но полезно для отладки)
|
||||||
|
-- В реальном пайплайне такие SELECT выносятся в отдельные скрипты или дашборды
|
||||||
|
|
||||||
|
SELECT 'dim_customer current = ' || COUNT(*) FROM dds.dim_customer WHERE valid_to IS NULL;
|
||||||
|
SELECT 'fact_sales count = ' || COUNT(*) FROM dds.fact_sales;
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
-- ===============================================
|
||||||
|
-- 03_demo_increment.sql
|
||||||
|
-- Имитация новых событий + инкрементальный SCD2
|
||||||
|
-- ===============================================
|
||||||
|
|
||||||
|
-- 0. Новые события в STG (пример)
|
||||||
|
INSERT INTO stg.customers_raw (_load_id, _load_ts, event_ts, customer_id, email, phone, city) VALUES
|
||||||
|
('batch_20241101_0800', '2024-11-01 08:00', '2024-11-01', '101','b@ex.com','700','Москва'), -- город вернулся
|
||||||
|
('batch_20240310_0800', '2024-03-10 08:00', '2024-03-10', '103','d@ex.com','702','Казань'); -- новый клиент
|
||||||
|
|
||||||
|
-- 1) UPSERT в ODS (вставка с обновлением по конфликту, INSERT ... ON CONFLICT DO UPDATE):
|
||||||
|
-- сохраняем в ods.customers последнюю версию клиента по BK (бизнес-ключ = customer_id)
|
||||||
|
WITH src AS (
|
||||||
|
SELECT
|
||||||
|
s.customer_id::INT AS customer_id,
|
||||||
|
NULLIF(trim(s.email), '') AS email,
|
||||||
|
NULLIF(trim(s.phone), '') AS phone,
|
||||||
|
NULLIF(trim(s.city), '') AS city,
|
||||||
|
NULLIF(s.event_ts,'')::timestamp AS event_ts,
|
||||||
|
s._load_id,
|
||||||
|
s._load_ts,
|
||||||
|
-- eff_ts нужен, чтобы выбрать "самое свежее" событие по клиенту:
|
||||||
|
-- если event_ts нет, используем время загрузки (_load_ts) как приближение.
|
||||||
|
COALESCE(NULLIF(s.event_ts,'')::timestamp, s._load_ts) AS eff_ts
|
||||||
|
FROM stg.customers_raw s
|
||||||
|
WHERE s.customer_id ~ '^\d+$'
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT
|
||||||
|
customer_id, email, phone, city, event_ts, _load_id, _load_ts,
|
||||||
|
-- берём одну строку на клиента: с максимальным eff_ts (при равенстве — с максимальным _load_ts)
|
||||||
|
row_number() OVER (PARTITION BY customer_id ORDER BY eff_ts DESC, _load_ts DESC) AS rn
|
||||||
|
FROM src
|
||||||
|
)
|
||||||
|
INSERT INTO ods.customers (customer_id, email, phone, city, event_ts, _load_id, _load_ts)
|
||||||
|
SELECT customer_id, email, phone, city, event_ts, _load_id, _load_ts
|
||||||
|
FROM ranked
|
||||||
|
WHERE rn = 1
|
||||||
|
ON CONFLICT (customer_id) DO UPDATE
|
||||||
|
SET email = EXCLUDED.email,
|
||||||
|
phone = EXCLUDED.phone,
|
||||||
|
city = EXCLUDED.city,
|
||||||
|
event_ts= EXCLUDED.event_ts,
|
||||||
|
_load_id= EXCLUDED._load_id,
|
||||||
|
_load_ts= EXCLUDED._load_ts
|
||||||
|
-- апдейтим только если пришло более «свежее» событие
|
||||||
|
WHERE COALESCE(EXCLUDED.event_ts, EXCLUDED._load_ts) >
|
||||||
|
COALESCE(ods.customers.event_ts, ods.customers._load_ts);
|
||||||
|
|
||||||
|
-- 2) Инкрементальное SCD2 из ODS (в одной транзакции, по последнему снимку в ODS)
|
||||||
|
-- Канон для курса: считаем по дням (valid_from/valid_to — DATE), интервалы [valid_from, valid_to),
|
||||||
|
-- текущая версия = valid_to IS NULL
|
||||||
|
-- Предполагаем, что для клиента нет нескольких изменений в один день.
|
||||||
|
--
|
||||||
|
-- Идея (по шагам):
|
||||||
|
-- 1) Берём текущий "снимок" клиента из ODS (одна строка на BK = customer_id).
|
||||||
|
-- 2) Сравниваем его с текущей версией в dds.dim_customer (valid_to IS NULL) по hashdiff.
|
||||||
|
-- 3) Если изменилось — закрываем текущую версию (ставим valid_to) и вставляем новую (valid_to = NULL).
|
||||||
|
--
|
||||||
|
-- Про даты:
|
||||||
|
-- eff_date берём из event_ts, а если его нет — из _load_ts (как приближение).
|
||||||
|
BEGIN;
|
||||||
|
-- 2.1) Закрываем предыдущую актуальную версию (только если реально изменились атрибуты)
|
||||||
|
-- delta/current считаем прямо в запросе (без временных таблиц) специально для читабельности.
|
||||||
|
WITH delta AS (
|
||||||
|
SELECT
|
||||||
|
c.customer_id AS customer_bk,
|
||||||
|
c.email, c.phone, c.city,
|
||||||
|
COALESCE(c.event_ts::date, c._load_ts::date) AS eff_date,
|
||||||
|
dds.customer_hash(c.email, c.phone, c.city) AS hashdiff
|
||||||
|
FROM ods.customers c
|
||||||
|
),
|
||||||
|
current AS (
|
||||||
|
SELECT d.*
|
||||||
|
FROM dds.dim_customer d
|
||||||
|
WHERE d.valid_to IS NULL
|
||||||
|
)
|
||||||
|
UPDATE dds.dim_customer d
|
||||||
|
SET valid_to = x.eff_date,
|
||||||
|
updated_at = now()
|
||||||
|
FROM (
|
||||||
|
-- x = кандидаты на "закрытие" текущей версии:
|
||||||
|
-- клиент есть в DDS (current) и атрибуты изменились (hashdiff стал другим).
|
||||||
|
SELECT
|
||||||
|
t.customer_bk,
|
||||||
|
t.eff_date,
|
||||||
|
c.customer_sk
|
||||||
|
FROM delta t
|
||||||
|
JOIN current c
|
||||||
|
ON c.customer_bk = t.customer_bk
|
||||||
|
WHERE c.hashdiff <> t.hashdiff
|
||||||
|
AND t.eff_date > c.valid_from -- не создаём период нулевой/отрицательной длины
|
||||||
|
) x
|
||||||
|
WHERE d.customer_sk = x.customer_sk
|
||||||
|
AND d.valid_to IS NULL;
|
||||||
|
|
||||||
|
-- 2.2) Вставляем новую версию (только если новая или изменившаяся)
|
||||||
|
-- delta/current повторяем ещё раз отдельно, чтобы блок вставки читался независимо от блока UPDATE.
|
||||||
|
WITH delta AS (
|
||||||
|
SELECT
|
||||||
|
c.customer_id AS customer_bk,
|
||||||
|
c.email, c.phone, c.city,
|
||||||
|
COALESCE(c.event_ts::date, c._load_ts::date) AS eff_date,
|
||||||
|
dds.customer_hash(c.email, c.phone, c.city) AS hashdiff
|
||||||
|
FROM ods.customers c
|
||||||
|
),
|
||||||
|
current AS (
|
||||||
|
SELECT d.*
|
||||||
|
FROM dds.dim_customer d
|
||||||
|
WHERE d.valid_to IS NULL
|
||||||
|
),
|
||||||
|
to_insert AS (
|
||||||
|
-- to_insert = кандидаты на вставку:
|
||||||
|
-- 1) новый клиент (в current нет строки);
|
||||||
|
-- 2) изменившийся клиент (hashdiff поменялся).
|
||||||
|
-- если атрибуты не менялись — клиент сюда не попадёт, и ничего делать не нужно.
|
||||||
|
SELECT
|
||||||
|
t.customer_bk,
|
||||||
|
t.email,
|
||||||
|
t.phone,
|
||||||
|
t.city,
|
||||||
|
t.hashdiff,
|
||||||
|
t.eff_date
|
||||||
|
FROM delta t
|
||||||
|
LEFT JOIN current c
|
||||||
|
ON c.customer_bk = t.customer_bk
|
||||||
|
WHERE c.customer_sk IS NULL
|
||||||
|
OR (c.hashdiff <> t.hashdiff AND t.eff_date > c.valid_from)
|
||||||
|
)
|
||||||
|
INSERT INTO dds.dim_customer (
|
||||||
|
customer_bk, email, phone, city, hashdiff,
|
||||||
|
valid_from, valid_to,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
t.customer_bk, t.email, t.phone, t.city, t.hashdiff,
|
||||||
|
t.eff_date, NULL,
|
||||||
|
now(), now()
|
||||||
|
FROM to_insert t
|
||||||
|
-- защита от повторного запуска: не вставляем одну и ту же версию (BK + valid_from) второй раз
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dds.dim_customer d
|
||||||
|
WHERE d.customer_bk = t.customer_bk
|
||||||
|
AND d.valid_from = t.eff_date
|
||||||
|
);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- (факты можно не перезаливать — даты заказов не поменялись)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- ===============================================
|
||||||
|
-- Проверки качества данных после загрузки STG→ODS→DDS
|
||||||
|
-- Запускается после 02_dml_stg-dds.sql (и, при необходимости, 03_demo_increment.sql)
|
||||||
|
-- ===============================================
|
||||||
|
|
||||||
|
-- 1. Проверка: dim_customer не пуста
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ASSERT (SELECT COUNT(*) FROM dds.dim_customer) > 0,
|
||||||
|
'ОШИБКА: таблица dds.dim_customer пуста — загрузка не прошла';
|
||||||
|
RAISE NOTICE '✅ dim_customer: НЕ ПУСТА (всего строк: %)',
|
||||||
|
(SELECT COUNT(*) FROM dds.dim_customer);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 2. Проверка: каждая строка из ODS попала в DDS-факт
|
||||||
|
-- Сравниваем количество строк в ods.order_items и dds.fact_sales
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
expected_count bigint;
|
||||||
|
actual_count bigint;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO expected_count FROM ods.order_items;
|
||||||
|
SELECT COUNT(*) INTO actual_count FROM dds.fact_sales;
|
||||||
|
--
|
||||||
|
ASSERT actual_count = expected_count,
|
||||||
|
format('ОШИБКА: в fact_sales %s строк, а в ods.order_items — %s. Разница: %s',
|
||||||
|
actual_count, expected_count, expected_count - actual_count);
|
||||||
|
--
|
||||||
|
RAISE NOTICE '✅ fact_sales: количество строк совпадает с ods.order_items (%)', actual_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 3. Проверка SCD2 (Type 2): у клиента 101 должно быть ≥2 версий (из-за смены email)
|
||||||
|
DO $$
|
||||||
|
DECLARE version_count INT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO version_count
|
||||||
|
FROM dds.dim_customer
|
||||||
|
WHERE customer_bk = 101;
|
||||||
|
--
|
||||||
|
ASSERT version_count >= 2,
|
||||||
|
FORMAT('ОШИБКА: у клиента 101 только %s версия, ожидается ≥2 (должна быть история)', version_count);
|
||||||
|
RAISE NOTICE '✅ SCD2: клиент 101 имеет % версий — история сохранена', version_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 4. Проверка SCD2 (Type 2): у каждого клиента ровно одна актуальная версия (valid_to IS NULL)
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
customers_cnt BIGINT;
|
||||||
|
current_cnt BIGINT;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(DISTINCT customer_bk) INTO customers_cnt FROM dds.dim_customer;
|
||||||
|
SELECT COUNT(*) INTO current_cnt
|
||||||
|
FROM dds.dim_customer
|
||||||
|
WHERE valid_to IS NULL;
|
||||||
|
--
|
||||||
|
ASSERT current_cnt = customers_cnt,
|
||||||
|
FORMAT('ОШИБКА: актуальных строк %s, а уникальных клиентов %s (ожидается 1 current на клиента)',
|
||||||
|
current_cnt, customers_cnt);
|
||||||
|
RAISE NOTICE '✅ SCD2: current-строки = количеству клиентов (%)', current_cnt;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 5. Проверка SCD2 (Type 2): периоды корректны (valid_to > valid_from или valid_to IS NULL)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ASSERT NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dds.dim_customer
|
||||||
|
WHERE valid_to IS NOT NULL
|
||||||
|
AND valid_to <= valid_from
|
||||||
|
),
|
||||||
|
'ОШИБКА: найдены строки dim_customer с некорректным периодом (valid_to <= valid_from)';
|
||||||
|
RAISE NOTICE '✅ SCD2: периоды valid_from/valid_to корректны';
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- ===============================================
|
||||||
|
-- DDL: Data Marts (DM)
|
||||||
|
-- Слой "готовых решений" — для BI, отчётов, API
|
||||||
|
-- ===============================================
|
||||||
|
|
||||||
|
DROP SCHEMA IF EXISTS dm CASCADE;
|
||||||
|
CREATE SCHEMA dm;
|
||||||
|
|
||||||
|
-- Витрина: ежедневные продажи по товарам и клиентам
|
||||||
|
-- Гранулярность: 1 строка = 1 день × 1 товар × 1 сегмент клиента
|
||||||
|
CREATE TABLE dm.mart_daily_sales (
|
||||||
|
date_actual DATE NOT NULL,
|
||||||
|
product_name VARCHAR(100) NOT NULL,
|
||||||
|
customer_segment VARCHAR(20) NOT NULL, -- напр. 'Premium', 'Basic'
|
||||||
|
total_qty INT NOT NULL,
|
||||||
|
total_revenue NUMERIC(18,2) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Витрина: 360°-портрет клиента (lifetime value)
|
||||||
|
CREATE TABLE dm.mart_customer_360 (
|
||||||
|
customer_bk INT NOT NULL,
|
||||||
|
first_order_date DATE,
|
||||||
|
last_order_date DATE,
|
||||||
|
total_line_items INT NOT NULL,
|
||||||
|
total_items INT NOT NULL,
|
||||||
|
lifetime_value NUMERIC(18,2) NOT NULL,
|
||||||
|
last_email VARCHAR(100),
|
||||||
|
last_city VARCHAR(50)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Индексы для ускорения BI (опционально, но рекомендовано)
|
||||||
|
CREATE INDEX ON dm.mart_daily_sales (date_actual);
|
||||||
|
CREATE INDEX ON dm.mart_daily_sales (product_name);
|
||||||
|
CREATE INDEX ON dm.mart_customer_360 (customer_bk);
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- ===============================================
|
||||||
|
-- DML: построение Data Marts из DDS
|
||||||
|
-- Идемпотентно: можно пересобирать в любое время
|
||||||
|
-- ===============================================
|
||||||
|
|
||||||
|
-- 1. Очистка (full refresh — для простоты; в продакшене — incremental)
|
||||||
|
TRUNCATE dm.mart_daily_sales, dm.mart_customer_360;
|
||||||
|
|
||||||
|
-- 2. mart_daily_sales: продажи по датам и товарам
|
||||||
|
-- Одна строка = дата × товар × простой сегмент заказа
|
||||||
|
INSERT INTO dm.mart_daily_sales (
|
||||||
|
date_actual, product_name, customer_segment, total_qty, total_revenue
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
d.date_actual,
|
||||||
|
p.product_name,
|
||||||
|
-- Делим заказы на Premium / Basic по сумме
|
||||||
|
CASE
|
||||||
|
WHEN f.amount >= 200 THEN 'Premium'
|
||||||
|
ELSE 'Basic'
|
||||||
|
END AS customer_segment,
|
||||||
|
SUM(f.quantity) AS total_qty,
|
||||||
|
SUM(f.amount) AS total_revenue
|
||||||
|
FROM dds.fact_sales f
|
||||||
|
JOIN dds.dim_date d ON f.date_key = d.date_key
|
||||||
|
JOIN dds.dim_product p ON f.product_sk = p.product_sk
|
||||||
|
JOIN dds.dim_customer c ON f.customer_sk = c.customer_sk -- факт уже ссылается на нужную версию клиента
|
||||||
|
GROUP BY d.date_actual, p.product_name,
|
||||||
|
CASE WHEN f.amount >= 200 THEN 'Premium' ELSE 'Basic' END;
|
||||||
|
|
||||||
|
-- 3. mart_customer_360: 360‑портрет клиента
|
||||||
|
-- Одна строка = один клиент
|
||||||
|
-- Считаем суммы по всей истории его покупок
|
||||||
|
INSERT INTO dm.mart_customer_360 (
|
||||||
|
customer_bk, first_order_date, last_order_date,
|
||||||
|
total_line_items, total_items, lifetime_value,
|
||||||
|
last_email, last_city
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.customer_bk,
|
||||||
|
MIN(d.date_actual) AS first_order_date,
|
||||||
|
MAX(d.date_actual) AS last_order_date,
|
||||||
|
COUNT(DISTINCT f.sale_id) AS total_line_items, -- строки факта (позиции продаж), не бизнес-заказы
|
||||||
|
SUM(f.quantity) AS total_items,
|
||||||
|
SUM(f.amount) AS lifetime_value,
|
||||||
|
-- Берём самый свежий email и город клиента
|
||||||
|
(SELECT email FROM dds.dim_customer c2
|
||||||
|
WHERE c2.customer_bk = c.customer_bk
|
||||||
|
ORDER BY c2.valid_from DESC
|
||||||
|
LIMIT 1) AS last_email,
|
||||||
|
(SELECT city FROM dds.dim_customer c2
|
||||||
|
WHERE c2.customer_bk = c.customer_bk
|
||||||
|
ORDER BY c2.valid_from DESC
|
||||||
|
LIMIT 1) AS last_city
|
||||||
|
FROM dds.fact_sales f
|
||||||
|
JOIN dds.dim_date d ON f.date_key = d.date_key
|
||||||
|
JOIN dds.dim_customer c ON f.customer_sk = c.customer_sk
|
||||||
|
-- Здесь не фильтруем по valid_to: нужна вся история фактов
|
||||||
|
GROUP BY c.customer_bk;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- ===============================================
|
||||||
|
-- DDL: дополнительные таблицы для домашки
|
||||||
|
-- Тема: статусы клиента (SCD2 поверх статуса)
|
||||||
|
-- Скрипт можно запускать после 01_ddl_stg-dds.sql
|
||||||
|
-- ===============================================
|
||||||
|
|
||||||
|
-- 1. STG: сырые события о статусе клиента из CRM
|
||||||
|
DROP TABLE IF EXISTS stg.customer_status_raw;
|
||||||
|
CREATE TABLE stg.customer_status_raw (
|
||||||
|
customer_id TEXT,
|
||||||
|
status TEXT,
|
||||||
|
event_ts TEXT,
|
||||||
|
_load_id TEXT,
|
||||||
|
_load_ts TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. ODS: очищенные и типизированные статусы
|
||||||
|
DROP TABLE IF EXISTS ods.customer_status;
|
||||||
|
CREATE TABLE ods.customer_status (
|
||||||
|
customer_id INT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
event_ts TIMESTAMP NOT NULL,
|
||||||
|
_load_id TEXT NOT NULL,
|
||||||
|
_load_ts TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ods.customer_status
|
||||||
|
ADD PRIMARY KEY (customer_id, event_ts);
|
||||||
|
|
||||||
|
-- 3. DDS: измерение статусов клиента с историей (SCD Type 2)
|
||||||
|
DROP TABLE IF EXISTS dds.dim_customer_status;
|
||||||
|
CREATE TABLE dds.dim_customer_status (
|
||||||
|
customer_status_sk BIGSERIAL PRIMARY KEY,
|
||||||
|
customer_bk INT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
hashdiff TEXT NOT NULL,
|
||||||
|
valid_from DATE NOT NULL,
|
||||||
|
valid_to DATE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE dds.dim_customer_status
|
||||||
|
ADD CONSTRAINT uq_dim_customer_status_bk_from UNIQUE (customer_bk, valid_from);
|
||||||
|
|
||||||
|
CREATE INDEX ix_dim_customer_status_bk_current
|
||||||
|
ON dds.dim_customer_status (customer_bk)
|
||||||
|
WHERE valid_to IS NULL;
|
||||||
|
|
||||||
|
-- 4. DM: витрина статусов клиентов по датам (опциональная часть домашки)
|
||||||
|
DROP TABLE IF EXISTS dm.mart_customer_status_daily;
|
||||||
|
CREATE TABLE dm.mart_customer_status_daily (
|
||||||
|
date_actual DATE NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
customers_cnt INT NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- ===============================================
|
||||||
|
-- DML-шаблон для домашки
|
||||||
|
-- Тема: статусы клиента (STG → ODS → DDS SCD2)
|
||||||
|
-- Цель: по customer_status_events.csv построить историю статусов
|
||||||
|
-- ===============================================
|
||||||
|
|
||||||
|
-- Подсказка:
|
||||||
|
-- 1) Загрузите CSV в stg.customer_status_raw (через COPY или \copy в psql).
|
||||||
|
-- См. пример структуры файла в dwh-modeling/data/customer_status_events.csv
|
||||||
|
-- 2) Переложите данные в ods.customer_status с приведением типов.
|
||||||
|
-- customer_id → INT, status → VARCHAR(20), event_ts / load_ts → TIMESTAMP
|
||||||
|
-- (в STG/ODS эта колонка будет жить как _load_ts).
|
||||||
|
-- 3) Постройте из ods.customer_status измерение dds.dim_customer_status в стиле SCD2:
|
||||||
|
-- - одна строка на период действия статуса (valid_from / valid_to);
|
||||||
|
-- - актуальная строка для клиента — та, где valid_to IS NULL;
|
||||||
|
-- - hashdiff можно считать, например, от одного поля status.
|
||||||
|
-- 4) При желании добавьте инкрементальную логику (как в 03_demo_increment.sql).
|
||||||
|
-- 5) Опционально: соберите витрину dm.mart_customer_status_daily
|
||||||
|
-- с количеством клиентов по статусам на каждую дату.
|
||||||
|
|
||||||
|
-- Ниже — ЗАГОТОВКИ блоков, которые можно дописать.
|
||||||
|
-- Они намеренно оставлены пустыми, чтобы вы написали SQL сами.
|
||||||
|
|
||||||
|
-- 1. ODS: очистка и типизация
|
||||||
|
-- TRUNCATE ods.customer_status;
|
||||||
|
-- INSERT INTO ods.customer_status (...)
|
||||||
|
-- SELECT ...
|
||||||
|
-- FROM stg.customer_status_raw;
|
||||||
|
|
||||||
|
-- 2. DDS: начальная загрузка SCD2
|
||||||
|
-- Примерный план:
|
||||||
|
-- - рассчитать hashdiff по (status);
|
||||||
|
-- - по каждому клиенту отсортировать события по времени;
|
||||||
|
-- - построить для каждой строки valid_from и valid_to (LEAD() OVER ...), последняя valid_to = NULL;
|
||||||
|
-- - вставить в dds.dim_customer_status.
|
||||||
|
|
||||||
|
-- 3. DDS: инкрементальная загрузка (по желанию)
|
||||||
|
-- Можно ориентироваться на примеры в 03_demo_increment.sql.
|
||||||
|
|
||||||
|
-- 4. DM: витрина статусов клиентов по датам (по желанию)
|
||||||
|
-- DDL витрины уже создан в 07_ddl_hw_customer_status.sql (dm.mart_customer_status_daily).
|
||||||
|
-- Идея: на каждую дату взять актуальный статус клиента
|
||||||
|
-- через JOIN dds.dim_customer_status + dds.dim_date.
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
-- ===============================================
|
||||||
|
-- 09_dml_hw_customer_status_solution.sql
|
||||||
|
-- Решение домашки: статусы клиента (STG -> ODS -> DDS SCD2 -> DM)
|
||||||
|
--
|
||||||
|
-- Это ЭТАЛОННОЕ РЕШЕНИЕ. Если вы ещё не пробовали решить домашку сами -
|
||||||
|
-- вернитесь к заданию (Homework_Customer_Status_DDS_DM.md) и шаблону (08_dml_hw_customer_status_template.sql).
|
||||||
|
-- Основная ценность задания - в самостоятельном разборе.
|
||||||
|
--
|
||||||
|
-- Что делает этот файл:
|
||||||
|
-- 1) Перекладывает события статусов в ODS (приводит типы, чистит пустое).
|
||||||
|
-- 2) Строит DDS-измерение со "встроенной историей" (SCD2): периоды valid_from/valid_to.
|
||||||
|
-- 3) Загружает инкрементальную порцию событий и обновляет ODS + DDS.
|
||||||
|
-- 4) Собирает простую витрину в DM: сколько клиентов в каком статусе по дням.
|
||||||
|
--
|
||||||
|
-- Как запускать:
|
||||||
|
-- - для первого знакомства можно запускать файл целиком;
|
||||||
|
-- - если хотите потренировать инкремент (п.3): добавьте свои события -> запустите блок 3 ещё раз.
|
||||||
|
--
|
||||||
|
-- Важно:
|
||||||
|
-- - здесь часто используется TRUNCATE (полная очистка), чтобы было легко повторять домашку;
|
||||||
|
-- - в реальном DWH так делают не всегда, но для обучения это удобнее.
|
||||||
|
--
|
||||||
|
-- Предусловия (DDL + данные в STG):
|
||||||
|
-- 1) dwh-modeling/sql/01_ddl_stg-dds.sql
|
||||||
|
-- 2) dwh-modeling/sql/02_dml_stg-dds.sql (нужен dim_date)
|
||||||
|
-- 3) dwh-modeling/sql/05_ddl_dm.sql
|
||||||
|
-- 4) dwh-modeling/sql/07_ddl_hw_customer_status.sql
|
||||||
|
-- 5) stg.customer_status_raw заполнена (см. dwh-modeling/Homework_Customer_Status_DDS_DM.md)
|
||||||
|
-- ===============================================
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- 1) ODS: очистка и типизация (full refresh)
|
||||||
|
-- ==========================================================
|
||||||
|
|
||||||
|
-- Идея:
|
||||||
|
-- - STG хранит "как пришло" (обычно TEXT);
|
||||||
|
-- - ODS хранит "аккуратно": правильные типы + простая чистка.
|
||||||
|
-- Для простоты пересобираем ODS с нуля.
|
||||||
|
--
|
||||||
|
-- Обратите внимание: ods.customer_status хранит ВСЕ события (PK = customer_id + event_ts),
|
||||||
|
-- а не только последнее состояние, как ods.customers (PK = customer_id).
|
||||||
|
-- Причина: источник данных здесь - поток событий ("статус стал X в момент Y"),
|
||||||
|
-- а не снимок ("вот текущие данные клиента"). ODS сохраняет природу источника:
|
||||||
|
-- снимок остаётся снимком, события остаются событиями.
|
||||||
|
-- Благодаря этому full backfill SCD2 (блок 2) строится прямо из ODS, а не из STG.
|
||||||
|
|
||||||
|
TRUNCATE ods.customer_status;
|
||||||
|
|
||||||
|
INSERT INTO ods.customer_status (
|
||||||
|
customer_id, status, event_ts, _load_id, _load_ts
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.customer_id::INT AS customer_id,
|
||||||
|
NULLIF(trim(s.status), '') AS status,
|
||||||
|
NULLIF(trim(s.event_ts), '')::TIMESTAMP AS event_ts,
|
||||||
|
s._load_id,
|
||||||
|
COALESCE(s._load_ts, now()) AS _load_ts
|
||||||
|
FROM stg.customer_status_raw s
|
||||||
|
WHERE s.customer_id ~ '^\d+$'
|
||||||
|
AND NULLIF(trim(s.event_ts), '') IS NOT NULL
|
||||||
|
AND NULLIF(trim(s.status), '') IS NOT NULL;
|
||||||
|
|
||||||
|
-- Проверка: что получилось в ODS
|
||||||
|
SELECT 'ods.customer_status count = ' || COUNT(*) FROM ods.customer_status;
|
||||||
|
SELECT * FROM ods.customer_status ORDER BY customer_id, event_ts;
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- 2) DDS: начальная загрузка SCD2 (full refresh)
|
||||||
|
-- ==========================================================
|
||||||
|
|
||||||
|
-- Идея SCD2 простыми словами:
|
||||||
|
-- - одна строка = один период, когда статус был одним и тем же;
|
||||||
|
-- - valid_from = с какого дня статус "начался";
|
||||||
|
-- - valid_to = с какого дня статус "закончился" (NULL = текущий статус);
|
||||||
|
-- - интервалы считаем так: [valid_from, valid_to) (valid_to не включаем).
|
||||||
|
-- - чтобы найти статус "на дату D":
|
||||||
|
-- D >= valid_from AND (valid_to IS NULL OR D < valid_to)
|
||||||
|
--
|
||||||
|
-- Упрощение для домашки:
|
||||||
|
-- - считаем, что у клиента нет двух разных смен статуса в один день.
|
||||||
|
|
||||||
|
TRUNCATE dds.dim_customer_status;
|
||||||
|
|
||||||
|
WITH src AS (
|
||||||
|
-- src: события из ODS + "контрольная сумма" статуса.
|
||||||
|
-- Так проще проверять, поменялся статус или остался тем же.
|
||||||
|
SELECT
|
||||||
|
customer_id AS customer_bk,
|
||||||
|
status,
|
||||||
|
event_ts,
|
||||||
|
md5(lower(coalesce(status, ''))) AS hashdiff
|
||||||
|
FROM ods.customer_status
|
||||||
|
),
|
||||||
|
ordered AS (
|
||||||
|
-- ordered: для каждого клиента смотрим "какая версия была до этого" (LAG)
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
lag(hashdiff) OVER (
|
||||||
|
PARTITION BY customer_bk
|
||||||
|
ORDER BY event_ts
|
||||||
|
) AS prev_hash
|
||||||
|
FROM src
|
||||||
|
),
|
||||||
|
changes AS (
|
||||||
|
-- changes: оставляем только первое состояние и реальные изменения статуса
|
||||||
|
SELECT *
|
||||||
|
FROM ordered
|
||||||
|
WHERE prev_hash IS DISTINCT FROM hashdiff OR prev_hash IS NULL
|
||||||
|
),
|
||||||
|
framed AS (
|
||||||
|
-- framed: превращаем изменения в периоды (valid_to = дата следующего события через LEAD)
|
||||||
|
SELECT
|
||||||
|
customer_bk,
|
||||||
|
status,
|
||||||
|
hashdiff,
|
||||||
|
event_ts::DATE AS valid_from,
|
||||||
|
lead(event_ts::DATE) OVER (
|
||||||
|
PARTITION BY customer_bk
|
||||||
|
ORDER BY event_ts
|
||||||
|
) AS valid_to
|
||||||
|
FROM changes
|
||||||
|
)
|
||||||
|
INSERT INTO dds.dim_customer_status (
|
||||||
|
customer_bk, status, hashdiff,
|
||||||
|
valid_from, valid_to,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
customer_bk, status, hashdiff,
|
||||||
|
valid_from, valid_to,
|
||||||
|
now(), now()
|
||||||
|
FROM framed
|
||||||
|
ORDER BY customer_bk, valid_from;
|
||||||
|
|
||||||
|
-- Проверка: периоды в DDS (у клиента 101 должно быть 4 строки: new -> active -> vip -> churned)
|
||||||
|
SELECT 'dim_customer_status count = ' || COUNT(*) FROM dds.dim_customer_status;
|
||||||
|
SELECT * FROM dds.dim_customer_status ORDER BY customer_bk, valid_from;
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- 3) Инкрементальная загрузка: STG -> ODS -> DDS
|
||||||
|
-- ==========================================================
|
||||||
|
|
||||||
|
-- Имитируем приход новой порции событий (customer_status_events_increment.csv):
|
||||||
|
-- - клиент 101: churned -> active (вернулся)
|
||||||
|
-- - клиент 102: churned -> active
|
||||||
|
-- - клиент 103: new -> active
|
||||||
|
-- - клиент 104: новый клиент, статус new
|
||||||
|
|
||||||
|
-- 3.0) Новые события в STG
|
||||||
|
-- При повторном запуске эти строки добавятся в STG ещё раз (дубли).
|
||||||
|
-- Для демо это не страшно: ODS-вставка ниже использует ON CONFLICT DO NOTHING,
|
||||||
|
-- а SCD2-блок защищён от повторных вставок через NOT EXISTS.
|
||||||
|
-- В продакшене STG обычно очищается перед каждой загрузкой (TRUNCATE / партиция по дате).
|
||||||
|
INSERT INTO stg.customer_status_raw (customer_id, status, event_ts, _load_id, _load_ts) VALUES
|
||||||
|
('101','active','2024-11-15 09:00:00','batch_20241115_1000','2024-11-15 10:00:00'),
|
||||||
|
('102','active','2024-05-05 09:30:00','batch_20240505_1000','2024-05-05 10:00:00'),
|
||||||
|
('103','active','2024-03-20 12:00:00','batch_20240320_1300','2024-03-20 13:00:00'),
|
||||||
|
('104','new', '2024-06-01 08:00:00','batch_20240601_0900','2024-06-01 09:00:00');
|
||||||
|
|
||||||
|
-- 3.1) UPSERT в ODS: добавляем новые события (не трогаем старые)
|
||||||
|
-- PK в ods.customer_status = (customer_id, event_ts), поэтому каждое уникальное
|
||||||
|
-- событие встаёт отдельной строкой. Дубли (одинаковый customer_id + event_ts) игнорируем.
|
||||||
|
INSERT INTO ods.customer_status (
|
||||||
|
customer_id, status, event_ts, _load_id, _load_ts
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.customer_id::INT,
|
||||||
|
NULLIF(trim(s.status), ''),
|
||||||
|
NULLIF(trim(s.event_ts), '')::TIMESTAMP,
|
||||||
|
s._load_id,
|
||||||
|
COALESCE(s._load_ts, now())
|
||||||
|
FROM stg.customer_status_raw s
|
||||||
|
WHERE s.customer_id ~ '^\d+$'
|
||||||
|
AND NULLIF(trim(s.event_ts), '') IS NOT NULL
|
||||||
|
AND NULLIF(trim(s.status), '') IS NOT NULL
|
||||||
|
ON CONFLICT (customer_id, event_ts) DO NOTHING;
|
||||||
|
|
||||||
|
-- Проверка: в ODS должны появиться новые строки
|
||||||
|
SELECT 'ods.customer_status after increment = ' || COUNT(*) FROM ods.customer_status;
|
||||||
|
|
||||||
|
-- 3.2) Инкрементальное обновление DDS (SCD2)
|
||||||
|
-- Идея:
|
||||||
|
-- 1) берём по каждому клиенту самое позднее событие из ODS;
|
||||||
|
-- 2) сравниваем с текущей версией в DDS (valid_to IS NULL);
|
||||||
|
-- 3) если статус изменился - закрываем старую версию и вставляем новую.
|
||||||
|
--
|
||||||
|
-- Ограничение учебного варианта:
|
||||||
|
-- - если добавили событие "задним числом" со старой датой, этот блок не пересоберёт всю историю.
|
||||||
|
-- Для такого кейса обычно делают full refresh (блок 2).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
-- 3.2a) Закрываем предыдущую актуальную версию
|
||||||
|
WITH ranked AS (
|
||||||
|
-- ranked: выбираем "самое свежее" событие на клиента
|
||||||
|
SELECT
|
||||||
|
customer_id AS customer_bk,
|
||||||
|
status,
|
||||||
|
event_ts::DATE AS eff_date,
|
||||||
|
md5(lower(coalesce(status, ''))) AS hashdiff,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY customer_id
|
||||||
|
ORDER BY event_ts DESC, _load_ts DESC
|
||||||
|
) AS rn
|
||||||
|
FROM ods.customer_status
|
||||||
|
WHERE event_ts IS NOT NULL
|
||||||
|
),
|
||||||
|
delta AS (
|
||||||
|
-- delta: ровно одна строка на клиента (самое свежее событие)
|
||||||
|
SELECT * FROM ranked WHERE rn = 1
|
||||||
|
),
|
||||||
|
current_ver AS (
|
||||||
|
-- current_ver: текущие версии в DDS (valid_to IS NULL)
|
||||||
|
SELECT d.*
|
||||||
|
FROM dds.dim_customer_status d
|
||||||
|
WHERE d.valid_to IS NULL
|
||||||
|
)
|
||||||
|
UPDATE dds.dim_customer_status d
|
||||||
|
SET valid_to = x.eff_date,
|
||||||
|
updated_at = now()
|
||||||
|
FROM (
|
||||||
|
-- x: кого "закрываем":
|
||||||
|
-- клиент уже есть в DDS, и статус действительно изменился.
|
||||||
|
SELECT
|
||||||
|
t.customer_bk,
|
||||||
|
t.eff_date,
|
||||||
|
c.customer_status_sk
|
||||||
|
FROM delta t
|
||||||
|
JOIN current_ver c
|
||||||
|
ON c.customer_bk = t.customer_bk
|
||||||
|
WHERE c.hashdiff <> t.hashdiff
|
||||||
|
AND t.eff_date > c.valid_from -- не создаём период нулевой/отрицательной длины
|
||||||
|
) x
|
||||||
|
WHERE d.customer_status_sk = x.customer_status_sk
|
||||||
|
AND d.valid_to IS NULL;
|
||||||
|
|
||||||
|
-- 3.2b) Вставляем новую версию
|
||||||
|
WITH ranked AS (
|
||||||
|
-- ranked/delta/current_ver повторяем отдельно, чтобы блок INSERT читался отдельно от UPDATE
|
||||||
|
SELECT
|
||||||
|
customer_id AS customer_bk,
|
||||||
|
status,
|
||||||
|
event_ts::DATE AS eff_date,
|
||||||
|
md5(lower(coalesce(status, ''))) AS hashdiff,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY customer_id
|
||||||
|
ORDER BY event_ts DESC, _load_ts DESC
|
||||||
|
) AS rn
|
||||||
|
FROM ods.customer_status
|
||||||
|
WHERE event_ts IS NOT NULL
|
||||||
|
),
|
||||||
|
delta AS (
|
||||||
|
SELECT * FROM ranked WHERE rn = 1
|
||||||
|
),
|
||||||
|
current_ver AS (
|
||||||
|
SELECT d.*
|
||||||
|
FROM dds.dim_customer_status d
|
||||||
|
WHERE d.valid_to IS NULL
|
||||||
|
),
|
||||||
|
to_insert AS (
|
||||||
|
-- to_insert: кого "вставляем":
|
||||||
|
-- 1) новый клиент (в current_ver нет строки);
|
||||||
|
-- 2) изменившийся клиент (статус поменялся).
|
||||||
|
SELECT
|
||||||
|
t.customer_bk,
|
||||||
|
t.status,
|
||||||
|
t.hashdiff,
|
||||||
|
t.eff_date
|
||||||
|
FROM delta t
|
||||||
|
LEFT JOIN current_ver c
|
||||||
|
ON c.customer_bk = t.customer_bk
|
||||||
|
WHERE c.customer_status_sk IS NULL
|
||||||
|
OR (c.hashdiff <> t.hashdiff AND t.eff_date > c.valid_from)
|
||||||
|
)
|
||||||
|
INSERT INTO dds.dim_customer_status (
|
||||||
|
customer_bk, status, hashdiff,
|
||||||
|
valid_from, valid_to,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
t.customer_bk, t.status, t.hashdiff,
|
||||||
|
t.eff_date, NULL,
|
||||||
|
now(), now()
|
||||||
|
FROM to_insert t
|
||||||
|
-- защита от повторного запуска: не вставляем одну и ту же версию (BK + valid_from) второй раз
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM dds.dim_customer_status d
|
||||||
|
WHERE d.customer_bk = t.customer_bk
|
||||||
|
AND d.valid_from = t.eff_date
|
||||||
|
);
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- Проверка: у клиента 101 должна появиться 5-я строка (active с 2024-11-15),
|
||||||
|
-- у 104 - первая строка (new с 2024-06-01)
|
||||||
|
SELECT 'dim_customer_status after increment = ' || COUNT(*) FROM dds.dim_customer_status;
|
||||||
|
SELECT * FROM dds.dim_customer_status ORDER BY customer_bk, valid_from;
|
||||||
|
|
||||||
|
-- ==========================================================
|
||||||
|
-- 4) DM: витрина статусов клиентов по датам (full refresh)
|
||||||
|
-- ==========================================================
|
||||||
|
|
||||||
|
-- Витрина "снимок на дату":
|
||||||
|
-- для каждого дня считаем, сколько клиентов было в каждом статусе.
|
||||||
|
-- Берём календарь dds.dim_date и подбираем статус по периоду valid_from/valid_to.
|
||||||
|
-- DDL витрины - в 07_ddl_hw_customer_status.sql.
|
||||||
|
|
||||||
|
TRUNCATE dm.mart_customer_status_daily;
|
||||||
|
|
||||||
|
WITH bounds AS (
|
||||||
|
SELECT
|
||||||
|
min(valid_from) AS date_from,
|
||||||
|
-- CURRENT_DATE для открытых интервалов (valid_to IS NULL = текущий статус),
|
||||||
|
-- иначе витрина не покроет даты после последней смены статуса.
|
||||||
|
-- Нюанс: количество строк в витрине зависит от даты запуска (каждый
|
||||||
|
-- день добавляется ещё один день). Для учебных целей это приемлемо.
|
||||||
|
max(coalesce(valid_to, CURRENT_DATE)) AS date_to
|
||||||
|
FROM dds.dim_customer_status
|
||||||
|
)
|
||||||
|
INSERT INTO dm.mart_customer_status_daily (
|
||||||
|
date_actual, status, customers_cnt
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
d.date_actual,
|
||||||
|
s.status,
|
||||||
|
COUNT(DISTINCT s.customer_bk) AS customers_cnt
|
||||||
|
FROM dds.dim_date d
|
||||||
|
JOIN bounds b
|
||||||
|
ON d.date_actual BETWEEN b.date_from AND b.date_to
|
||||||
|
JOIN dds.dim_customer_status s
|
||||||
|
ON d.date_actual >= s.valid_from
|
||||||
|
AND (s.valid_to IS NULL OR d.date_actual < s.valid_to)
|
||||||
|
GROUP BY d.date_actual, s.status
|
||||||
|
ORDER BY d.date_actual, s.status;
|
||||||
|
|
||||||
|
-- Проверка: выборочные даты из витрины
|
||||||
|
SELECT 'mart_customer_status_daily count = ' || COUNT(*) FROM dm.mart_customer_status_daily;
|
||||||
|
SELECT *
|
||||||
|
FROM dm.mart_customer_status_daily
|
||||||
|
WHERE date_actual IN ('2024-01-15', '2024-04-10', '2024-09-15', '2024-12-01')
|
||||||
|
ORDER BY date_actual, status;
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# Конфигурация lychee — CI-проверка внешних ссылок
|
||||||
|
# Используется в .github/workflows/check-links.yml (подхватывается автоматически)
|
||||||
|
# Локальный запуск: docker run --rm -v "$PWD:/input" -w /input lycheeverse/lychee './**/*.md'
|
||||||
|
|
||||||
|
# Служебные каталоги и каталоги, исключённые из сайта
|
||||||
|
exclude_path = ["project", "site"]
|
||||||
|
|
||||||
|
# Локальные адреса стендов (127.0.0.1, localhost и т.п.)
|
||||||
|
exclude_all_private = true
|
||||||
|
|
||||||
|
exclude = [
|
||||||
|
# Режут ботов и датацентровые IP (проверять вручную из браузера)
|
||||||
|
"^https?://habr\\.com",
|
||||||
|
"^https?://stepik\\.org",
|
||||||
|
"^https?://leetcode\\.com",
|
||||||
|
"^https?://realpython\\.com",
|
||||||
|
# YouTube в CI ненадёжен: 429 на пачку запросов, удалённые видео отдают 200
|
||||||
|
"^https?://(www\\.)?youtube\\.com",
|
||||||
|
"^https?://youtu\\.be",
|
||||||
|
# Telegram отдаёт 200 даже для несуществующих каналов
|
||||||
|
"^https?://t\\.me",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 429 (rate limit) не считаем битой ссылкой
|
||||||
|
accept = ["200..=204", "429"]
|
||||||
|
|
||||||
|
max_retries = 2
|
||||||
|
timeout = 30
|
||||||
|
user_agent = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
site_name: "DE Roadmap — Data Engineering с нуля до middle"
|
||||||
|
site_url: https://de.dementev.space/
|
||||||
|
site_description: "Роадмап по Data Engineering: SQL, Python, Airflow, Greenplum, Kafka и ClickHouse"
|
||||||
|
site_author: Dmitry Dementev
|
||||||
|
|
||||||
|
repo_url: https://github.com/dementev-dev/de-roadmap
|
||||||
|
repo_name: dementev-dev/de-roadmap
|
||||||
|
|
||||||
|
docs_dir: .
|
||||||
|
site_dir: site
|
||||||
|
|
||||||
|
exclude_docs: |
|
||||||
|
project/
|
||||||
|
postgres-bookings/
|
||||||
|
.github/
|
||||||
|
.gitea/
|
||||||
|
.claude/
|
||||||
|
site/
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
COMMIT_RULES.md
|
||||||
|
LICENSE
|
||||||
|
.gitignore
|
||||||
|
mkdocs.yml
|
||||||
|
dwh-modeling/TODO.md
|
||||||
|
overrides/
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- Роадмап: README.md
|
||||||
|
- Моделирование данных:
|
||||||
|
- Введение: dwh-modeling/README.md
|
||||||
|
- SCD: dwh-modeling/SCD.md
|
||||||
|
- Data Vault: dwh-modeling/DataVault.md
|
||||||
|
- "Домашка: STG → ODS → DDS → DM": dwh-modeling/Homework_Customer_Status_DDS_DM.md
|
||||||
|
- Разработка с ИИ:
|
||||||
|
- Введение: ai-dev/README.md
|
||||||
|
- Лучшие практики: ai-dev/best-practice.md
|
||||||
|
- Механизм памяти: ai-dev/memory-mechanism.md
|
||||||
|
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
custom_dir: overrides
|
||||||
|
language: ru
|
||||||
|
palette:
|
||||||
|
- scheme: default
|
||||||
|
primary: indigo
|
||||||
|
accent: indigo
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-7
|
||||||
|
name: Тёмная тема
|
||||||
|
- scheme: slate
|
||||||
|
primary: indigo
|
||||||
|
accent: indigo
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-4
|
||||||
|
name: Светлая тема
|
||||||
|
features:
|
||||||
|
- navigation.top
|
||||||
|
- navigation.tracking
|
||||||
|
- search.suggest
|
||||||
|
- search.highlight
|
||||||
|
- toc.follow
|
||||||
|
- content.code.copy
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- toc:
|
||||||
|
slugify: !!python/object/apply:pymdownx.slugs.slugify
|
||||||
|
kwds:
|
||||||
|
case: lower
|
||||||
|
permalink: true
|
||||||
|
- admonition
|
||||||
|
- pymdownx.details
|
||||||
|
- pymdownx.superfences
|
||||||
|
- pymdownx.highlight
|
||||||
|
- attr_list
|
||||||
|
|
||||||
|
extra:
|
||||||
|
social:
|
||||||
|
- icon: fontawesome/brands/github
|
||||||
|
link: https://github.com/dementev-dev/de-roadmap
|
||||||
|
name: GitHub
|
||||||
|
- icon: fontawesome/brands/telegram
|
||||||
|
link: https://t.me/dementev_dev
|
||||||
|
name: Написать в Telegram
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- same-dir
|
||||||
|
- search:
|
||||||
|
lang: ru
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block extrahead %}
|
||||||
|
<style>
|
||||||
|
.tg-fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.6rem;
|
||||||
|
left: 1.6rem;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.2rem;
|
||||||
|
height: 3.2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #29b6f6;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, .25);
|
||||||
|
transition: transform .2s, box-shadow .2s, background .2s;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.tg-fab:hover {
|
||||||
|
transform: scale(1.12);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, .3);
|
||||||
|
background: #039be5;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 76.25em) {
|
||||||
|
.tg-fab {
|
||||||
|
width: 2.8rem;
|
||||||
|
height: 2.8rem;
|
||||||
|
bottom: 1.2rem;
|
||||||
|
left: 1.2rem;
|
||||||
|
}
|
||||||
|
.tg-fab svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!-- Yandex.Metrika counter -->
|
||||||
|
<script type="text/javascript">
|
||||||
|
(function(m,e,t,r,i,k,a){
|
||||||
|
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
|
m[i].l=1*new Date();
|
||||||
|
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||||
|
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
||||||
|
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=108294923', 'ym');
|
||||||
|
|
||||||
|
ym(108294923, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
||||||
|
</script>
|
||||||
|
<noscript><div><img src="https://mc.yandex.ru/watch/108294923" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
|
||||||
|
<!-- /Yandex.Metrika counter -->
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ super() }}
|
||||||
|
<a
|
||||||
|
href="https://t.me/dementev_dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="tg-fab"
|
||||||
|
title="Написать в Telegram"
|
||||||
|
aria-label="Написать в Telegram"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="28" fill="currentColor">
|
||||||
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.479.33-.913.492-1.302.48-.428-.012-1.252-.242-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,2 +1,20 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
docker compose exec -it db psql -U ${POSTGRES_USER:-postgres} -d demo
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Usage:
|
||||||
|
# ./psql_sh # interactive psql inside container
|
||||||
|
# ./psql_sh -c "SELECT 1;" # run a command
|
||||||
|
# cat file.csv | ./psql_sh -c "\\copy ... FROM STDIN WITH (FORMAT csv, HEADER true)"
|
||||||
|
|
||||||
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
compose_file="${script_dir}/docker-compose.yml"
|
||||||
|
|
||||||
|
compose() {
|
||||||
|
docker compose -f "${compose_file}" --project-directory "${script_dir}" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -t 0 ]; then
|
||||||
|
compose exec -it db psql -U "${POSTGRES_USER:-postgres}" -d demo "$@"
|
||||||
|
else
|
||||||
|
compose exec -T db psql -U "${POSTGRES_USER:-postgres}" -d demo "$@"
|
||||||
|
fi
|
||||||
+353
@@ -0,0 +1,353 @@
|
|||||||
|
# ADR: Архитектура сайта de-roadmap
|
||||||
|
|
||||||
|
> Архитектурный документ. Проектные цели и требования — в [PRD](./PRD.md).
|
||||||
|
> Решения о публикации и custom domain заменены спецификацией
|
||||||
|
> [«Публикация сайта через Gitea Actions и VPS»](./specs/2026-08-04-gitea-vps-site-publishing.md).
|
||||||
|
> Решения о MkDocs, структуре файлов и ссылках остаются актуальными.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ключевые архитектурные требования
|
||||||
|
|
||||||
|
Из PRD вытекают четыре жёстких ограничения, определяющих выбор инструментов:
|
||||||
|
|
||||||
|
1. **Сайт опционален.** Репозиторий должен полноценно работать без сайта. Удалили конфиг — всё как было.
|
||||||
|
2. **Dual-compatible links.** Внутренние ссылки между `.md` файлами должны работать и на GitHub, и на сайте.
|
||||||
|
3. **Один файл = один источник правды.** Не должно быть копий или генерируемых `.md`. Редактируем один раз — рендерится везде.
|
||||||
|
4. **Zero-effort deploy.** Push в `main` → сайт обновился. Без ручных шагов, без локальной сборки.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Выбор генератора статического сайта
|
||||||
|
|
||||||
|
### Рассмотренные варианты
|
||||||
|
|
||||||
|
| Критерий | MkDocs Material | Docusaurus | Hugo | Astro |
|
||||||
|
|----------|----------------|------------|------|-------|
|
||||||
|
| Язык/экосистема | Python | Node.js (React) | Go | Node.js |
|
||||||
|
| Порог входа | Минимальный — `pip install`, один YAML | Средний — npm, React-компоненты | Средний — Go templates | Высокий — фреймворк |
|
||||||
|
| Поддержка Markdown «как есть» | Отличная, расширения через плагины | Хорошая, но MDX-ориентирован | Хорошая, но shortcodes вместо стандартного MD | Хорошая |
|
||||||
|
| Навигация по длинной странице | TOC sidebar из коробки | Есть, но заточен под многостраничность | Зависит от темы | Зависит от темы |
|
||||||
|
| Поиск | Встроенный (lunr.js), работает offline | Algolia (внешний сервис) или плагин | Нет из коробки | Нет из коробки |
|
||||||
|
| Тёмная тема | Из коробки, переключатель | Из коробки | Зависит от темы | Зависит от темы |
|
||||||
|
| GitHub Pages деплой | Одна команда / готовый Action | Готовый Action | Готовый Action | Готовый Action |
|
||||||
|
| Dual-compatible links | Поддерживает с настройкой `use_directory_urls` | Преобразует ссылки, может ломать GitHub | Преобразует ссылки | Преобразует ссылки |
|
||||||
|
| Один мейнтейнер, Python-стек | ✅ Идеально | ❌ Node.js | ⚠️ Go, но бинарник | ❌ Node.js |
|
||||||
|
|
||||||
|
### Решение: MkDocs Material
|
||||||
|
|
||||||
|
**Почему:**
|
||||||
|
|
||||||
|
- Python-based — совпадает со стеком автора, `pip install` и готово.
|
||||||
|
- Лучшая из коробки поддержка длинных страниц с TOC — именно наш сценарий.
|
||||||
|
- Встроенный поиск без внешних сервисов.
|
||||||
|
- Самый простой конфиг — один `mkdocs.yml`.
|
||||||
|
- Крупнейшее комьюнити среди генераторов документации, активно развивается.
|
||||||
|
- Dual-compatible links решаемы (см. раздел 4).
|
||||||
|
|
||||||
|
**Почему не Docusaurus:** Node.js-зависимость, ориентирован на многостраничные доки с MDX-компонентами — overkill для «красивый рендер Markdown». Преобразование ссылок может конфликтовать с GitHub-форматом.
|
||||||
|
|
||||||
|
**Почему не Hugo:** Быстрый, но Go-шаблоны сложнее отлаживать. Нет встроенного поиска. Для нашего объёма контента скорость сборки не критична.
|
||||||
|
|
||||||
|
**Почему не Astro:** Полноценный веб-фреймворк — избыточен. Подошёл бы, если бы мы строили маркетинговый сайт с интерактивом, но это не текущая цель.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Структура файлов
|
||||||
|
|
||||||
|
### Решение открытого вопроса: `docs/` vs корень репо
|
||||||
|
|
||||||
|
**Решение: `docs_dir: .` (корень репо = корень сайта), с исключениями.**
|
||||||
|
|
||||||
|
Причина: не создаём отдельную папку `docs/`, не дублируем и не перемещаем файлы. MkDocs умеет работать с корнем репо как источником, исключая ненужное.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# mkdocs.yml (в корне репо)
|
||||||
|
docs_dir: .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Решение открытого вопроса: README.md как index
|
||||||
|
|
||||||
|
**Решение: плагин `awesome-pages` или конфигурация `nav` с явным указанием.**
|
||||||
|
|
||||||
|
MkDocs по умолчанию ищет `index.md`. Но мы хотим сохранить `README.md` (GitHub его рендерит на главной репо). Есть два пути:
|
||||||
|
|
||||||
|
- **Вариант A:** Симлинк `docs/index.md → ../README.md`. Но мы не используем `docs/`.
|
||||||
|
- **Вариант B (выбран):** В `mkdocs.yml` явно указать `README.md` как главную:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nav:
|
||||||
|
- Роадмап: README.md
|
||||||
|
- Моделирование данных:
|
||||||
|
- Введение: dwh-modeling/README.md
|
||||||
|
- SCD: dwh-modeling/SCD.md
|
||||||
|
- Data Vault: dwh-modeling/DataVault.md
|
||||||
|
- "Домашка: STG → DDS → DM": dwh-modeling/Homework_Customer_Status_DDS_DM.md
|
||||||
|
```
|
||||||
|
|
||||||
|
MkDocs Material корректно обрабатывает `README.md` файлы — рендерит их как `index.html` соответствующей директории.
|
||||||
|
|
||||||
|
### Исключения из сборки
|
||||||
|
|
||||||
|
Файлы и папки, которые не должны попасть на сайт:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
exclude_docs: |
|
||||||
|
project/ # проектная документация (PRD, ADR)
|
||||||
|
postgres-bookings/ # скрипты стенда (не контент сайта)
|
||||||
|
AGENTS.md # конфигурация для AI-агентов
|
||||||
|
LICENSE # лицензия (есть в footer)
|
||||||
|
.gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
### Итоговая структура репо
|
||||||
|
|
||||||
|
```
|
||||||
|
de-roadmap/
|
||||||
|
├── mkdocs.yml ← конфиг сайта (единственный новый файл в корне)
|
||||||
|
├── README.md ← главная страница сайта И главная страница GitHub
|
||||||
|
├── dwh-modeling/
|
||||||
|
│ ├── README.md ← подстраница «Введение в DWH»
|
||||||
|
│ ├── SCD.md ← подстраница
|
||||||
|
│ ├── DataVault.md ← подстраница
|
||||||
|
│ └── Homework_*.md ← подстраница
|
||||||
|
├── postgres-bookings/ ← исключён из сайта
|
||||||
|
├── project/ ← PRD, ADR — исключены из сайта
|
||||||
|
│ ├── PRD.md
|
||||||
|
│ └── ADR.md
|
||||||
|
├── .github/
|
||||||
|
│ └── workflows/
|
||||||
|
│ └── deploy-site.yml ← CI/CD pipeline
|
||||||
|
├── AGENTS.md ← исключён из сайта
|
||||||
|
└── LICENSE
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Стратегия ссылок (Dual Compatibility)
|
||||||
|
|
||||||
|
### Проблема
|
||||||
|
|
||||||
|
GitHub рендерит ссылки вида `[текст](dwh-modeling/SCD.md)` как переход к файлу.
|
||||||
|
MkDocs по умолчанию преобразует `.md` → `.html` и может менять структуру URL.
|
||||||
|
|
||||||
|
### Решение
|
||||||
|
|
||||||
|
Комбинация настроек MkDocs:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
use_directory_urls: true # /dwh-modeling/SCD/ вместо /dwh-modeling/SCD.html
|
||||||
|
```
|
||||||
|
|
||||||
|
И ссылки в Markdown пишем **всегда как относительные пути к `.md` файлам**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<!-- Это работает и на GitHub, и в MkDocs -->
|
||||||
|
[Теория про SCD](dwh-modeling/SCD.md)
|
||||||
|
[Введение в DWH](dwh-modeling/README.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
MkDocs Material автоматически резолвит `.md` ссылки в правильные URL сайта.
|
||||||
|
|
||||||
|
### Кириллические якоря
|
||||||
|
|
||||||
|
GitHub генерирует якоря из кириллических заголовков с URL-encoding:
|
||||||
|
`## Базы данных` → `#базы-данных`
|
||||||
|
|
||||||
|
MkDocs Material по умолчанию делает то же самое через расширение `toc`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
markdown_extensions:
|
||||||
|
- toc:
|
||||||
|
slugify: !!python/object/apply:pymdownx.slugs.slugify
|
||||||
|
kwds:
|
||||||
|
case: lower
|
||||||
|
permalink: true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Риск:** поведение может различаться на edge cases (спецсимволы, эмодзи в заголовках). Митигация — на этапе MVP протестировать все существующие якорные ссылки в README.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. CI/CD Pipeline
|
||||||
|
|
||||||
|
### GitHub Actions Workflow
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/deploy-site.yml
|
||||||
|
name: Deploy MkDocs to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch: # ручной запуск для отладки
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.x'
|
||||||
|
|
||||||
|
- run: pip install mkdocs-material
|
||||||
|
|
||||||
|
- run: mkdocs build --strict
|
||||||
|
# --strict: падает на warnings (битые ссылки, отсутствующие файлы)
|
||||||
|
|
||||||
|
- uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: site/
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что даёт `--strict`
|
||||||
|
|
||||||
|
Сборка упадёт, если:
|
||||||
|
- Есть ссылка на несуществующий `.md` файл.
|
||||||
|
- Есть битый якорь.
|
||||||
|
- Есть warning от MkDocs.
|
||||||
|
|
||||||
|
Это наш автотест ссылок — бесплатно, в CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Конфигурация MkDocs Material
|
||||||
|
|
||||||
|
### Минимальный `mkdocs.yml` для MVP
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
site_name: "DE Roadmap — Data Engineering с нуля до middle"
|
||||||
|
site_url: https://dementev-dev.github.io/de-roadmap/
|
||||||
|
site_description: "Роадмап по Data Engineering: SQL, Python, Airflow, Greenplum и далее"
|
||||||
|
site_author: Dmitry Dementev
|
||||||
|
|
||||||
|
repo_url: https://github.com/dementev-dev/de-roadmap
|
||||||
|
repo_name: dementev-dev/de-roadmap
|
||||||
|
|
||||||
|
docs_dir: .
|
||||||
|
site_dir: site
|
||||||
|
|
||||||
|
# Исключаем из сборки
|
||||||
|
exclude_docs: |
|
||||||
|
project/
|
||||||
|
postgres-bookings/
|
||||||
|
AGENTS.md
|
||||||
|
LICENSE
|
||||||
|
.gitignore
|
||||||
|
.github/
|
||||||
|
site/
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- Роадмап: README.md
|
||||||
|
- Моделирование данных:
|
||||||
|
- Введение: dwh-modeling/README.md
|
||||||
|
- SCD: dwh-modeling/SCD.md
|
||||||
|
- Data Vault: dwh-modeling/DataVault.md
|
||||||
|
- "Домашка: STG → DDS → DM": dwh-modeling/Homework_Customer_Status_DDS_DM.md
|
||||||
|
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
language: ru
|
||||||
|
palette:
|
||||||
|
- scheme: default
|
||||||
|
primary: indigo
|
||||||
|
accent: indigo
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-7
|
||||||
|
name: Тёмная тема
|
||||||
|
- scheme: slate
|
||||||
|
primary: indigo
|
||||||
|
accent: indigo
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-4
|
||||||
|
name: Светлая тема
|
||||||
|
features:
|
||||||
|
- navigation.top # кнопка «наверх»
|
||||||
|
- navigation.tracking # URL обновляется при скролле
|
||||||
|
- search.suggest # подсказки в поиске
|
||||||
|
- search.highlight # подсветка найденного
|
||||||
|
- toc.follow # TOC следит за скроллом
|
||||||
|
- content.code.copy # кнопка копирования кода
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- toc:
|
||||||
|
permalink: true
|
||||||
|
- admonition # сворачиваемые блоки (Спринт 2)
|
||||||
|
- pymdownx.details # для <details>
|
||||||
|
- pymdownx.superfences # вложенные блоки кода
|
||||||
|
- pymdownx.highlight # подсветка синтаксиса
|
||||||
|
- attr_list # атрибуты для элементов
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- search:
|
||||||
|
lang: ru
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что уже включено в MVP (бесплатно с Material)
|
||||||
|
|
||||||
|
- Тёмная/светлая тема с переключателем.
|
||||||
|
- Полнотекстовый поиск на русском.
|
||||||
|
- TOC (оглавление) в правом sidebar.
|
||||||
|
- Кнопка «наверх» на длинной странице.
|
||||||
|
- URL обновляется при скролле (можно дать ссылку на конкретный раздел).
|
||||||
|
- Подсветка синтаксиса в блоках кода.
|
||||||
|
- Кнопка копирования кода.
|
||||||
|
- Ссылка на GitHub-репозиторий в шапке.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Кастомный домен (Спринт 2)
|
||||||
|
|
||||||
|
Домен `dementev.space` уже есть. Для подключения:
|
||||||
|
|
||||||
|
1. Создать CNAME-запись: `roadmap.dementev.space → dementev-dev.github.io`.
|
||||||
|
2. Добавить файл `CNAME` в корень репо с содержимым `roadmap.dementev.space`.
|
||||||
|
3. В `mkdocs.yml` обновить `site_url`.
|
||||||
|
4. Включить HTTPS в настройках GitHub Pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Риски и митигации
|
||||||
|
|
||||||
|
| Риск | Митигация |
|
||||||
|
|------|-----------|
|
||||||
|
| `docs_dir: .` подхватывает лишние файлы | `exclude_docs` со списком исключений; `--strict` ловит проблемы |
|
||||||
|
| Кириллические якоря ведут себя по-разному | Тестируем все якорные ссылки из README при первом деплое |
|
||||||
|
| `README.md` содержит GitHub-специфичный синтаксис | Проверяем рендер; при необходимости используем MkDocs-совместимые альтернативы |
|
||||||
|
| Зависимость от `mkdocs-material` | Активный проект (30k+ stars), Python-пакет; при необходимости — заморозить версию в `requirements.txt` |
|
||||||
|
| `exclude_docs` — относительно новая фича MkDocs | Альтернатива: `.mkdocsignore` или плагин `mkdocs-exclude`; протестировать на MVP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Порядок действий (Спринт 1)
|
||||||
|
|
||||||
|
1. Создать ветку `feature/site`.
|
||||||
|
2. Добавить `mkdocs.yml` в корень репо.
|
||||||
|
3. Добавить `.github/workflows/deploy-site.yml`.
|
||||||
|
4. Добавить `project/PRD.md` и `project/ADR.md`.
|
||||||
|
5. Проверить локально: `pip install mkdocs-material && mkdocs serve`.
|
||||||
|
6. Протестировать все внутренние ссылки и якоря.
|
||||||
|
7. При необходимости — адаптировать ссылки для dual compatibility.
|
||||||
|
8. Merge в `main` → автодеплой → проверить `https://dementev-dev.github.io/de-roadmap/`.
|
||||||
|
9. Включить GitHub Pages (Settings → Pages → Source: GitHub Actions).
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
# PRD: Сайт для de-roadmap
|
||||||
|
|
||||||
|
> Проектный документ. Техническая архитектура — в отдельном ADR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Контекст и мотивация
|
||||||
|
|
||||||
|
### Текущее состояние
|
||||||
|
|
||||||
|
Роадмап по Data Engineering живет как Git-репозиторий. Основной origin
|
||||||
|
и работа над материалами остаются в собственной Gitea
|
||||||
|
([ddmitry/de-roadmap](https://git.dementev.space/ddmitry/de-roadmap)).
|
||||||
|
После восстановления аккаунта GitHub изменения зеркалируются туда средствами
|
||||||
|
Gitea. Ссылки для менти ведут на [GitHub](https://github.com/dementev-dev/de-roadmap),
|
||||||
|
откуда удобно клонировать стенды и делать свои форки. Сайт по-прежнему
|
||||||
|
публикуется через Gitea Actions на VPS; GitHub Pages сохраняется как резерв.
|
||||||
|
|
||||||
|
- Основной контент — монолитный `README.md` (~700 строк) с полным учебным планом.
|
||||||
|
- Дополнительные материалы — в подпапках (`dwh-modeling/`, `postgres-bookings/`): теория DWH-моделирования, SCD, Data Vault, домашние задания, скрипты.
|
||||||
|
- 109 коммитов, контент активно развивается.
|
||||||
|
- Основной поток менти приходит через маркетплейс ОМ (Осознанная Меркантильность) — платформу менторства с системой отзывов, ранжированием менторов и модерацией. Участие платное (абонентская плата + аукцион за позицию в выдаче).
|
||||||
|
- Роадмап изначально создавался для себя — как конспект подходов к обучению. Со временем стал использоваться и для менти (удобная структура прохождения), и на ознакомительных созвонах с лидами из ОМ (демонстрация программы и подхода к обучению).
|
||||||
|
- Отдельного маркетингового продвижения роадмапа не было, органических приходов «по ссылке из интернета» — ноль. Сайта ментора тоже пока нет — приходить некуда.
|
||||||
|
|
||||||
|
### Проблема
|
||||||
|
|
||||||
|
GitHub README — рабочий, но не презентабельный формат:
|
||||||
|
|
||||||
|
- Нет удобной навигации по длинному документу (только ручной скролл или оглавление-ссылки).
|
||||||
|
- Выглядит как «техническая документация», а не как продукт ментора.
|
||||||
|
- GitHub README плохо индексируется поисковыми системами — роадмап в таком виде даже потенциально не может привлекать органический трафик и «продавать сам себя».
|
||||||
|
- Неудобно давать ссылку потенциальному менти — GitHub-интерфейс отвлекает (issues, commits, файловое дерево).
|
||||||
|
- Нет мобильной адаптации контента (GitHub на телефоне — страдание).
|
||||||
|
|
||||||
|
### Чего НЕ решаем этим проектом
|
||||||
|
|
||||||
|
- **Лидогенерацию и воронку продаж.** Основной поток тёплых лидов идёт через маркетплейс ОМ, и конкурировать с ним по объёму нереалистично — даже топовые менторы не добирают сравнимое количество лидов из других источников. В перспективе сайт может стать элементом маркетинговой воронки (Habr → сайт → менторство), но это отдалённая цель, не влияющая на текущие решения. Сейчас сайт — витрина экспертизы, а не маркетинговый инструмент.
|
||||||
|
- **Масштабирование менторского бизнеса.** Сейчас приоритет — выпустить текущих менти, а не набрать новых.
|
||||||
|
- **Создание LMS / интерактивного курса.** Формат остаётся «структурированный текст + ссылки», без трекинга прогресса и автопроверок.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Цели
|
||||||
|
|
||||||
|
| # | Цель | Метрика успеха |
|
||||||
|
|---|------|----------------|
|
||||||
|
| 1 | Удобная читаемая версия роадмапа | Сайт с навигацией, поиском, мобильной версией |
|
||||||
|
| 2 | Репозиторий остаётся полностью рабочим без сайта | Все `.md` файлы читаемы на GitHub, ссылки между ними работают |
|
||||||
|
| 3 | Минимальные накладные расходы на поддержку | Обновил `.md` → запушил → сайт обновился автоматически |
|
||||||
|
| 4 | Презентабельный вид для демонстрации на созвонах | Ссылка, которую удобно показать лиду из ОМ при обсуждении программы |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Целевая аудитория
|
||||||
|
|
||||||
|
**Основная:** менти (текущие и потенциальные) — джуны и переходящие в DE из смежных областей. Приходят по рекомендации из ОМ или по прямой ссылке от ментора. Им нужно быстро оценить объём программы и найти нужный раздел.
|
||||||
|
|
||||||
|
**Вторичная:** коллеги-инженеры, которые наткнулись на репозиторий через GitHub/поиск. Для них важна полнота контента и техническая достоверность, а не «продающие» элементы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Требования
|
||||||
|
|
||||||
|
### 4.1. Обязательные (MVP)
|
||||||
|
|
||||||
|
**Контент и структура:**
|
||||||
|
|
||||||
|
- [x] Главная страница сайта = полный текст текущего `README.md` (одна длинная страница, не разбиваем).
|
||||||
|
- [x] Подстраницы из существующих `.md` файлов (`dwh-modeling/README.md`, `SCD.md`, `DataVault.md`, домашки).
|
||||||
|
- [x] Автоматическое оглавление (Table of Contents) по заголовкам H2/H3 на главной странице.
|
||||||
|
- [x] Внутренние ссылки работают и на GitHub, и на сайте (dual-compatible links).
|
||||||
|
|
||||||
|
**Навигация:**
|
||||||
|
|
||||||
|
- [x] Боковая панель с разделами сайта (основной README + подразделы).
|
||||||
|
- [x] Оглавление текущей страницы (правый sidebar / TOC).
|
||||||
|
- [x] Полнотекстовый поиск по сайту.
|
||||||
|
|
||||||
|
**Деплой:**
|
||||||
|
|
||||||
|
- [x] Автоматическая сборка и публикация при пуше в `main`.
|
||||||
|
- [x] Публикация без дополнительных расходов: собственная VPS как основной
|
||||||
|
контур, GitHub Pages как резерв.
|
||||||
|
|
||||||
|
**Совместимость с репо:**
|
||||||
|
|
||||||
|
- [x] Все `.md` файлы остаются читаемыми на GitHub «как есть».
|
||||||
|
- [x] `README.md` в корне репо продолжает рендериться на главной странице репозитория.
|
||||||
|
- [x] Добавление сайта не ломает существующую структуру — если удалить конфиг сайта, репо работает как раньше.
|
||||||
|
|
||||||
|
### 4.2. Желательные (Спринт 2+)
|
||||||
|
|
||||||
|
- [x] Кастомный домен: `de.dementev.space` (подключён 2026-03-27, переведён на
|
||||||
|
VPS 2026-08-05).
|
||||||
|
- [x] Тёмная тема (переключатель light/dark).
|
||||||
|
- [x] Сворачиваемые блоки (`<details>`) — точечно, для подсказок/решений в домашках (2026-03-29).
|
||||||
|
- [x] Кнопка «Написать в Telegram» — floating-кнопка + иконки в футере (2026-03-29).
|
||||||
|
- [ ] Иконки/бейджи для статуса разделов (пройден / в процессе / не начат) — пока декоративные, без бэкенда.
|
||||||
|
- [x] Яндекс.Метрика — счётчик 108294923, вебвизор, карта кликов (2026-03-29).
|
||||||
|
|
||||||
|
### 4.3. Явно НЕ делаем
|
||||||
|
|
||||||
|
- Разбивку основного README на отдельные страницы.
|
||||||
|
- Интерактивный трекинг прогресса менти.
|
||||||
|
- Систему авторизации / личный кабинет.
|
||||||
|
- Блог или раздел новостей (для этого есть Telegram-канал).
|
||||||
|
- SEO-оптимизацию и маркетинговые landing pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ограничения
|
||||||
|
|
||||||
|
- **Бюджет: 0 ₽** (кроме домена, если решим подключить кастомный — `dementev.space` уже есть).
|
||||||
|
- **Время на поддержку: минимальное.** Рабочий процесс = «редактирую `.md` → push → сайт обновляется». Не должно быть отдельного шага сборки, ручного деплоя или npm-зависимостей, требующих обновления.
|
||||||
|
- **Стек автора: Python-first.** Решение на Python-тулинге предпочтительнее, чем на Node.js/Ruby (проще отлаживать при необходимости).
|
||||||
|
- **Один мейнтейнер.** Сайт поддерживает один человек — сложность решения должна быть соответствующей.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Итерации
|
||||||
|
|
||||||
|
### Спринт 1 — MVP: «Сайт, который просто работает» ✅ (2026-03-27)
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- Конфигурация генератора статического сайта.
|
||||||
|
- CI/CD pipeline (GitHub Actions → GitHub Pages).
|
||||||
|
- Главная страница = README.
|
||||||
|
- Подстраницы из `dwh-modeling/`.
|
||||||
|
- Фикс внутренних ссылок для dual compatibility.
|
||||||
|
- Адаптация Markdown для Python-Markdown (пустые строки перед списками, 4-пробельные отступы, заголовки `#`→`##`).
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- ~~Сайт доступен по URL `https://dementev-dev.github.io/de-roadmap/`.~~ → `https://de.dementev.space/`
|
||||||
|
- Все ссылки внутри README работают и на сайте, и на GitHub.
|
||||||
|
- При пуше в `main` сайт автоматически пересобирается за < 2 минут (факт: ~34 сек).
|
||||||
|
- Контент на GitHub выглядит ровно так же, как до добавления сайта.
|
||||||
|
|
||||||
|
### Спринт 2 — «Удобство и навигация» (в процессе)
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- ~~Кастомный домен.~~ ✅ `de.dementev.space` (2026-03-27)
|
||||||
|
- ~~Тёмная тема.~~ ✅ из коробки Material (2026-03-27)
|
||||||
|
- ~~Сворачиваемые блоки.~~ ✅ `<details>` точечно в домашках (2026-03-29)
|
||||||
|
- ~~Кнопка «Написать ментору» (Telegram).~~ ✅ floating + футер (2026-03-29)
|
||||||
|
- ~~Базовая аналитика.~~ ✅ Яндекс.Метрика (2026-03-29)
|
||||||
|
|
||||||
|
### Спринт 3 — «Контент и визуал» (по необходимости)
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- Визуальная карта роадмапа (интерактивная диаграмма прохождения).
|
||||||
|
- Страница «О менторе» / «Отзывы выпускников» (когда будут выпускники).
|
||||||
|
- Расширение контента: новые разделы роадмапа → автоматически появляются на сайте.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Риски
|
||||||
|
|
||||||
|
| Риск | Вероятность | Влияние | Митигация |
|
||||||
|
|------|-------------|---------|-----------|
|
||||||
|
| Ссылки ломаются при конвертации (GitHub vs сайт) | Средняя | Высокое | Стратегия dual-compatible links в ADR; автотест ссылок в CI |
|
||||||
|
| Кириллические якоря рендерятся по-разному | Средняя | Среднее | Тестирование конкретного генератора; при необходимости — латинские id |
|
||||||
|
| Генератор сайта перестаёт поддерживаться | Низкая | Среднее | Контент в plain Markdown — миграция на другой генератор за день |
|
||||||
|
| Накладные расходы на поддержку растут | Низкая | Среднее | Принцип «сайт опционален»: если мешает — удаляем конфиг, репо работает |
|
||||||
|
| VPS временно недоступна | Низкая | Высокое | Опубликованный сайт не зависит от Gitea; GitHub Pages сохраняется как ручной резерв |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Открытые вопросы
|
||||||
|
|
||||||
|
1. **Домен.** Использовать GitHub Pages URL или сразу подключить кастомный домен? Можно решить в Спринте 2.
|
||||||
|
2. **README.md в корне vs docs/.** Оставляем README.md в корне (GitHub его рендерит) и используем его же как `index.md` для сайта, или делаем симлинк / копию? → Решение в ADR.
|
||||||
|
3. **Структура `docs/` директории.** Нужно ли вообще создавать `docs/`, или генератор может работать прямо с корнем репо? → Решение в ADR.
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
# TODO: общие задачи проекта
|
||||||
|
|
||||||
|
Приоритеты: P1 — делаем в первую очередь; P2 — полезно, когда дойдут руки; P3 — идеи под вопросом.
|
||||||
|
|
||||||
|
## P1
|
||||||
|
|
||||||
|
- [ ] **Проверить синхронизацию GitHub-зеркала `airflow-greenplum`.**
|
||||||
|
Проверка через `git ls-remote` 2026-09-27: GitHub отстает от Gitea
|
||||||
|
на коммит `4ed7e6f`, меняющий только `docs/agents/triage-labels.md`.
|
||||||
|
Учебные материалы совпадают. У `airflow-manual`, `mini-lakehouse-lab`
|
||||||
|
и `de-roadmap` ветки `main` в Gitea и GitHub совпали; прежнее подозрение
|
||||||
|
об их отставании по ответам API не подтвердилось.
|
||||||
|
|
||||||
|
## P2
|
||||||
|
|
||||||
|
- [ ] **Проработать инструкции по прохождению крупных блоков.**
|
||||||
|
Начать с Python: определить части внешних курсов, глубину изучения
|
||||||
|
и практику для бесплатной альтернативы. Далее связать DEV1, книгу и QPT,
|
||||||
|
затем главы Airflow с существующими заданиями.
|
||||||
|
[Черновик обсуждения](specs/2026-09-27-learning-route.md).
|
||||||
|
|
||||||
|
- [ ] **Дополнить практику Kafka перед Clickstream.**
|
||||||
|
В `nifi-kafka-postgres-lab` добавить опыты на существующем потоке:
|
||||||
|
остановить потребителя и дочитать накопившиеся сообщения; прочитать
|
||||||
|
события двумя независимыми consumer groups; повторить чтение и проверить
|
||||||
|
дубли в PostgreSQL. Для каждого опыта описать исходное состояние,
|
||||||
|
действия и проверку результата. Идея согласована 2026-09-27;
|
||||||
|
реализация в репозитории стенда еще не начата.
|
||||||
|
|
||||||
|
- [ ] **Перенос учебника Airflow в de-roadmap.**
|
||||||
|
Перенести 9 глав учебника из `airflow-manual` в `airflow/` (de-roadmap).
|
||||||
|
В `airflow-manual` оставить только стенд (`airflow-docker/`).
|
||||||
|
Добавить навигацию в `mkdocs.yml`, обеспечить dual-compatible links.
|
||||||
|
|
||||||
|
- [ ] **Добавить счётчик Google Analytics** на сайт (MkDocs Material
|
||||||
|
поддерживает GA через `extra.analytics` в `mkdocs.yml`).
|
||||||
|
|
||||||
|
- [ ] **Ориентиры трудозатрат по блокам.**
|
||||||
|
Одна строка на блок («~N часов»), по образцу курса Lakehouse (~12–15 часов).
|
||||||
|
Менти всегда спрашивают «сколько займёт»; ориентир защищает от провала
|
||||||
|
в практику на месяцы.
|
||||||
|
|
||||||
|
- [ ] **Шаблон прогресса менти.**
|
||||||
|
Файл-чеклист по критериям «когда блок считаем пройденным»; менти копирует
|
||||||
|
в свой форк и ведёт коммитами. Живая практика Git с первой недели +
|
||||||
|
прозрачный прогресс для ментора.
|
||||||
|
|
||||||
|
## P3
|
||||||
|
|
||||||
|
- [ ] **Свой dbt-стенд.**
|
||||||
|
Сейчас практика dbt — на чужом jaffle-shop; единственная секция без
|
||||||
|
собственного стенда. Вариант: dbt-модели поверх postgres-bookings или
|
||||||
|
clickstream-стенда.
|
||||||
|
|
||||||
|
- [ ] **Абзац про Data Quality в курсовой.**
|
||||||
|
Валидационный DAG курсовой — это и есть DQ на практике; добавить абзац,
|
||||||
|
как об этом говорить на собеседовании. Новая секция не нужна.
|
||||||
|
|
||||||
|
- [ ] **Иконки/бейджи статуса разделов** (пройден / в процессе / не начат) —
|
||||||
|
декоративные, без бэкенда. Сомнение: статус у каждого менти свой,
|
||||||
|
пересекается с идеей шаблона прогресса — возможно, отпадёт.
|
||||||
|
|
||||||
|
## Сделано
|
||||||
|
|
||||||
|
- [x] **Исправлена ссылка на практику Airflow** — 2026-10-18:
|
||||||
|
`educational-tasks.md` перенесён в корень `airflow-manual`; ссылка в роадмапе
|
||||||
|
обновлена, новая страница возвращает HTTP 200. Проверены HTTP-ответы
|
||||||
|
118 внешних адресов; единственный полученный 404 исправлен. Ответы
|
||||||
|
403/502 и капча не подтверждают недоступность материала для читателя;
|
||||||
|
доступность видео YouTube по HTTP-статусу не определяется.
|
||||||
|
|
||||||
|
- [x] **Актуализация маршрута и учебных ссылок** - 2026-09-27:
|
||||||
|
ссылки на стенды, SQL и данные, кнопки репозитория на сайте возвращены
|
||||||
|
на GitHub; рабочий origin и публикация сайта остаются в Gitea.
|
||||||
|
Streaming и ClickHouse включены в основную программу перед трудоустройством,
|
||||||
|
`clickstream-data-platform` стал второй, большой курсовой с критериями
|
||||||
|
прохождения. Ссылка на курс ClickHouse для аналитика удалена.
|
||||||
|
venv и JupyterLab перенесены перед курсом Python для продвинутых;
|
||||||
|
в альтернативу курсу для профи добавлены материалы по управлению памятью
|
||||||
|
и критерий проверки понимания.
|
||||||
|
|
||||||
|
- [x] **Публикация сайта перенесена на Gitea Actions и VPS** —
|
||||||
|
2026-08-05: настроены repository-scoped host runner, строгая сборка MkDocs,
|
||||||
|
атомарные релизы, nginx и TLS для `de.dementev.space`; GitHub Pages сохранён
|
||||||
|
как неактивный резерв.
|
||||||
|
|
||||||
|
- [x] **Подраздел «Linux и терминал» в блоке базовых инструментов** —
|
||||||
|
2026-07-12: видео-интро («Девопс на троечку», покрытие проверено по
|
||||||
|
субтитрам) + три статьи (навигация и grep — habr, права — FirstVDS,
|
||||||
|
ssh — Cloud.ru) + опциональный интерактивный курс Hexlet, примечание про
|
||||||
|
WSL для Windows, два новых критерия готовности блока, Linux добавлен
|
||||||
|
в строку оглавления. Отдельной практики нет — ею служат стенды.
|
||||||
|
|
||||||
|
- [x] **CI-проверка внешних ссылок** — 2026-07-12: `lychee.toml` + workflow
|
||||||
|
`check-links.yml` (еженедельно по понедельникам, при битых ссылках создаёт
|
||||||
|
issue). Игнор-лист: habr, stepik, leetcode, realpython (режут ботов),
|
||||||
|
YouTube и t.me (проверка ненадёжна). Попутно исправлена битая ссылка на
|
||||||
|
русские доки Python в README (перевод `/ru/` на docs.python.org умер целиком).
|
||||||
|
|
||||||
|
- [x] **Убрать раздел «Понятие сложности алгоритмов» из README** —
|
||||||
|
2026-07-12, коммит `6f61c0e`: асимптотика перенесена в Python ссылкой
|
||||||
|
на разбор Big O (habr), по SQL — без дублирования, тему покрывает QPT.
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# Эксплуатация публикации `de.dementev.space`
|
||||||
|
|
||||||
|
Этот runbook реализует спецификацию
|
||||||
|
[`2026-08-04-gitea-vps-site-publishing.md`](../../specs/2026-08-04-gitea-vps-site-publishing.md).
|
||||||
|
Команды рассчитаны на Ubuntu 26.04 и Gitea 1.27.
|
||||||
|
|
||||||
|
## Зафиксированные параметры
|
||||||
|
|
||||||
|
- Gitea Runner: `2.3.0`, Linux amd64.
|
||||||
|
- SHA256: `1e9fb1bea022fdf40993ecbc1a13e87db1bfd3d7f42666e37f15f71d840d53b3`.
|
||||||
|
- Runner: repository-scoped, метка `de-roadmap-host:host`.
|
||||||
|
- Пользователь сервиса: `gitea-runner`, без `sudo` и Docker.
|
||||||
|
- Корень публикации: `/srv/de-roadmap`.
|
||||||
|
- Хранение: текущий релиз и две предыдущие версии.
|
||||||
|
- Окружение сборки: `/var/lib/gitea-runner/venvs/site`.
|
||||||
|
- Публичный IPv4 VPS: `167.224.64.252`.
|
||||||
|
|
||||||
|
## Предварительные условия
|
||||||
|
|
||||||
|
- Есть пользователь с `sudo` и рабочий SSH-доступ к VPS.
|
||||||
|
- Есть доступ к управлению DNS-зоной `dementev.space`.
|
||||||
|
- В репозитории `ddmitry/de-roadmap` включены Gitea Actions.
|
||||||
|
|
||||||
|
## Подготовка VPS
|
||||||
|
|
||||||
|
Установить Git, HTTP-сервер, Certbot, UFW и Python venv:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install \
|
||||||
|
ca-certificates \
|
||||||
|
certbot \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
jq \
|
||||||
|
nginx \
|
||||||
|
python3-certbot-nginx \
|
||||||
|
python3-venv \
|
||||||
|
ufw
|
||||||
|
```
|
||||||
|
|
||||||
|
Получить административный checkout, из которого устанавливаются tracked-файлы:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.dementev.space/ddmitry/de-roadmap.git
|
||||||
|
cd de-roadmap
|
||||||
|
```
|
||||||
|
|
||||||
|
Создать пользователя и каталоги:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo useradd \
|
||||||
|
--system \
|
||||||
|
--home-dir /var/lib/gitea-runner \
|
||||||
|
--create-home \
|
||||||
|
--shell /usr/sbin/nologin \
|
||||||
|
gitea-runner
|
||||||
|
sudo install -d -o gitea-runner -g gitea-runner -m 0755 \
|
||||||
|
/var/lib/gitea-runner/workspaces \
|
||||||
|
/srv/de-roadmap \
|
||||||
|
/srv/de-roadmap/releases
|
||||||
|
sudo install -d -o root -g root -m 0755 /etc/gitea-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
Скачать runner во временный каталог, сверить checksum и установить root-owned
|
||||||
|
бинарник в `/usr/local/bin/gitea-runner`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
runner_tmp_dir=$(mktemp -d /tmp/de-roadmap-runner.XXXXXX)
|
||||||
|
curl -fsSLo "${runner_tmp_dir}/gitea-runner" \
|
||||||
|
https://dl.gitea.com/gitea-runner/2.3.0/gitea-runner-2.3.0-linux-amd64
|
||||||
|
printf '%s %s\n' \
|
||||||
|
'1e9fb1bea022fdf40993ecbc1a13e87db1bfd3d7f42666e37f15f71d840d53b3' \
|
||||||
|
"${runner_tmp_dir}/gitea-runner" \
|
||||||
|
| sha256sum --check
|
||||||
|
sudo install -o root -g root -m 0755 \
|
||||||
|
"${runner_tmp_dir}/gitea-runner" /usr/local/bin/gitea-runner
|
||||||
|
rm "${runner_tmp_dir}/gitea-runner"
|
||||||
|
rmdir "$runner_tmp_dir"
|
||||||
|
```
|
||||||
|
|
||||||
|
Из корня репозитория установить конфигурацию и unit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -o root -g root -m 0644 \
|
||||||
|
project/ops/gitea-vps-site/gitea-runner.yaml \
|
||||||
|
/etc/gitea-runner/config.yaml
|
||||||
|
sudo install -o root -g root -m 0644 \
|
||||||
|
project/ops/gitea-vps-site/gitea-runner.service \
|
||||||
|
/etc/systemd/system/gitea-runner.service
|
||||||
|
sudo systemd-analyze verify /etc/systemd/system/gitea-runner.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Регистрация runner
|
||||||
|
|
||||||
|
Repository registration token получают в Gitea:
|
||||||
|
`ddmitry/de-roadmap` → Settings → Actions → Runners. Токен не сохраняют в Git
|
||||||
|
или shell history. Временный файл с токеном создают с владельцем
|
||||||
|
`gitea-runner:gitea-runner` и режимом `0600`. После регистрации файл
|
||||||
|
`/var/lib/gitea-runner/.runner` должен принадлежать тому же пользователю и
|
||||||
|
иметь режим `0600`.
|
||||||
|
|
||||||
|
Token-file должен содержать ровно 40 символов без завершающего перевода строки.
|
||||||
|
При извлечении JSON-ответа через `jq` использовать `jq --join-output '.token'`,
|
||||||
|
а не `jq --raw-output`, который добавляет newline.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u gitea-runner \
|
||||||
|
/usr/local/bin/gitea-runner \
|
||||||
|
--config /etc/gitea-runner/config.yaml \
|
||||||
|
register \
|
||||||
|
--no-interactive \
|
||||||
|
--instance https://git.dementev.space \
|
||||||
|
--token-file /var/lib/gitea-runner/.registration-token \
|
||||||
|
--name de-roadmap-vps
|
||||||
|
sudo rm /var/lib/gitea-runner/.registration-token
|
||||||
|
sudo chmod 0600 /var/lib/gitea-runner/.runner
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now gitea-runner
|
||||||
|
systemctl is-enabled gitea-runner
|
||||||
|
systemctl is-active gitea-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
Временный файл с токеном удаляют сразу после успешной регистрации. В Gitea на
|
||||||
|
странице Settings → Actions → Runners runner `de-roadmap-vps` должен перейти в
|
||||||
|
состояние online и показывать метку `de-roadmap-host`.
|
||||||
|
|
||||||
|
## Nginx и первичная публикация
|
||||||
|
|
||||||
|
Из корня репозитория установить bootstrap-конфигурацию nginx, создать ссылку,
|
||||||
|
отключить стандартный сайт Ubuntu и только затем перечитать проверенную
|
||||||
|
конфигурацию:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo install -o root -g root -m 0644 \
|
||||||
|
project/ops/gitea-vps-site/nginx.conf \
|
||||||
|
/etc/nginx/sites-available/de-roadmap
|
||||||
|
sudo ln -s \
|
||||||
|
/etc/nginx/sites-available/de-roadmap \
|
||||||
|
/etc/nginx/sites-enabled/de-roadmap
|
||||||
|
sudo unlink /etc/nginx/sites-enabled/default
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Первый server block в `nginx.conf` возвращает `404` для неизвестных HTTP Host,
|
||||||
|
не раскрывает версию nginx и отклоняет TLS handshake для IP или неизвестного
|
||||||
|
SNI. Поэтому сертификат `de.dementev.space` не выдаётся при обращении к VPS по
|
||||||
|
IP. Если проверка конфигурации не прошла, отключить новый virtual host,
|
||||||
|
восстановить стандартный сайт и перечитать проверенную конфигурацию:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo unlink /etc/nginx/sites-enabled/de-roadmap
|
||||||
|
sudo ln -s \
|
||||||
|
/etc/nginx/sites-available/default \
|
||||||
|
/etc/nginx/sites-enabled/default
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Файл `project/ops/gitea-vps-site/nginx.conf` предназначен только для запуска до
|
||||||
|
выпуска сертификата. После выпуска сертификата Certbot изменяет установленный
|
||||||
|
virtual host. Повторная установка bootstrap-файла поверх рабочего конфига
|
||||||
|
удалит TLS-директивы.
|
||||||
|
|
||||||
|
## Окружение сборки
|
||||||
|
|
||||||
|
Скрипт `.gitea/scripts/build-site.sh` создаёт persistent venv при первом запуске
|
||||||
|
и переиспользует его в следующих сборках. `pip install` выполняется каждый раз,
|
||||||
|
чтобы применить изменения `.gitea/requirements-site.txt`, но уже установленные
|
||||||
|
версии пакетов не переустанавливаются.
|
||||||
|
|
||||||
|
## Первый деплой
|
||||||
|
|
||||||
|
В Gitea открыть Actions → Deploy MkDocs to VPS, выбрать ветку `main` и нажать
|
||||||
|
Run workflow. Job `deploy` должен завершиться успешно. Проверить опубликованный
|
||||||
|
release и локальную выдачу nginx до переключения DNS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
readlink -f /srv/de-roadmap/current
|
||||||
|
curl --fail --header 'Host: de.dementev.space' http://127.0.0.1/
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем разрешить SSH, HTTP и HTTPS в UFW. Если SSH работает не на стандартном
|
||||||
|
порту `22`, сначала разрешить фактический порт вместо профиля `OpenSSH`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw default deny incoming
|
||||||
|
sudo ufw default allow outgoing
|
||||||
|
sudo ufw allow OpenSSH
|
||||||
|
sudo ufw allow 'Nginx Full'
|
||||||
|
sudo ufw enable
|
||||||
|
sudo ufw status verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидается политика `deny (incoming)` и разрешения только для SSH, `80/tcp` и
|
||||||
|
`443/tcp`.
|
||||||
|
|
||||||
|
## DNS и TLS
|
||||||
|
|
||||||
|
1. Уменьшить TTL записи `de.dementev.space`.
|
||||||
|
2. Направить `A` на VPS; удалить или корректно направить `AAAA`.
|
||||||
|
3. Убедиться, что сайт доступен извне по HTTP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --fail --head http://de.dementev.space/
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Выпустить сертификат и включить перенаправление HTTP на HTTPS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo certbot --nginx \
|
||||||
|
--non-interactive \
|
||||||
|
--agree-tos \
|
||||||
|
--email me@dementev.space \
|
||||||
|
--redirect \
|
||||||
|
-d de.dementev.space
|
||||||
|
```
|
||||||
|
|
||||||
|
5. В созданном Certbot HTTP-блоке для `de.dementev.space` добавить
|
||||||
|
`server_tokens off;`, затем проверить и перечитать конфигурацию:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudoedit /etc/nginx/sites-available/de-roadmap
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Проверить перенаправление, HTTPS, сертификат и автоматическое продление:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --head http://de.dementev.space/
|
||||||
|
curl --fail --head https://de.dementev.space/
|
||||||
|
! curl --insecure --head https://167.224.64.252/
|
||||||
|
sudo certbot certificates
|
||||||
|
systemctl is-enabled certbot.timer
|
||||||
|
systemctl is-active certbot.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидаются `301 Moved Permanently`, затем `200 OK`, отказ TLS по IP,
|
||||||
|
действующий сертификат и состояния таймера `enabled` и `active`.
|
||||||
|
|
||||||
|
7. Вернуть обычный DNS TTL.
|
||||||
|
|
||||||
|
## Проверка и откат
|
||||||
|
|
||||||
|
Активная версия определяется ссылкой `/srv/de-roadmap/current`. Для ручного
|
||||||
|
отката сначала выбрать точный release id из сохранённых каталогов, затем создать
|
||||||
|
временную ссылку и атомарно заменить `current`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find /srv/de-roadmap/releases \
|
||||||
|
-mindepth 1 \
|
||||||
|
-maxdepth 1 \
|
||||||
|
-type d \
|
||||||
|
-printf '%f\n' \
|
||||||
|
| sort
|
||||||
|
|
||||||
|
rollback_release='<COMMIT_SHA>-<RUN_ID>'
|
||||||
|
rollback_link='/srv/de-roadmap/.current.rollback'
|
||||||
|
[[ "$rollback_release" =~ ^[0-9a-f]{40}-[0-9]+$ ]]
|
||||||
|
sudo test -d "/srv/de-roadmap/releases/${rollback_release}"
|
||||||
|
sudo test ! -e "$rollback_link"
|
||||||
|
sudo -u gitea-runner \
|
||||||
|
ln -s "releases/${rollback_release}" "$rollback_link"
|
||||||
|
sudo -u gitea-runner \
|
||||||
|
mv -Tf "$rollback_link" /srv/de-roadmap/current
|
||||||
|
readlink -f /srv/de-roadmap/current
|
||||||
|
curl --fail --head https://de.dementev.space/
|
||||||
|
```
|
||||||
|
|
||||||
|
Откат не удаляет более новые releases. Перед их ручным удалением всегда
|
||||||
|
проверять результат `readlink -f /srv/de-roadmap/current`.
|
||||||
|
|
||||||
|
Диагностика:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status gitea-runner
|
||||||
|
sudo journalctl -u gitea-runner
|
||||||
|
sudo nginx -t
|
||||||
|
readlink -f /srv/de-roadmap/current
|
||||||
|
curl --fail --head https://de.dementev.space/
|
||||||
|
```
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Gitea Actions runner for de-roadmap
|
||||||
|
Documentation=https://docs.gitea.com/usage/actions
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=gitea-runner
|
||||||
|
Group=gitea-runner
|
||||||
|
WorkingDirectory=/var/lib/gitea-runner
|
||||||
|
ExecStart=/usr/local/bin/gitea-runner daemon --config /etc/gitea-runner/config.yaml
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10s
|
||||||
|
TimeoutStopSec=45s
|
||||||
|
KillMode=mixed
|
||||||
|
|
||||||
|
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
|
||||||
|
UMask=0022
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateDevices=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectHostname=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ReadWritePaths=/var/lib/gitea-runner /srv/de-roadmap
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
LockPersonality=true
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
|
||||||
|
MemoryMax=1G
|
||||||
|
CPUQuota=100%
|
||||||
|
TasksMax=128
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
log:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
runner:
|
||||||
|
file: /var/lib/gitea-runner/.runner
|
||||||
|
capacity: 1
|
||||||
|
timeout: 15m
|
||||||
|
shutdown_timeout: 30s
|
||||||
|
insecure: false
|
||||||
|
fetch_timeout: 5s
|
||||||
|
fetch_interval: 2s
|
||||||
|
fetch_interval_max: 10s
|
||||||
|
workdir_cleanup_age: 24h
|
||||||
|
idle_cleanup_interval: 10m
|
||||||
|
labels:
|
||||||
|
- "de-roadmap-host:host"
|
||||||
|
allocate_pty: false
|
||||||
|
|
||||||
|
cache:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
container:
|
||||||
|
valid_volumes: []
|
||||||
|
docker_host: "-"
|
||||||
|
require_docker: false
|
||||||
|
|
||||||
|
host:
|
||||||
|
workdir_parent: /var/lib/gitea-runner/workspaces
|
||||||
|
|
||||||
|
health_check:
|
||||||
|
enabled: true
|
||||||
|
min_free_disk_space_mb: 1024
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
enabled: false
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
listen [::]:443 ssl default_server;
|
||||||
|
|
||||||
|
server_name _;
|
||||||
|
server_tokens off;
|
||||||
|
ssl_reject_handshake on;
|
||||||
|
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
|
||||||
|
server_name de.dementev.space;
|
||||||
|
root /srv/de-roadmap/current;
|
||||||
|
index index.html;
|
||||||
|
charset utf-8;
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Публикация сайта через Gitea Actions и VPS
|
||||||
|
|
||||||
|
Статус: публикация на VPS реализована 2026-08-05. После восстановления
|
||||||
|
доступа к GitHub выбран встроенный push mirror Gitea для синхронизации
|
||||||
|
репозиториев; рабочий origin остается в Gitea. Инструкции по
|
||||||
|
восстановлению и эксплуатации находятся в
|
||||||
|
[`project/ops/gitea-vps-site/README.md`](../ops/gitea-vps-site/README.md).
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Сайт `de.dementev.space` публиковался через GitHub Actions и GitHub Pages. После блокировки учётной записи GitHub основной домен стал недоступен, хотя исходный Markdown и конфигурация MkDocs сохранились, а основной Git-репозиторий уже размещён в собственной Gitea.
|
||||||
|
|
||||||
|
Публикация сайта не должна зависеть от доступности учётной записи GitHub. При этом рабочий процесс из исходной архитектуры сохраняется: изменение попадает в `main`, сайт собирается и обновляется автоматически без отдельного ручного деплоя.
|
||||||
|
|
||||||
|
## Цели
|
||||||
|
|
||||||
|
- Публиковать `de.dementev.space` с существующей VPS.
|
||||||
|
- Запускать сборку и публикацию автоматически после push в `main` в Gitea.
|
||||||
|
- Не заменять работающую версию сайта, если получение исходников или строгая сборка MkDocs завершились ошибкой.
|
||||||
|
- Ограничить права runner каталогами, необходимыми для сборки и публикации сайта.
|
||||||
|
- Сохранить GitHub Pages как заранее собираемый резерв на случай проблем с VPS.
|
||||||
|
- Сохранить минимальные накладные расходы и нулевые дополнительные расходы на хостинг.
|
||||||
|
|
||||||
|
## Не цели
|
||||||
|
|
||||||
|
- Автоматическое переключение между VPS и GitHub Pages.
|
||||||
|
- Жёсткие требования к времени восстановления или высокой доступности.
|
||||||
|
- Перенос еженедельной проверки внешних ссылок с GitHub Actions в Gitea Actions.
|
||||||
|
- Изменение существующих файлов в `.github/workflows/`.
|
||||||
|
- Добавление динамического приложения, авторизации или серверной базы данных.
|
||||||
|
|
||||||
|
## Исходное состояние на 2026-08-04
|
||||||
|
|
||||||
|
- MkDocs Material собирает статический каталог `site/`; `site_url` уже равен `https://de.dementev.space/`.
|
||||||
|
- Публичный репозиторий `ddmitry/de-roadmap` в Gitea является текущим `origin`.
|
||||||
|
- Gitea Actions для репозитория включены, но подходящего runner пока нет.
|
||||||
|
- Gitea видит существующие GitHub workflows, однако они зависят от GitHub Pages, GitHub-токенов и сторонних actions.
|
||||||
|
- На VPS, выбранной для сайта, пока нет HTTP-сервера; публично доступен только SSH.
|
||||||
|
|
||||||
|
## Выбранное решение
|
||||||
|
|
||||||
|
### Основной контур публикации
|
||||||
|
|
||||||
|
Gitea становится источником событий для основной публикации. На VPS с сайтом работает repository-scoped Gitea runner, зарегистрированный только для `de-roadmap`. Runner использует host mode и отдельную метку, предназначенную только для этого workflow.
|
||||||
|
|
||||||
|
Workflow хранится отдельно в `.gitea/workflows/deploy-site.yml`. Gitea выбирает `.gitea/workflows` раньше `.github/workflows`, поэтому GitHub-specific workflows сохраняются в репозитории, но не исполняются Gitea.
|
||||||
|
|
||||||
|
Workflow запускается после push в `main` и вручную. Он получает конкретный commit из публичного Gitea-репозитория обычным Git, создаёт изолированное Python-окружение, устанавливает закреплённые версии зависимостей и выполняет строгую сборку MkDocs. Сторонние `uses:` не применяются, поэтому сборка не зависит от GitHub Actions Marketplace.
|
||||||
|
|
||||||
|
### Изоляция runner
|
||||||
|
|
||||||
|
Runner работает как отдельный непривилегированный системный пользователь. У него нет `sudo`, членства в группе `docker` и доступа на запись к конфигурации nginx, сертификатам или другим сервисам VPS.
|
||||||
|
|
||||||
|
Пользователь runner может записывать только в собственный рабочий каталог и каталог релизов сайта. Workflow не запускается для pull request из недоверенных веток. Потребление памяти, CPU и количество процессов ограничиваются средствами менеджера сервисов операционной системы.
|
||||||
|
|
||||||
|
### Публикация и восстановление после ошибки
|
||||||
|
|
||||||
|
Каждая успешная сборка создаёт отдельную версию статического сайта. Новая версия становится активной только после завершения всех проверок. Переключение между версиями выполняется атомарно; частично собранный каталог никогда не становится корнем сайта.
|
||||||
|
|
||||||
|
Ошибка получения исходников, установки зависимостей или сборки оставляет активной предыдущую версию. Как минимум одна предыдущая успешная версия сохраняется для быстрого ручного отката.
|
||||||
|
|
||||||
|
### HTTP и HTTPS
|
||||||
|
|
||||||
|
Статические файлы обслуживает штатный nginx из репозитория Ubuntu. Nginx только читает активную версию сайта и не требует перезагрузки при обычной публикации контента.
|
||||||
|
|
||||||
|
TLS-сертификат для `de.dementev.space` получает и продлевает Certbot с интеграцией nginx. Перед переключением проверяются локальная сборка и конфигурация nginx, а DNS TTL заранее уменьшается. Затем DNS направляется на VPS, проверяется публичная доступность по HTTP и выпускается сертификат. Короткий интервал между переключением DNS и готовностью HTTPS допустим, поскольку жёсткого требования к непрерывной доступности нет.
|
||||||
|
|
||||||
|
### Резерв на GitHub Pages
|
||||||
|
|
||||||
|
Изменения из основной Gitea передаются на GitHub встроенным push mirror.
|
||||||
|
Ссылки для учащихся ведут на GitHub-зеркала, основной домен остается на VPS.
|
||||||
|
Workflow в `.github/workflows/deploy-site.yml` сохранен для резервной
|
||||||
|
публикации. Перед использованием резерва нужно проверить настройки Pages
|
||||||
|
и успешный деплой: наличие зеркала репозитория само по себе этого не подтверждает.
|
||||||
|
|
||||||
|
GitHub Pages считается тёплым резервом контента и холодным резервом домена. При отказе VPS владелец вручную переключает DNS и при необходимости повторно активирует custom domain в настройках GitHub Pages. Допустимы задержка распространения DNS и ожидание выпуска TLS-сертификата; автоматический failover не требуется.
|
||||||
|
|
||||||
|
## Отклонённые варианты
|
||||||
|
|
||||||
|
- **Периодический pull с VPS:** проще инфраструктурно, но не даёт опыта работы с Gitea runner и менее наглядно связывает push с результатом сборки.
|
||||||
|
- **Runner рядом с Gitea:** усложняет доставку результата на VPS и позволяет сборкам влиять на ресурсы Git-сервера.
|
||||||
|
- **Jobs в Docker:** дают лучшую изоляцию, но требуют доступа runner к Docker и отдельного механизма передачи результата в каталог nginx. Для одного доверенного репозитория это лишняя сложность.
|
||||||
|
- **Caddy вместо nginx:** упрощает автоматический TLS, но штатный nginx лучше соответствует предпочтению владельца и доступен с обновлениями безопасности из основного репозитория Ubuntu. Certbot закрывает задачу TLS отдельно.
|
||||||
|
- **Cloudflare Pages, GitLab Pages или другой внешний Pages-сервис:** уменьшают нагрузку на VPS, но добавляют новую внешнюю учётную запись и зависимость, от которой как раз уходим.
|
||||||
|
- **Ожидание разблокировки GitHub:** сохраняет старую архитектуру, но оставляет сайт недоступным на неопределённый срок.
|
||||||
|
|
||||||
|
## Риски и меры
|
||||||
|
|
||||||
|
| Риск | Мера |
|
||||||
|
|------|------|
|
||||||
|
| Workflow исполняет команды непосредственно на VPS | Repository-scoped runner, отдельный пользователь без `sudo` и Docker, узкие права на запись, запуск только из `main`, системные лимиты ресурсов |
|
||||||
|
| Ошибка сборки ломает опубликованный сайт | Сборка в отдельной версии и атомарное переключение только после успеха |
|
||||||
|
| Сторонний action выполняет неожиданный код | Workflow состоит из собственных команд и не использует `uses:` |
|
||||||
|
| Gitea временно недоступна | Уже опубликованный сайт обслуживается независимо от Gitea |
|
||||||
|
| VPS недоступна или потеряна | Исходники остаются в Gitea; GitHub Pages служит ручным резервом после проверки резервной публикации |
|
||||||
|
| После переключения DNS HTTPS ещё не готов | DNS TTL уменьшается заранее; Certbot запускается сразу после подтверждения публичного HTTP; короткий перерыв принят как допустимый |
|
||||||
|
| Сертификат GitHub Pages не готов во время аварии | Допускается задержка; на время восстановления используется стандартный адрес GitHub Pages |
|
||||||
|
| GitHub-резерв отстает от Gitea | Проверяем результат push mirror, совпадение коммитов и резервную сборку; до успешной проверки резерв не считается готовым |
|
||||||
|
|
||||||
|
## Проверка реализации
|
||||||
|
|
||||||
|
- Runner после перезагрузки VPS автоматически подключается к Gitea и принимает только jobs с выделенной меткой.
|
||||||
|
- Push тестового изменения в `main` запускает ровно один Gitea workflow и публикует соответствующий commit.
|
||||||
|
- Gitea не запускает workflows из `.github/workflows/`, а сами файлы остаются без изменений.
|
||||||
|
- Workflow не обращается к GitHub за actions и не требует GitHub-токенов.
|
||||||
|
- Ошибка `mkdocs build --strict` завершает workflow с ошибкой и не изменяет публичную версию сайта.
|
||||||
|
- Успешная сборка переключает сайт целиком, без периода частично обновлённого содержимого.
|
||||||
|
- Пользователь runner не может использовать `sudo`, Docker или изменять конфигурацию nginx.
|
||||||
|
- `https://de.dementev.space/` отдаёт собранный сайт с действующим сертификатом после переключения DNS.
|
||||||
|
- Nginx и runner восстанавливаются после перезагрузки VPS без ручного запуска.
|
||||||
|
- После восстановления GitHub и настройки синхронизации резервная сборка получает тот же commit и остаётся доступна по стандартному адресу GitHub Pages.
|
||||||
|
|
||||||
|
## Влияние на документацию
|
||||||
|
|
||||||
|
Эта спецификация заменяет решения о публикации и custom domain из `project/ADR.md`. Решения того документа о MkDocs Material, структуре файлов и dual-compatible links остаются актуальными.
|
||||||
|
|
||||||
|
Документация проекта описывает Gitea Actions и VPS как основной контур
|
||||||
|
публикации. Ссылки репозитория в `mkdocs.yml` направлены на GitHub для менти;
|
||||||
|
рабочий origin остается в Gitea.
|
||||||
|
|
||||||
|
## Синхронизация GitHub
|
||||||
|
|
||||||
|
Способ синхронизации выбран: встроенный Gitea push mirror. Рабочие изменения
|
||||||
|
вносятся в Gitea, GitHub используется как зеркало для чтения и создания
|
||||||
|
учебных форков. Нужно отдельно проверить работу зеркал всех стендов и
|
||||||
|
готовность резервной публикации Pages.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Как проходить материалы роадмапа
|
||||||
|
|
||||||
|
Статус: черновик для обсуждения. Базовая актуализация опубликована отдельно
|
||||||
|
в коммите `806417c`. Здесь прорабатываем следующий этап: письменные указания
|
||||||
|
по порядку занятий, глубине чтения и выполнению заданий.
|
||||||
|
|
||||||
|
## Задача
|
||||||
|
|
||||||
|
На занятиях ментор регулярно объясняет, какие части внешнего курса нужны
|
||||||
|
целиком, где достаточно нескольких упражнений и когда переходить к стенду.
|
||||||
|
Эти указания должны быть доступны рядом с материалами, до начала работы.
|
||||||
|
|
||||||
|
Предлагаемый формат: в README оставить общий маршрут. Подробные инструкции
|
||||||
|
к крупным блокам вынести на отдельные страницы со ссылками из этих разделов.
|
||||||
|
У каждого шага должны быть понятны материал, глубина изучения, практика
|
||||||
|
и результат, с которым можно переходить дальше.
|
||||||
|
|
||||||
|
## Какие вопросы должна закрыть инструкция
|
||||||
|
|
||||||
|
- Какие части внешних курсов Python нужны для дальнейшей практики?
|
||||||
|
- Насколько подробно изучать устройство PostgreSQL в QPT?
|
||||||
|
- Какие главы книги прочитать перед конкретными лекциями DEV1?
|
||||||
|
- Как поддерживать SQL-практику после перехода к следующему блоку?
|
||||||
|
- Когда запускать каждый стенд и какие задания на нем выполнять?
|
||||||
|
|
||||||
|
Объем упражнений связываем с проверяемыми умениями. Индивидуальные сроки
|
||||||
|
и сокращения программы не превращаем в требования ко всем учащимся.
|
||||||
|
|
||||||
|
## Что уже определено
|
||||||
|
|
||||||
|
- Docker впервые используем на DEV1: запускаем PostgreSQL, проверяем статус,
|
||||||
|
подключаемся к базе. Подробный блок Docker остается дальше.
|
||||||
|
- Data Vault и Anchor Modeling изучаем обзорно.
|
||||||
|
- venv и JupyterLab осваиваем перед Python для продвинутых.
|
||||||
|
- Greenplum остается первой курсовой; Kafka, ClickHouse и Clickstream входят
|
||||||
|
в основную программу. Lakehouse и dbt идут после нее.
|
||||||
|
- Задания Python и SQL в Airflow уже есть. Для них нужна привязка к чтению
|
||||||
|
учебника и моменту выполнения.
|
||||||
|
- Опыты с Kafka записаны отдельной задачей в [TODO](../TODO.md).
|
||||||
|
|
||||||
|
## Первый блок для проработки: Python
|
||||||
|
|
||||||
|
Собрать инструкцию к существующим курсам и бесплатным материалам.
|
||||||
|
Начальный вариант границ для проверки:
|
||||||
|
|
||||||
|
| Тема | Как проходить | Как проверить результат |
|
||||||
|
|---|---|---|
|
||||||
|
| Вложенные списки и матрицы | Выборочные упражнения на механику | Самостоятельно обойти и изменить вложенный список |
|
||||||
|
| Словари, функции, файлы | Подробно, с практикой | Прочитать данные, преобразовать их и сохранить результат |
|
||||||
|
| Turtle и Monte Carlo | Можно пропустить в основном маршруте | Отдельная проверка не нужна |
|
||||||
|
| Память и ссылки, исключения, итераторы и генераторы | Включить и в курс, и в бесплатную альтернативу | Объяснить поведение небольшого примера и самостоятельно изменить его |
|
||||||
|
|
||||||
|
Два варианта после advanced Python должны приводить к одному набору умений:
|
||||||
|
выбранные части Python Professional или последовательность бесплатных
|
||||||
|
материалов с упражнениями. Особенно проверить покрытие управления памятью
|
||||||
|
и обработки исключений.
|
||||||
|
|
||||||
|
Перед публикацией нужно сверить актуальное оглавление обоих курсов,
|
||||||
|
подобрать точные разделы и определить достаточную практику. Проценты заданий
|
||||||
|
из отдельной встречи не заменяют этого отбора. Также нужно уточнить глубину
|
||||||
|
ООП, pandas и декораторов для дальнейших заданий Airflow.
|
||||||
|
|
||||||
|
## Следующие кандидаты
|
||||||
|
|
||||||
|
Для DEV1 связать лекции, главы книги и практику на `postgres-bookings`.
|
||||||
|
Например, чтение о транзакциях поставить перед темой MVCC. На входе пояснить,
|
||||||
|
где выполняются команды: терминал, `psql`, редактор SQL в DBeaver.
|
||||||
|
|
||||||
|
Для QPT описать результат первого прохода и возвращение к теме позже.
|
||||||
|
Предлагаемый результат: прочитать план, сопоставить оценки с фактическими
|
||||||
|
строками, сформулировать и проверить гипотезу. Повторная практика должна
|
||||||
|
использовать уже знакомую базу и конкретный запрос.
|
||||||
|
|
||||||
|
Для Airflow сопоставить главы учебника с существующими заданиями.
|
||||||
|
В каждом переходе обозначить, что сначала разбираем по готовому примеру,
|
||||||
|
что делаем самостоятельно и как проверяем результат.
|
||||||
|
|
||||||
|
На переходах между крупными блоками напомнить о короткой регулярной практике
|
||||||
|
SQL, а после Python добавить и его поддержание. Объем подбирать по учебной
|
||||||
|
нагрузке; календарные сроки всей программы пока не фиксировать.
|
||||||
|
|
||||||
|
## Как оценить первый результат
|
||||||
|
|
||||||
|
На примере Python проверить, может ли читатель определить свой следующий шаг,
|
||||||
|
объем чтения и практики, способ проверки и условие перехода дальше.
|
||||||
|
После этого решить, подходит ли такой формат для остальных блоков.
|
||||||
Reference in new issue
Block a user