Commit 83a5183
Eric Bower
·
2026-05-05 10:47:35 -0400 EDT
parent dc6ed61
chore(visits): new indexes and parallel queries
3 files changed,
+162,
-88
M
Makefile
+2,
-1
| ... | ... | @@ -151,10 +151,11 @@ migrate: | |
| 151 | 151 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260116_add_analytics_filter_indexes.sql | |
| 152 | 152 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260305_add_forms_table.sql | |
| 153 | 153 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260503_add_analytics_summary_tables.sql | |
| 154 | + | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260504_add_analytics_summary_indexes.sql | |
| 154 | 155 | .PHONY: migrate | |
| 155 | 156 | ||
| 156 | 157 | latest: | |
| 157 | - | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260503_add_analytics_summary_tables.sql | |
| 158 | + | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20260504_add_analytics_summary_indexes.sql | |
| 158 | 159 | .PHONY: latest | |
| 159 | 160 | ||
| 160 | 161 | psql: |
+153,
-87
| ... | ... | @@ -9,6 +9,7 @@ import ( | |
| 9 | 9 | "slices" | |
| 10 | 10 | "sort" | |
| 11 | 11 | "strings" | |
| 12 | + | "sync" | |
| 12 | 13 | "time" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/jmoiron/sqlx" |
| ... | ... | @@ -724,14 +725,28 @@ func visitFilterBy(opts *db.SummaryOpts) (string, string) { | |
| 724 | 725 | } | |
| 725 | 726 | ||
| 726 | 727 | func (me *PsqlDB) visitUnique(opts *db.SummaryOpts) ([]*db.VisitInterval, error) { | |
| 727 | - | intervals, err := me.visitUniqueFromSummary(opts) | |
| 728 | - | if err != nil { | |
| 729 | - | return nil, fmt.Errorf("query summary visits: %w", err) | |
| 730 | - | } | |
| 728 | + | var intervals, currentIntervals []*db.VisitInterval | |
| 729 | + | var sumErr, rawErr error | |
| 731 | 730 | ||
| 732 | - | currentIntervals, err := me.visitUniqueFromRaw(opts) | |
| 733 | - | if err != nil { | |
| 734 | - | return nil, fmt.Errorf("query raw visits: %w", err) | |
| 731 | + | var wg sync.WaitGroup | |
| 732 | + | wg.Add(2) | |
| 733 | + | ||
| 734 | + | go func() { | |
| 735 | + | defer wg.Done() | |
| 736 | + | intervals, sumErr = me.visitUniqueFromSummary(opts) | |
| 737 | + | }() | |
| 738 | + | go func() { | |
| 739 | + | defer wg.Done() | |
| 740 | + | currentIntervals, rawErr = me.visitUniqueFromRaw(opts) | |
| 741 | + | }() | |
| 742 | + | ||
| 743 | + | wg.Wait() | |
| 744 | + | ||
| 745 | + | if sumErr != nil { | |
| 746 | + | return nil, fmt.Errorf("query summary visits: %w", sumErr) | |
| 747 | + | } | |
| 748 | + | if rawErr != nil { | |
| 749 | + | return nil, fmt.Errorf("query raw visits: %w", rawErr) | |
| 735 | 750 | } | |
| 736 | 751 | ||
| 737 | 752 | // Merge: current month data may overlap with summary data, combine counts |
| ... | ... | @@ -744,23 +759,13 @@ func (me *PsqlDB) visitUniqueFromSummary(opts *db.SummaryOpts) ([]*db.VisitInter | |
| 744 | 759 | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 745 | 760 | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 746 | 761 | ||
| 747 | - | // Always include at least the previous month in the summary query, since raw data | |
| 748 | - | // for the previous month may have been deleted by aggregation. visitUniqueFromRaw | |
| 749 | - | // covers the previous and current months, but once aggregation runs and deletes raw | |
| 750 | - | // data, the summary table is the only source. Using min(origin, previousMonthStart) | |
| 751 | - | // ensures we don't skip the previous month even when origin is set to the current month. | |
| 752 | - | effectiveOrigin := opts.Origin | |
| 753 | - | if opts.Origin.After(previousMonthStart) { | |
| 754 | - | effectiveOrigin = previousMonthStart | |
| 755 | - | } | |
| 756 | - | ||
| 757 | - | // If effective origin is in or after current month, no historical data to fetch | |
| 758 | - | if !effectiveOrigin.Before(currentMonthStart) { | |
| 762 | + | // If origin is in the previous month or later, raw data covers it — no summary to fetch. | |
| 763 | + | if !opts.Origin.Before(previousMonthStart) { | |
| 759 | 764 | return nil, nil | |
| 760 | 765 | } | |
| 761 | 766 | ||
| 762 | 767 | where := "" | |
| 763 | - | args := []interface{}{opts.UserID, effectiveOrigin, currentMonthStart} | |
| 768 | + | args := []interface{}{opts.UserID, opts.Origin, currentMonthStart} | |
| 764 | 769 | argIdx := 4 | |
| 765 | 770 | if opts.Host != "" { | |
| 766 | 771 | where = "AND host = $" + fmt.Sprintf("%d", argIdx) |
| ... | ... | @@ -953,14 +958,28 @@ func mergeHosts(historical, current []*db.VisitUrl) []*db.VisitUrl { | |
| 953 | 958 | } | |
| 954 | 959 | ||
| 955 | 960 | func (me *PsqlDB) visitReferer(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 956 | - | historical, err := me.visitRefererFromSummary(opts) | |
| 957 | - | if err != nil { | |
| 958 | - | return nil, fmt.Errorf("query summary referers: %w", err) | |
| 959 | - | } | |
| 961 | + | var historical, current []*db.VisitUrl | |
| 962 | + | var histErr, rawErr error | |
| 960 | 963 | ||
| 961 | - | current, err := me.visitRefererFromRaw(opts) | |
| 962 | - | if err != nil { | |
| 963 | - | return nil, fmt.Errorf("query raw referers: %w", err) | |
| 964 | + | var wg sync.WaitGroup | |
| 965 | + | wg.Add(2) | |
| 966 | + | ||
| 967 | + | go func() { | |
| 968 | + | defer wg.Done() | |
| 969 | + | historical, histErr = me.visitRefererFromSummary(opts) | |
| 970 | + | }() | |
| 971 | + | go func() { | |
| 972 | + | defer wg.Done() | |
| 973 | + | current, rawErr = me.visitRefererFromRaw(opts) | |
| 974 | + | }() | |
| 975 | + | ||
| 976 | + | wg.Wait() | |
| 977 | + | ||
| 978 | + | if histErr != nil { | |
| 979 | + | return nil, fmt.Errorf("query summary referers: %w", histErr) | |
| 980 | + | } | |
| 981 | + | if rawErr != nil { | |
| 982 | + | return nil, fmt.Errorf("query raw referers: %w", rawErr) | |
| 964 | 983 | } | |
| 965 | 984 | ||
| 966 | 985 | return mergeTopReferers(historical, current), nil |
| ... | ... | @@ -972,18 +991,13 @@ func (me *PsqlDB) visitRefererFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, | |
| 972 | 991 | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 973 | 992 | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 974 | 993 | ||
| 975 | - | effectiveOrigin := opts.Origin | |
| 976 | - | if opts.Origin.After(previousMonthStart) { | |
| 977 | - | effectiveOrigin = previousMonthStart | |
| 978 | - | } | |
| 979 | - | ||
| 980 | - | // If effective origin is in or after current month, no historical data to fetch | |
| 981 | - | if !effectiveOrigin.Before(currentMonthStart) { | |
| 994 | + | // If origin is in the previous month or later, raw data covers it — no summary to fetch. | |
| 995 | + | if !opts.Origin.Before(previousMonthStart) { | |
| 982 | 996 | return nil, nil | |
| 983 | 997 | } | |
| 984 | 998 | ||
| 985 | 999 | // Clamp origin to month boundary for summary table lookup | |
| 986 | - | originMonthStart := time.Date(effectiveOrigin.Year(), effectiveOrigin.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1000 | + | originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 987 | 1001 | ||
| 988 | 1002 | where := "" | |
| 989 | 1003 | args := []interface{}{opts.UserID, originMonthStart, currentMonthStart} |
| ... | ... | @@ -1059,14 +1073,28 @@ func (me *PsqlDB) visitRefererFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, err | |
| 1059 | 1073 | } | |
| 1060 | 1074 | ||
| 1061 | 1075 | func (me *PsqlDB) visitUrl(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 1062 | - | historical, err := me.visitUrlFromSummary(opts) | |
| 1063 | - | if err != nil { | |
| 1064 | - | return nil, fmt.Errorf("query summary urls: %w", err) | |
| 1065 | - | } | |
| 1076 | + | var historical, current []*db.VisitUrl | |
| 1077 | + | var histErr, rawErr error | |
| 1066 | 1078 | ||
| 1067 | - | current, err := me.visitUrlFromRaw(opts) | |
| 1068 | - | if err != nil { | |
| 1069 | - | return nil, fmt.Errorf("query raw urls: %w", err) | |
| 1079 | + | var wg sync.WaitGroup | |
| 1080 | + | wg.Add(2) | |
| 1081 | + | ||
| 1082 | + | go func() { | |
| 1083 | + | defer wg.Done() | |
| 1084 | + | historical, histErr = me.visitUrlFromSummary(opts) | |
| 1085 | + | }() | |
| 1086 | + | go func() { | |
| 1087 | + | defer wg.Done() | |
| 1088 | + | current, rawErr = me.visitUrlFromRaw(opts) | |
| 1089 | + | }() | |
| 1090 | + | ||
| 1091 | + | wg.Wait() | |
| 1092 | + | ||
| 1093 | + | if histErr != nil { | |
| 1094 | + | return nil, fmt.Errorf("query summary urls: %w", histErr) | |
| 1095 | + | } | |
| 1096 | + | if rawErr != nil { | |
| 1097 | + | return nil, fmt.Errorf("query raw urls: %w", rawErr) | |
| 1070 | 1098 | } | |
| 1071 | 1099 | ||
| 1072 | 1100 | return mergeTopUrls(historical, current), nil |
| ... | ... | @@ -1078,18 +1106,13 @@ func (me *PsqlDB) visitUrlFromSummary(opts *db.SummaryOpts) ([]*db.VisitUrl, err | |
| 1078 | 1106 | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1079 | 1107 | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 1080 | 1108 | ||
| 1081 | - | effectiveOrigin := opts.Origin | |
| 1082 | - | if opts.Origin.After(previousMonthStart) { | |
| 1083 | - | effectiveOrigin = previousMonthStart | |
| 1084 | - | } | |
| 1085 | - | ||
| 1086 | - | // If effective origin is in or after current month, no historical data to fetch | |
| 1087 | - | if !effectiveOrigin.Before(currentMonthStart) { | |
| 1109 | + | // If origin is in the previous month or later, raw data covers it — no summary to fetch. | |
| 1110 | + | if !opts.Origin.Before(previousMonthStart) { | |
| 1088 | 1111 | return nil, nil | |
| 1089 | 1112 | } | |
| 1090 | 1113 | ||
| 1091 | 1114 | // Clamp origin to month boundary for summary table lookup | |
| 1092 | - | originMonthStart := time.Date(effectiveOrigin.Year(), effectiveOrigin.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1115 | + | originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1093 | 1116 | ||
| 1094 | 1117 | where := "" | |
| 1095 | 1118 | args := []interface{}{opts.UserID, originMonthStart, currentMonthStart} |
| ... | ... | @@ -1170,14 +1193,28 @@ func (me *PsqlDB) VisitUrlNotFound(opts *db.SummaryOpts) ([]*db.VisitUrl, error) | |
| 1170 | 1193 | limit = 10 | |
| 1171 | 1194 | } | |
| 1172 | 1195 | ||
| 1173 | - | historical, err := me.visitUrlNotFoundFromSummary(opts, limit) | |
| 1174 | - | if err != nil { | |
| 1175 | - | return nil, fmt.Errorf("query summary 404 urls: %w", err) | |
| 1176 | - | } | |
| 1196 | + | var historical, current []*db.VisitUrl | |
| 1197 | + | var histErr, rawErr error | |
| 1177 | 1198 | ||
| 1178 | - | current, err := me.visitUrlNotFoundFromRaw(opts, limit) | |
| 1179 | - | if err != nil { | |
| 1180 | - | return nil, fmt.Errorf("query raw 404 urls: %w", err) | |
| 1199 | + | var wg sync.WaitGroup | |
| 1200 | + | wg.Add(2) | |
| 1201 | + | ||
| 1202 | + | go func() { | |
| 1203 | + | defer wg.Done() | |
| 1204 | + | historical, histErr = me.visitUrlNotFoundFromSummary(opts, limit) | |
| 1205 | + | }() | |
| 1206 | + | go func() { | |
| 1207 | + | defer wg.Done() | |
| 1208 | + | current, rawErr = me.visitUrlNotFoundFromRaw(opts, limit) | |
| 1209 | + | }() | |
| 1210 | + | ||
| 1211 | + | wg.Wait() | |
| 1212 | + | ||
| 1213 | + | if histErr != nil { | |
| 1214 | + | return nil, fmt.Errorf("query summary 404 urls: %w", histErr) | |
| 1215 | + | } | |
| 1216 | + | if rawErr != nil { | |
| 1217 | + | return nil, fmt.Errorf("query raw 404 urls: %w", rawErr) | |
| 1181 | 1218 | } | |
| 1182 | 1219 | ||
| 1183 | 1220 | return mergeTopUrls(historical, current), nil |
| ... | ... | @@ -1189,18 +1226,13 @@ func (me *PsqlDB) visitUrlNotFoundFromSummary(opts *db.SummaryOpts, limit int) ( | |
| 1189 | 1226 | currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1190 | 1227 | previousMonthStart := currentMonthStart.AddDate(0, -1, 0) | |
| 1191 | 1228 | ||
| 1192 | - | effectiveOrigin := opts.Origin | |
| 1193 | - | if opts.Origin.After(previousMonthStart) { | |
| 1194 | - | effectiveOrigin = previousMonthStart | |
| 1195 | - | } | |
| 1196 | - | ||
| 1197 | - | // If effective origin is in or after current month, no historical data to fetch | |
| 1198 | - | if !effectiveOrigin.Before(currentMonthStart) { | |
| 1229 | + | // If origin is in the previous month or later, raw data covers it — no summary to fetch. | |
| 1230 | + | if !opts.Origin.Before(previousMonthStart) { | |
| 1199 | 1231 | return nil, nil | |
| 1200 | 1232 | } | |
| 1201 | 1233 | ||
| 1202 | 1234 | // Clamp origin to month boundary for summary table lookup | |
| 1203 | - | originMonthStart := time.Date(effectiveOrigin.Year(), effectiveOrigin.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1235 | + | originMonthStart := time.Date(opts.Origin.Year(), opts.Origin.Month(), 1, 0, 0, 0, 0, time.UTC) | |
| 1204 | 1236 | ||
| 1205 | 1237 | where := "" | |
| 1206 | 1238 | args := []interface{}{opts.UserID, originMonthStart, currentMonthStart} |
| ... | ... | @@ -1277,14 +1309,28 @@ func (me *PsqlDB) visitUrlNotFoundFromRaw(opts *db.SummaryOpts, limit int) ([]*d | |
| 1277 | 1309 | } | |
| 1278 | 1310 | ||
| 1279 | 1311 | func (me *PsqlDB) visitHost(opts *db.SummaryOpts) ([]*db.VisitUrl, error) { | |
| 1280 | - | historical, err := me.visitHostFromSummary(opts) | |
| 1281 | - | if err != nil { | |
| 1282 | - | return nil, fmt.Errorf("query summary hosts: %w", err) | |
| 1283 | - | } | |
| 1312 | + | var historical, current []*db.VisitUrl | |
| 1313 | + | var histErr, rawErr error | |
| 1284 | 1314 | ||
| 1285 | - | current, err := me.visitHostFromRaw(opts) | |
| 1286 | - | if err != nil { | |
| 1287 | - | return nil, fmt.Errorf("query raw hosts: %w", err) | |
| 1315 | + | var wg sync.WaitGroup | |
| 1316 | + | wg.Add(2) | |
| 1317 | + | ||
| 1318 | + | go func() { | |
| 1319 | + | defer wg.Done() | |
| 1320 | + | historical, histErr = me.visitHostFromSummary(opts) | |
| 1321 | + | }() | |
| 1322 | + | go func() { | |
| 1323 | + | defer wg.Done() | |
| 1324 | + | current, rawErr = me.visitHostFromRaw(opts) | |
| 1325 | + | }() | |
| 1326 | + | ||
| 1327 | + | wg.Wait() | |
| 1328 | + | ||
| 1329 | + | if histErr != nil { | |
| 1330 | + | return nil, fmt.Errorf("query summary hosts: %w", histErr) | |
| 1331 | + | } | |
| 1332 | + | if rawErr != nil { | |
| 1333 | + | return nil, fmt.Errorf("query raw hosts: %w", rawErr) | |
| 1288 | 1334 | } | |
| 1289 | 1335 | ||
| 1290 | 1336 | return mergeHosts(historical, current), nil |
| ... | ... | @@ -1341,24 +1387,44 @@ func (me *PsqlDB) visitHostFromRaw(opts *db.SummaryOpts) ([]*db.VisitUrl, error) | |
| 1341 | 1387 | } | |
| 1342 | 1388 | ||
| 1343 | 1389 | func (me *PsqlDB) VisitSummary(opts *db.SummaryOpts) (*db.SummaryVisits, error) { | |
| 1344 | - | visitors, err := me.visitUnique(opts) | |
| 1345 | - | if err != nil { | |
| 1346 | - | return nil, err | |
| 1347 | - | } | |
| 1390 | + | var ( | |
| 1391 | + | visitors []*db.VisitInterval | |
| 1392 | + | urls []*db.VisitUrl | |
| 1393 | + | refs []*db.VisitUrl | |
| 1394 | + | notFound []*db.VisitUrl | |
| 1395 | + | visitorsErr error | |
| 1396 | + | urlsErr error | |
| 1397 | + | refsErr error | |
| 1398 | + | nfErr error | |
| 1399 | + | ) | |
| 1348 | 1400 | ||
| 1349 | - | urls, err := me.visitUrl(opts) | |
| 1350 | - | if err != nil { | |
| 1351 | - | return nil, err | |
| 1352 | - | } | |
| 1401 | + | var wg sync.WaitGroup | |
| 1402 | + | wg.Add(4) | |
| 1353 | 1403 | ||
| 1354 | - | notFound, err := me.VisitUrlNotFound(opts) | |
| 1355 | - | if err != nil { | |
| 1356 | - | return nil, err | |
| 1357 | - | } | |
| 1404 | + | go func() { | |
| 1405 | + | defer wg.Done() | |
| 1406 | + | visitors, visitorsErr = me.visitUnique(opts) | |
| 1407 | + | }() | |
| 1408 | + | go func() { | |
| 1409 | + | defer wg.Done() | |
| 1410 | + | urls, urlsErr = me.visitUrl(opts) | |
| 1411 | + | }() | |
| 1412 | + | go func() { | |
| 1413 | + | defer wg.Done() | |
| 1414 | + | refs, refsErr = me.visitReferer(opts) | |
| 1415 | + | }() | |
| 1416 | + | go func() { | |
| 1417 | + | defer wg.Done() | |
| 1418 | + | notFound, nfErr = me.VisitUrlNotFound(opts) | |
| 1419 | + | }() | |
| 1358 | 1420 | ||
| 1359 | - | refs, err := me.visitReferer(opts) | |
| 1360 | - | if err != nil { | |
| 1361 | - | return nil, err | |
| 1421 | + | wg.Wait() | |
| 1422 | + | ||
| 1423 | + | // Return the first error encountered | |
| 1424 | + | for _, err := range []error{visitorsErr, urlsErr, refsErr, nfErr} { | |
| 1425 | + | if err != nil { | |
| 1426 | + | return nil, err | |
| 1427 | + | } | |
| 1362 | 1428 | } | |
| 1363 | 1429 | ||
| 1364 | 1430 | return &db.SummaryVisits{ |
| ... | ... | @@ -0,0 +1,7 @@ | |
| 1 | + | -- Composite index for visitUniqueFromSummary: filters by (user_id, host, visit_date) | |
| 2 | + | CREATE INDEX IF NOT EXISTS idx_monthly_visits_user_host_date | |
| 3 | + | ON analytics_monthly_visits (user_id, host, visit_date); | |
| 4 | + | ||
| 5 | + | -- Covering index for visitHostFromRaw: filters by (user_id, created_at), groups by host, counts distinct ip_address | |
| 6 | + | CREATE INDEX IF NOT EXISTS idx_analytics_visits_user_created_host_ip | |
| 7 | + | ON analytics_visits (user_id, created_at, host, ip_address); |