Skip to content

Commit 1352ea0

Browse files
authored
Analyze where necessary (#1427)
* Analyze where necessary * analyze task should show up in reg
1 parent fe4f8c9 commit 1352ea0

11 files changed

Lines changed: 328 additions & 0 deletions

File tree

cmd/curio/tasks/tasks.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import (
4444
"github.com/filecoin-project/curio/market/libp2p"
4545
"github.com/filecoin-project/curio/pdpnode"
4646
"github.com/filecoin-project/curio/tasks/balancemgr"
47+
"github.com/filecoin-project/curio/tasks/dbmaint"
4748
"github.com/filecoin-project/curio/tasks/expmgr"
4849
"github.com/filecoin-project/curio/tasks/f3"
4950
"github.com/filecoin-project/curio/tasks/gc"
@@ -130,6 +131,9 @@ func StartTasks(ctx context.Context, dependencies *deps.Deps, shutdownChan chan
130131
balanceMgrTask := balancemgr.NewBalanceMgrTask(db, full, chainSched, sender)
131132
expmgrTask := expmgr.NewExpMgrTask(db, full, chainSched, sender)
132133
activeTasks = append(activeTasks, sendTask, balanceMgrTask, expmgrTask)
134+
if cfg.Subsystems.EnableDBAnalyze {
135+
activeTasks = append(activeTasks, dbmaint.NewDBAnalyzeTask(db))
136+
}
133137
dependencies.Sender = sender
134138

135139
// paramfetch

deps/config/doc_gen.go

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

deps/config/types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func DefaultCurioConfig() *CurioConfig {
2121
IndexingMaxTasks: 8,
2222
RemoteProofMaxUploads: 15,
2323
ParkPieceMinFreeStoragePercent: 5,
24+
EnableDBAnalyze: true,
2425
},
2526
Fees: CurioFees{
2627
MaxPreCommitBatchGasFee: BatchFeeConfig{
@@ -471,6 +472,12 @@ type CurioSubsystemsConfig struct {
471472
// EnableWalletExporter enables the wallet exporter on the node. This will export wallet stats to prometheus.
472473
// NOTE: THIS MUST BE ENABLED ONLY ON A SINGLE NODE IN THE CLUSTER TO BE USEFUL (Default: false)
473474
EnableWalletExporter bool
475+
476+
// EnableDBAnalyze enables the cluster-wide DBAnalyze singleton task to speed up SQL queries.
477+
// It periodically runs ANALYZE on tables whose write churn (pg_stat_user_tables) has grown
478+
// by 10% since the last analyze.
479+
// Disable this if you manage table statistics outside Curio. (Default: true)
480+
EnableDBAnalyze bool
474481
}
475482
type CurioFees struct {
476483
// maxBatchFee = maxBase + maxPerSector * nSectors

documentation/en/configuration/default-curio-configuration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,14 @@ description: The default curio configuration
365365
# type: bool
366366
#EnableWalletExporter = false
367367

368+
# EnableDBAnalyze enables the cluster-wide DBAnalyze singleton task to speed up SQL queries.
369+
# It periodically runs ANALYZE on tables whose write churn (pg_stat_user_tables) has grown
370+
# by 10% since the last analyze.
371+
# Disable this if you manage table statistics outside Curio. (Default: true)
372+
#
373+
# type: bool
374+
#EnableDBAnalyze = true
375+
368376

369377
# Fees holds the fee-related configuration parameters for various operations in the Curio node.
370378
#
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DROP TABLE IF EXISTS table_analyze_state;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
-- Tracks table write churn observed at the time of the last ANALYZE issued by the
2+
-- DBAnalyze task, so tables are only re-analyzed after meaningful growth.
3+
CREATE TABLE IF NOT EXISTS table_analyze_state (
4+
table_name TEXT PRIMARY KEY,
5+
churn_at_analyze BIGINT NOT NULL,
6+
rows_at_analyze BIGINT NOT NULL DEFAULT 0,
7+
last_analyzed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8+
analyze_count BIGINT NOT NULL DEFAULT 0
9+
);

harmony/harmonytask/curio_mayfollow_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
// Side-effect imports: harmonytask.Reg in each package fills Registry for these tests.
1919
_ "github.com/filecoin-project/curio/alertmanager"
2020
_ "github.com/filecoin-project/curio/tasks/balancemgr"
21+
_ "github.com/filecoin-project/curio/tasks/dbmaint"
2122
_ "github.com/filecoin-project/curio/tasks/expmgr"
2223
_ "github.com/filecoin-project/curio/tasks/f3"
2324
_ "github.com/filecoin-project/curio/tasks/gc"

pdpnode/tasks.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/filecoin-project/curio/harmony/resources/ffigpu"
1313
"github.com/filecoin-project/curio/harmony/taskhelp"
1414
"github.com/filecoin-project/curio/lib/chainsched"
15+
"github.com/filecoin-project/curio/tasks/dbmaint"
1516
"github.com/filecoin-project/curio/tasks/gc"
1617
"github.com/filecoin-project/curio/tasks/indexing"
1718
"github.com/filecoin-project/curio/tasks/message"
@@ -113,6 +114,9 @@ func buildPDPTasks(ctx context.Context, d *Deps, chainSched *chainsched.CurioCha
113114
if pdponly {
114115
amTask := alertmanager.NewAlertTask(d.Chain, db, cfg.Alerting)
115116
tasks = append(tasks, amTask, gc.NewPieceCleanupTask(db, d.IndexStore))
117+
if cfg.Subsystems.EnableDBAnalyze {
118+
tasks = append(tasks, dbmaint.NewDBAnalyzeTask(db))
119+
}
116120
return &pdpTaskBundle{
117121
tasks: tasks,
118122
amTask: amTask,

tasks/dbmaint/task_db_analyze.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package dbmaint
2+
3+
import (
4+
"context"
5+
"strings"
6+
"time"
7+
8+
logging "github.com/ipfs/go-log/v2"
9+
"github.com/yugabyte/pgx/v5"
10+
"golang.org/x/mod/semver"
11+
"golang.org/x/xerrors"
12+
13+
curiobuild "github.com/filecoin-project/curio/build"
14+
"github.com/filecoin-project/curio/harmony/harmonydb"
15+
"github.com/filecoin-project/curio/harmony/harmonytask"
16+
"github.com/filecoin-project/curio/harmony/resources"
17+
"github.com/filecoin-project/curio/harmony/taskhelp"
18+
"github.com/filecoin-project/curio/tasks/tasknames"
19+
)
20+
21+
var log = logging.Logger("dbanalyze")
22+
23+
const (
24+
analyzeInterval = 24 * time.Hour
25+
analyzeGrowthThreshold = 0.10
26+
minChurnDelta = 100
27+
perTableAnalyzeTimeout = 10 * time.Minute
28+
upgradeQuietWindow = time.Hour
29+
)
30+
31+
type DBAnalyzeTask struct {
32+
db *harmonydb.DB
33+
}
34+
35+
func NewDBAnalyzeTask(db *harmonydb.DB) *DBAnalyzeTask {
36+
return &DBAnalyzeTask{db: db}
37+
}
38+
39+
func (d *DBAnalyzeTask) Do(ctx context.Context, taskID harmonytask.TaskID, stillOwned func() bool) (done bool, err error) {
40+
skip, err := d.skipForRollingUpgrade(ctx)
41+
if err != nil {
42+
return false, xerrors.Errorf("checking rolling upgrade: %w", err)
43+
}
44+
if skip {
45+
return true, nil
46+
}
47+
48+
var rows []struct {
49+
TableName string `db:"table_name"`
50+
Churn int64 `db:"churn"`
51+
LiveRows int64 `db:"live_rows"`
52+
ChurnAtAnalyze *int64 `db:"churn_at_analyze"`
53+
}
54+
err = d.db.Select(ctx, &rows, `
55+
SELECT s.relname AS table_name,
56+
s.n_tup_ins + s.n_tup_upd + s.n_tup_del AS churn,
57+
s.n_live_tup AS live_rows,
58+
a.churn_at_analyze
59+
FROM pg_stat_user_tables s
60+
LEFT JOIN table_analyze_state a ON a.table_name = s.relname
61+
WHERE s.schemaname = current_schema()
62+
ORDER BY s.relname`)
63+
if err != nil {
64+
return false, xerrors.Errorf("listing table churn: %w", err)
65+
}
66+
67+
var schema string
68+
if err := d.db.QueryRow(ctx, `SELECT current_schema()`).Scan(&schema); err != nil {
69+
return false, xerrors.Errorf("getting current schema: %w", err)
70+
}
71+
72+
analyzed := 0
73+
for _, r := range rows {
74+
if !stillOwned() {
75+
return false, nil
76+
}
77+
if !shouldAnalyze(r.Churn, r.ChurnAtAnalyze) {
78+
continue
79+
}
80+
81+
tctx, cancel := context.WithTimeout(ctx, perTableAnalyzeTimeout)
82+
_, err = harmonydb.AdminQuery(tctx, d.db, "ANALYZE "+pgx.Identifier{schema, r.TableName}.Sanitize())
83+
cancel()
84+
if err != nil {
85+
log.Warnw("ANALYZE failed", "table", r.TableName, "error", err)
86+
continue
87+
}
88+
89+
_, err = d.db.Exec(ctx, `
90+
INSERT INTO table_analyze_state (table_name, churn_at_analyze, rows_at_analyze, last_analyzed_at, analyze_count)
91+
VALUES ($1, $2, $3, NOW(), 1)
92+
ON CONFLICT (table_name) DO UPDATE SET
93+
churn_at_analyze = EXCLUDED.churn_at_analyze,
94+
rows_at_analyze = EXCLUDED.rows_at_analyze,
95+
last_analyzed_at = NOW(),
96+
analyze_count = table_analyze_state.analyze_count + 1`,
97+
r.TableName, r.Churn, r.LiveRows)
98+
if err != nil {
99+
log.Warnw("failed to record analyze state", "table", r.TableName, "error", err)
100+
continue
101+
}
102+
103+
analyzed++
104+
log.Infow("analyzed table", "table", r.TableName, "churn", r.Churn, "live_rows", r.LiveRows)
105+
}
106+
107+
if _, err := d.db.Exec(ctx, `
108+
DELETE FROM table_analyze_state
109+
WHERE table_name NOT IN (
110+
SELECT relname FROM pg_stat_user_tables WHERE schemaname = current_schema()
111+
)`); err != nil {
112+
log.Warnw("failed to prune dropped table analyze state", "error", err)
113+
}
114+
115+
log.Infow("DB analyze pass complete", "task_id", taskID, "tables", len(rows), "analyzed", analyzed)
116+
return true, nil
117+
}
118+
119+
func shouldAnalyze(churn int64, churnAtAnalyze *int64) bool {
120+
if churnAtAnalyze == nil {
121+
return true
122+
}
123+
prev := *churnAtAnalyze
124+
if churn < prev {
125+
// Stats counters were reset; re-baseline.
126+
return true
127+
}
128+
delta := churn - prev
129+
if delta < minChurnDelta {
130+
return false
131+
}
132+
return float64(churn) >= float64(prev)*(1+analyzeGrowthThreshold)
133+
}
134+
135+
func (d *DBAnalyzeTask) skipForRollingUpgrade(ctx context.Context) (bool, error) {
136+
var peers []struct {
137+
Version string `db:"version"`
138+
StartupTime time.Time `db:"startup_time"`
139+
MachineID int64 `db:"machine_id"`
140+
}
141+
err := d.db.Select(ctx, &peers, `
142+
SELECT machine_id, version, startup_time
143+
FROM harmony_machine_details
144+
WHERE version IS NOT NULL
145+
AND startup_time > NOW() - $1::interval`, upgradeQuietWindow.String())
146+
if err != nil {
147+
return false, err
148+
}
149+
150+
myLabel := curiobuild.ClusterMachineVersionLabel()
151+
mine := analyzeSemver(myLabel)
152+
for _, p := range peers {
153+
if semver.Compare(analyzeSemver(p.Version), mine) > 0 {
154+
log.Infow("skipping DB analyze pass, newer machine booted recently",
155+
"machine_id", p.MachineID,
156+
"their_version", p.Version,
157+
"my_version", myLabel,
158+
"startup_time", p.StartupTime)
159+
return true, nil
160+
}
161+
}
162+
return false, nil
163+
}
164+
165+
// analyzeSemver turns a harmony_machine_details.version label such as
166+
// "1.28.3 abcdef1" or "1.28.3-rc1" into a semver.Compare-able string.
167+
func analyzeSemver(label string) string {
168+
v, _, _ := strings.Cut(label, " ") // drop the git hash suffix
169+
if !strings.HasPrefix(v, "v") {
170+
v = "v" + v
171+
}
172+
if !semver.IsValid(v) {
173+
return ""
174+
}
175+
return v
176+
}
177+
178+
func (d *DBAnalyzeTask) CanAccept(ids []harmonytask.TaskID, engine *harmonytask.TaskEngine) ([]harmonytask.TaskID, error) {
179+
return ids, nil
180+
}
181+
182+
func (d *DBAnalyzeTask) TypeDetails() harmonytask.TaskTypeDetails {
183+
return harmonytask.TaskTypeDetails{
184+
Max: taskhelp.Max(1),
185+
Name: tasknames.DBAnalyze,
186+
Cost: resources.Resources{
187+
Cpu: 0,
188+
Gpu: 0,
189+
Ram: 64 << 20,
190+
},
191+
MaxFailures: 3,
192+
IAmBored: harmonytask.SingletonTaskAdder(analyzeInterval, d),
193+
}
194+
}
195+
196+
func (d *DBAnalyzeTask) Adder(taskFunc harmonytask.AddTaskFunc) {}
197+
198+
var _ = harmonytask.Reg(&DBAnalyzeTask{})
199+
var _ harmonytask.TaskInterface = &DBAnalyzeTask{}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package dbmaint
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
9+
"github.com/filecoin-project/curio/harmony/harmonydb"
10+
)
11+
12+
func openITestDB(t *testing.T) *harmonydb.DB {
13+
t.Helper()
14+
db, err := harmonydb.NewFromConfigWithITestID(t)
15+
if err == nil {
16+
return db
17+
}
18+
// Local Yugabyte often listens on 5433 instead of the default template Postgres:5432.
19+
if strings.Contains(err.Error(), "connection refused") {
20+
db, err = harmonydb.NewFromConfigWithITestID(t, harmonydb.YugabyteDB(true))
21+
}
22+
require.NoError(t, err)
23+
return db
24+
}
25+
26+
func TestDoAnalyzesTables(t *testing.T) {
27+
ctx := t.Context()
28+
db := openITestDB(t)
29+
30+
// Fresh itest schema: no harmony_machine_details peers => upgrade guard is a no-op.
31+
// Empty table_analyze_state => shouldAnalyze returns true for every table (first sight).
32+
task := NewDBAnalyzeTask(db)
33+
done, err := task.Do(ctx, 1, func() bool { return true })
34+
require.NoError(t, err)
35+
require.True(t, done)
36+
37+
var n int
38+
require.NoError(t, db.QueryRow(ctx, `SELECT COUNT(*) FROM table_analyze_state`).Scan(&n))
39+
require.Greater(t, n, 0, "expected at least one table to be ANALYZEd and recorded")
40+
41+
var sample string
42+
require.NoError(t, db.QueryRow(ctx, `
43+
SELECT table_name FROM table_analyze_state ORDER BY table_name LIMIT 1`).Scan(&sample))
44+
t.Logf("analyzed %d tables; sample=%s", n, sample)
45+
46+
// Second pass with no churn growth should not bump analyze_count.
47+
var before int64
48+
require.NoError(t, db.QueryRow(ctx, `
49+
SELECT analyze_count FROM table_analyze_state WHERE table_name = $1`, sample).Scan(&before))
50+
51+
done, err = task.Do(ctx, 2, func() bool { return true })
52+
require.NoError(t, err)
53+
require.True(t, done)
54+
55+
var after int64
56+
require.NoError(t, db.QueryRow(ctx, `
57+
SELECT analyze_count FROM table_analyze_state WHERE table_name = $1`, sample).Scan(&after))
58+
require.Equal(t, before, after, "second pass should skip tables without 10%% churn growth")
59+
}
60+
61+
func TestDoSkipsWhenNewerMachineBooted(t *testing.T) {
62+
ctx := t.Context()
63+
db := openITestDB(t)
64+
65+
var machineID int64
66+
require.NoError(t, db.QueryRow(ctx, `
67+
INSERT INTO harmony_machines (host_and_port, cpu, ram, gpu)
68+
VALUES ('dbanalyze-upgrade-test', 1, 1, 0)
69+
RETURNING id`).Scan(&machineID))
70+
71+
// Seed a peer that looks like a freshly booted higher version.
72+
_, err := db.Exec(ctx, `
73+
INSERT INTO harmony_machine_details (machine_id, version, startup_time)
74+
VALUES ($1, '999.0.0', NOW())`, machineID)
75+
require.NoError(t, err)
76+
77+
task := NewDBAnalyzeTask(db)
78+
done, err := task.Do(ctx, 1, func() bool { return true })
79+
require.NoError(t, err)
80+
require.True(t, done)
81+
82+
var n int
83+
require.NoError(t, db.QueryRow(ctx, `SELECT COUNT(*) FROM table_analyze_state`).Scan(&n))
84+
require.Equal(t, 0, n, "upgrade quiet window should skip ANALYZE entirely")
85+
}

0 commit comments

Comments
 (0)