diff --git a/expression/aggregation/explain.go b/expression/aggregation/explain.go index b001a21c23d1e..e32ce6e337be5 100644 --- a/expression/aggregation/explain.go +++ b/expression/aggregation/explain.go @@ -21,7 +21,7 @@ import ( ) // ExplainAggFunc generates explain information for a aggregation function. -func ExplainAggFunc(agg *AggFuncDesc) string { +func ExplainAggFunc(agg *AggFuncDesc, normalized bool) string { var buffer bytes.Buffer fmt.Fprintf(&buffer, "%s(", agg.Name) if agg.HasDistinct { @@ -32,11 +32,19 @@ func ExplainAggFunc(agg *AggFuncDesc) string { if len(agg.OrderByItems) > 0 { buffer.WriteString(" order by ") for i, item := range agg.OrderByItems { - order := "asc" if item.Desc { - order = "desc" + if normalized { + fmt.Fprintf(&buffer, "%s desc", item.Expr.ExplainNormalizedInfo()) + } else { + fmt.Fprintf(&buffer, "%s desc", item.Expr.ExplainInfo()) + } + } else { + if normalized { + fmt.Fprintf(&buffer, "%s asc", item.Expr.ExplainNormalizedInfo()) + } else { + fmt.Fprintf(&buffer, "%s asc", item.Expr.ExplainInfo()) + } } - fmt.Fprintf(&buffer, "%s %s", item.Expr.ExplainInfo(), order) if i+1 < len(agg.OrderByItems) { buffer.WriteString(", ") } @@ -46,7 +54,11 @@ func ExplainAggFunc(agg *AggFuncDesc) string { } else if i != 0 { buffer.WriteString(", ") } - buffer.WriteString(arg.ExplainInfo()) + if normalized { + buffer.WriteString(arg.ExplainNormalizedInfo()) + } else { + buffer.WriteString(arg.ExplainInfo()) + } } buffer.WriteString(")") return buffer.String() diff --git a/expression/explain.go b/expression/explain.go index 726a25692d34d..1bfe29827aacd 100644 --- a/expression/explain.go +++ b/expression/explain.go @@ -57,7 +57,10 @@ func (col *Column) ExplainInfo() string { // ExplainNormalizedInfo implements the Expression interface. func (col *Column) ExplainNormalizedInfo() string { - return col.ExplainInfo() + if col.OrigName != "" { + return col.OrigName + } + return "?" } // ExplainInfo implements the Expression interface. diff --git a/planner/core/encode.go b/planner/core/encode.go index b98a02868cc66..9d96c786e84f0 100644 --- a/planner/core/encode.go +++ b/planner/core/encode.go @@ -137,7 +137,7 @@ func (d *planDigester) normalizePlanTree(p PhysicalPlan) { } func (d *planDigester) normalizePlan(p PhysicalPlan, isRoot bool, depth int) { - plancodec.NormalizePlanNode(depth, p.ID(), p.TP(), isRoot, p.ExplainNormalizedInfo(), &d.buf) + plancodec.NormalizePlanNode(depth, p.TP(), isRoot, p.ExplainNormalizedInfo(), &d.buf) d.encodedPlans[p.ID()] = true depth++ diff --git a/planner/core/explain.go b/planner/core/explain.go index e0f74ee827a45..7b1686904626f 100644 --- a/planner/core/explain.go +++ b/planner/core/explain.go @@ -95,7 +95,9 @@ func (p *PhysicalIndexScan) AccessObject() string { func (p *PhysicalIndexScan) OperatorInfo(normalized bool) string { buffer := bytes.NewBufferString("") if len(p.rangeInfo) > 0 { - fmt.Fprintf(buffer, "range: decided by %v, ", p.rangeInfo) + if !normalized { + fmt.Fprintf(buffer, "range: decided by %v, ", p.rangeInfo) + } } else if p.haveCorCol() { if normalized { fmt.Fprintf(buffer, "range: decided by %s, ", expression.SortedExplainNormalizedExpressionList(p.AccessCondition)) @@ -249,7 +251,7 @@ func (p *PhysicalTableReader) ExplainInfo() string { // ExplainNormalizedInfo implements Plan interface. func (p *PhysicalTableReader) ExplainNormalizedInfo() string { - return p.ExplainInfo() + return "" } // ExplainInfo implements Plan interface. @@ -335,7 +337,13 @@ func (p *basePhysicalAgg) explainInfo(normalized bool) string { } for i := 0; i < len(p.AggFuncs); i++ { builder.WriteString("funcs:") - fmt.Fprintf(builder, "%v->%v", aggregation.ExplainAggFunc(p.AggFuncs[i]), p.schema.Columns[i]) + var colName string + if normalized { + colName = p.schema.Columns[i].ExplainNormalizedInfo() + } else { + colName = p.schema.Columns[i].ExplainInfo() + } + fmt.Fprintf(builder, "%v->%v", aggregation.ExplainAggFunc(p.AggFuncs[i], normalized), colName) if i+1 < len(p.AggFuncs) { builder.WriteString(", ") } @@ -360,7 +368,11 @@ func (p *PhysicalIndexJoin) explainInfo(normalized bool) string { } buffer := bytes.NewBufferString(p.JoinType.String()) - fmt.Fprintf(buffer, ", inner:%s", p.Children()[p.InnerChildIdx].ExplainID()) + if normalized { + fmt.Fprintf(buffer, ", inner:%s", p.Children()[p.InnerChildIdx].TP()) + } else { + fmt.Fprintf(buffer, ", inner:%s", p.Children()[p.InnerChildIdx].ExplainID()) + } if len(p.OuterJoinKeys) > 0 { fmt.Fprintf(buffer, ", outer key:%s", expression.ExplainColumnList(p.OuterJoinKeys)) @@ -623,7 +635,7 @@ func (p *LogicalAggregation) ExplainInfo() string { if len(p.AggFuncs) > 0 { buffer.WriteString("funcs:") for i, agg := range p.AggFuncs { - buffer.WriteString(aggregation.ExplainAggFunc(agg)) + buffer.WriteString(aggregation.ExplainAggFunc(agg, false)) if i+1 < len(p.AggFuncs) { buffer.WriteString(", ") } diff --git a/planner/core/plan_test.go b/planner/core/plan_test.go index 76785d60923bd..61d1c1c35101b 100644 --- a/planner/core/plan_test.go +++ b/planner/core/plan_test.go @@ -114,9 +114,56 @@ func (s *testPlanNormalize) TestEncodeDecodePlan(c *C) { func (s *testPlanNormalize) TestNormalizedDigest(c *C) { tk := testkit.NewTestKit(c, s.store) tk.MustExec("use test") - tk.MustExec("drop table if exists t1,t2") + tk.MustExec("drop table if exists t1,t2, bmsql_order_line, bmsql_district,bmsql_stock") tk.MustExec("create table t1 (a int key,b int,c int, index (b));") tk.MustExec("create table t2 (a int key,b int,c int, index (b));") + tk.MustExec(`CREATE TABLE bmsql_order_line ( + ol_w_id int(11) NOT NULL, + ol_d_id int(11) NOT NULL, + ol_o_id int(11) NOT NULL, + ol_number int(11) NOT NULL, + ol_i_id int(11) NOT NULL, + ol_delivery_d timestamp NULL DEFAULT NULL, + ol_amount decimal(6,2) DEFAULT NULL, + ol_supply_w_id int(11) DEFAULT NULL, + ol_quantity int(11) DEFAULT NULL, + ol_dist_info char(24) DEFAULT NULL, + PRIMARY KEY ( ol_w_id , ol_d_id , ol_o_id , ol_number ) + );`) + tk.MustExec(`CREATE TABLE bmsql_district ( + d_w_id int(11) NOT NULL, + d_id int(11) NOT NULL, + d_ytd decimal(12,2) DEFAULT NULL, + d_tax decimal(4,4) DEFAULT NULL, + d_next_o_id int(11) DEFAULT NULL, + d_name varchar(10) DEFAULT NULL, + d_street_1 varchar(20) DEFAULT NULL, + d_street_2 varchar(20) DEFAULT NULL, + d_city varchar(20) DEFAULT NULL, + d_state char(2) DEFAULT NULL, + d_zip char(9) DEFAULT NULL, + PRIMARY KEY ( d_w_id , d_id ) + );`) + tk.MustExec(`CREATE TABLE bmsql_stock ( + s_w_id int(11) NOT NULL, + s_i_id int(11) NOT NULL, + s_quantity int(11) DEFAULT NULL, + s_ytd int(11) DEFAULT NULL, + s_order_cnt int(11) DEFAULT NULL, + s_remote_cnt int(11) DEFAULT NULL, + s_data varchar(50) DEFAULT NULL, + s_dist_01 char(24) DEFAULT NULL, + s_dist_02 char(24) DEFAULT NULL, + s_dist_03 char(24) DEFAULT NULL, + s_dist_04 char(24) DEFAULT NULL, + s_dist_05 char(24) DEFAULT NULL, + s_dist_06 char(24) DEFAULT NULL, + s_dist_07 char(24) DEFAULT NULL, + s_dist_08 char(24) DEFAULT NULL, + s_dist_09 char(24) DEFAULT NULL, + s_dist_10 char(24) DEFAULT NULL, + PRIMARY KEY ( s_w_id , s_i_id ) + );`) normalizedDigestCases := []struct { sql1 string sql2 string @@ -197,6 +244,27 @@ func (s *testPlanNormalize) TestNormalizedDigest(c *C) { sql2: "select count(1) as num,a from t1 where a=2 group by a union select count(1) as num,a from t1 where a=4 group by a;", isSame: true, }, + { + sql1: `SELECT COUNT(*) AS low_stock + FROM + ( + SELECT * + FROM bmsql_stock + WHERE s_w_id = 1 + AND s_quantity < 2 + AND s_i_id IN ( SELECT /*+ TIDB_INLJ(bmsql_order_line) */ ol_i_id FROM bmsql_district JOIN bmsql_order_line ON ol_w_id = d_w_id AND ol_d_id = d_id AND ol_o_id >= d_next_o_id - 20 AND ol_o_id < d_next_o_id WHERE d_w_id = 1 AND d_id = 2 ) + ) AS L;`, + sql2: `SELECT COUNT(*) AS low_stock + FROM + ( + SELECT * + FROM bmsql_stock + WHERE s_w_id = 5 + AND s_quantity < 6 + AND s_i_id IN ( SELECT /*+ TIDB_INLJ(bmsql_order_line) */ ol_i_id FROM bmsql_district JOIN bmsql_order_line ON ol_w_id = d_w_id AND ol_d_id = d_id AND ol_o_id >= d_next_o_id - 70 AND ol_o_id < d_next_o_id WHERE d_w_id = 5 AND d_id = 6 ) + ) AS L;`, + isSame: true, + }, } for _, testCase := range normalizedDigestCases { testNormalizeDigest(tk, c, testCase.sql1, testCase.sql2, testCase.isSame) diff --git a/planner/core/testdata/plan_normalized_suite_out.json b/planner/core/testdata/plan_normalized_suite_out.json index 7f7a014b9493b..2dbd9851a1c91 100644 --- a/planner/core/testdata/plan_normalized_suite_out.json +++ b/planner/core/testdata/plan_normalized_suite_out.json @@ -5,141 +5,141 @@ { "SQL": "select * from t1;", "Plan": [ - " TableReader_5 root data:TableFullScan_4", - " └─TableScan_4 cop table:t1, range:[?,?], keep order:false" + " TableReader root ", + " └─TableScan cop table:t1, range:[?,?], keep order:false" ] }, { "SQL": "select * from t1 where a<1;", "Plan": [ - " TableReader_6 root data:TableRangeScan_5", - " └─TableScan_5 cop table:t1, range:[?,?], keep order:false" + " TableReader root ", + " └─TableScan cop table:t1, range:[?,?], keep order:false" ] }, { "SQL": "select * from t1 where a>1", "Plan": [ - " TableReader_6 root data:TableRangeScan_5", - " └─TableScan_5 cop table:t1, range:[?,?], keep order:false" + " TableReader root ", + " └─TableScan cop table:t1, range:[?,?], keep order:false" ] }, { "SQL": "select * from t1 where a=1", "Plan": [ - " Point_Get_1 root table:t1, handle:?" + " Point_Get root table:t1, handle:?" ] }, { "SQL": "select * from t1 where a in (1,2,3)", "Plan": [ - " Batch_Point_Get_1 root table:t1, handle:?, keep order:false, desc:false" + " Batch_Point_Get root table:t1, handle:?, keep order:false, desc:false" ] }, { "SQL": "select * from t1 where b=1", "Plan": [ - " IndexLookUp_10 root ", - " ├─IndexScan_8 cop table:t1, index:b(b), range:[?,?], keep order:false", - " └─TableScan_9 cop table:t1, keep order:false" + " IndexLookUp root ", + " ├─IndexScan cop table:t1, index:b(b), range:[?,?], keep order:false", + " └─TableScan cop table:t1, keep order:false" ] }, { "SQL": "select a+1,b+2 from t1 use index(b) where b=3", "Plan": [ - " Projection_4 root plus(test.t1.a, ?), plus(test.t1.b, ?)", - " └─IndexReader_6 root index:IndexRangeScan_5", - " └─IndexScan_5 cop table:t1, index:b(b), range:[?,?], keep order:false" + " Projection root plus(test.t1.a, ?), plus(test.t1.b, ?)", + " └─IndexReader root index:IndexRangeScan_5", + " └─IndexScan cop table:t1, index:b(b), range:[?,?], keep order:false" ] }, { "SQL": "select * from t1 where t1.b > 1 and t1.a in (select sum(t2.b) from t2 where t2.a=t1.a and t2.b is not null)", "Plan": [ - " Projection_10 root test.t1.a, test.t1.b, test.t1.c", - " └─Apply_12 root semi join, equal:eq(Column#8, Column#7)", - " ├─Projection_13 root cast(test.t1.a), test.t1.a, test.t1.b, test.t1.c", - " │ └─TableReader_16 root data:Selection_15", - " │ └─Selection_15 cop gt(test.t1.b, ?)", - " │ └─TableScan_14 cop table:t1, range:[?,?], keep order:false", - " └─StreamAgg_34 root funcs:sum(Column#11)->Column#7", - " └─TableReader_35 root data:StreamAgg_23", - " └─StreamAgg_23 cop funcs:sum(test.t2.b)->Column#11", - " └─Selection_33 cop not(isnull(test.t2.b))", - " └─TableScan_32 cop table:t2, range: decided by eq(test.t2.a, test.t1.a), keep order:false" + " Projection root test.t1.a, test.t1.b, test.t1.c", + " └─Apply root semi join, equal:eq(?, ?)", + " ├─Projection root cast(test.t1.a), test.t1.a, test.t1.b, test.t1.c", + " │ └─TableReader root ", + " │ └─Selection cop gt(test.t1.b, ?)", + " │ └─TableScan cop table:t1, range:[?,?], keep order:false", + " └─StreamAgg root funcs:sum(?)->?", + " └─TableReader root ", + " └─StreamAgg cop funcs:sum(test.t2.b)->?", + " └─Selection cop not(isnull(test.t2.b))", + " └─TableScan cop table:t2, range: decided by eq(test.t2.a, test.t1.a), keep order:false" ] }, { "SQL": "SELECT * from t1 where a!=1 order by c limit 1", "Plan": [ - " TopN_8 root test.t1.c:asc", - " └─TableReader_16 root data:TopN_15", - " └─TopN_15 cop test.t1.c:asc", - " └─TableScan_14 cop table:t1, range:[?,?], keep order:false" + " TopN root test.t1.c:asc", + " └─TableReader root ", + " └─TopN cop test.t1.c:asc", + " └─TableScan cop table:t1, range:[?,?], keep order:false" ] }, { "SQL": "SELECT /*+ TIDB_SMJ(t1, t2) */ * from t1, t2 where t1.a = t2.a and t1.c>1;", "Plan": [ - " MergeJoin_7 root inner join, left key:test.t1.a, right key:test.t2.a", - " ├─TableReader_11 root data:Selection_10", - " │ └─Selection_10 cop gt(test.t1.c, ?)", - " │ └─TableScan_9 cop table:t1, range:[?,?], keep order:true", - " └─TableReader_13 root data:TableFullScan_12", - " └─TableScan_12 cop table:t2, range:[?,?], keep order:true" + " MergeJoin root inner join, left key:test.t1.a, right key:test.t2.a", + " ├─TableReader root ", + " │ └─Selection cop gt(test.t1.c, ?)", + " │ └─TableScan cop table:t1, range:[?,?], keep order:true", + " └─TableReader root ", + " └─TableScan cop table:t2, range:[?,?], keep order:true" ] }, { "SQL": "SELECT /*+ TIDB_INLJ(t1, t2) */ * from t1, t2 where t1.a = t2.a and t1.c>1;", "Plan": [ - " IndexJoin_10 root inner join, inner:TableReader_9, outer key:test.t1.a, inner key:test.t2.a", - " ├─TableReader_30 root data:Selection_29", - " │ └─Selection_29 cop gt(test.t1.c, ?)", - " │ └─TableScan_28 cop table:t1, range:[?,?], keep order:false", - " └─TableReader_9 root data:TableRangeScan_8", - " └─TableScan_8 cop table:t2, range: decided by [test.t1.a], keep order:false" + " IndexJoin root inner join, inner:TableReader, outer key:test.t1.a, inner key:test.t2.a", + " ├─TableReader root ", + " │ └─Selection cop gt(test.t1.c, ?)", + " │ └─TableScan cop table:t1, range:[?,?], keep order:false", + " └─TableReader root ", + " └─TableScan cop table:t2, range: decided by [test.t1.a], keep order:false" ] }, { "SQL": "SELECT /*+ TIDB_HJ(t1, t2) */ * from t1, t2 where t1.a = t2.a and t1.c>1;", "Plan": [ - " HashJoin_29 root inner join, equal:eq(test.t1.a, test.t2.a)", - " ├─TableReader_32 root data:Selection_31", - " │ └─Selection_31 cop gt(test.t1.c, ?)", - " │ └─TableScan_30 cop table:t1, range:[?,?], keep order:false", - " └─TableReader_34 root data:TableFullScan_33", - " └─TableScan_33 cop table:t2, range:[?,?], keep order:false" + " HashJoin root inner join, equal:eq(test.t1.a, test.t2.a)", + " ├─TableReader root ", + " │ └─Selection cop gt(test.t1.c, ?)", + " │ └─TableScan cop table:t1, range:[?,?], keep order:false", + " └─TableReader root ", + " └─TableScan cop table:t2, range:[?,?], keep order:false" ] }, { "SQL": "SELECT /*+ TIDB_HJ(t1, t2) */ * from t1, t2 where t1.a = t2.a and t1.c>1;", "Plan": [ - " HashJoin_29 root inner join, equal:eq(test.t1.a, test.t2.a)", - " ├─TableReader_32 root data:Selection_31", - " │ └─Selection_31 cop gt(test.t1.c, ?)", - " │ └─TableScan_30 cop table:t1, range:[?,?], keep order:false", - " └─TableReader_34 root data:TableFullScan_33", - " └─TableScan_33 cop table:t2, range:[?,?], keep order:false" + " HashJoin root inner join, equal:eq(test.t1.a, test.t2.a)", + " ├─TableReader root ", + " │ └─Selection cop gt(test.t1.c, ?)", + " │ └─TableScan cop table:t1, range:[?,?], keep order:false", + " └─TableReader root ", + " └─TableScan cop table:t2, range:[?,?], keep order:false" ] }, { "SQL": "SELECT /*+ TIDB_INLJ(t1, t2) */ * from t1, t2 where t1.a = t2.a and t1.c>1;", "Plan": [ - " IndexJoin_10 root inner join, inner:TableReader_9, outer key:test.t1.a, inner key:test.t2.a", - " ├─TableReader_30 root data:Selection_29", - " │ └─Selection_29 cop gt(test.t1.c, ?)", - " │ └─TableScan_28 cop table:t1, range:[?,?], keep order:false", - " └─TableReader_9 root data:TableRangeScan_8", - " └─TableScan_8 cop table:t2, range: decided by [test.t1.a], keep order:false" + " IndexJoin root inner join, inner:TableReader, outer key:test.t1.a, inner key:test.t2.a", + " ├─TableReader root ", + " │ └─Selection cop gt(test.t1.c, ?)", + " │ └─TableScan cop table:t1, range:[?,?], keep order:false", + " └─TableReader root ", + " └─TableScan cop table:t2, range: decided by [test.t1.a], keep order:false" ] }, { "SQL": "select count(1) as num,a from t1 where a=1 group by a union select count(1) as num,a from t1 where a=3 group by a;", "Plan": [ - " HashAgg_17 root group by:Column#10, Column#9, funcs:firstrow(Column#9)->Column#9, funcs:firstrow(Column#10)->Column#10", - " └─Union_18 root ", - " ├─Projection_19 root ?, test.t1.a", - " │ └─Point_Get_20 root table:t1, handle:?", - " └─Projection_21 root ?, test.t1.a", - " └─Point_Get_22 root table:t1, handle:?" + " HashAgg root group by:?, ?, funcs:firstrow(?)->?, funcs:firstrow(?)->?", + " └─Union root ", + " ├─Projection root ?, test.t1.a", + " │ └─Point_Get root table:t1, handle:?", + " └─Projection root ?, test.t1.a", + " └─Point_Get root table:t1, handle:?" ] }, { @@ -151,39 +151,39 @@ { "SQL": "insert into t1 select * from t2 where t2.a>0 and t2.b!=0", "Plan": [ - " TableReader_9 root data:Selection_8", - " └─Selection_8 cop ne(test.t2.b, ?)", - " └─TableScan_7 cop table:t2, range:[?,?], keep order:false" + " TableReader root ", + " └─Selection cop ne(test.t2.b, ?)", + " └─TableScan cop table:t2, range:[?,?], keep order:false" ] }, { "SQL": "update t1 set a=a+1", "Plan": [ - " TableReader_6 root data:TableFullScan_5", - " └─TableScan_5 cop table:t1, range:[?,?], keep order:false" + " TableReader root ", + " └─TableScan cop table:t1, range:[?,?], keep order:false" ] }, { "SQL": "update t1 set a=a+1 where a>0", "Plan": [ - " TableReader_7 root data:TableRangeScan_6", - " └─TableScan_6 cop table:t1, range:[?,?], keep order:false" + " TableReader root ", + " └─TableScan cop table:t1, range:[?,?], keep order:false" ] }, { "SQL": "delete from t1", "Plan": [ - " TableReader_6 root data:TableFullScan_5", - " └─TableScan_5 cop table:t1, range:[?,?], keep order:false" + " TableReader root ", + " └─TableScan cop table:t1, range:[?,?], keep order:false" ] }, { "SQL": "delete from t1 where a>0 and b=1 and c!=2", "Plan": [ - " IndexLookUp_12 root ", - " ├─IndexScan_9 cop table:t1, index:b(b), range:[?,?], keep order:false", - " └─Selection_11 cop ne(test.t1.c, ?)", - " └─TableScan_10 cop table:t1, keep order:false" + " IndexLookUp root ", + " ├─IndexScan cop table:t1, index:b(b), range:[?,?], keep order:false", + " └─Selection cop ne(test.t1.c, ?)", + " └─TableScan cop table:t1, keep order:false" ] }, { diff --git a/util/plancodec/codec.go b/util/plancodec/codec.go index 60966aedea4d5..0b99002cd9452 100644 --- a/util/plancodec/codec.go +++ b/util/plancodec/codec.go @@ -285,14 +285,18 @@ func decodePlanInfo(str string) (*planInfo, error) { // plan ID case 1: ids := strings.Split(v, idSeparator) - if len(ids) != 2 { + if len(ids) != 1 && len(ids) != 2 { return nil, errors.Errorf("decode plan: %v error, invalid plan id: %v", str, v) } planID, err := strconv.Atoi(ids[0]) if err != nil { return nil, errors.Errorf("decode plan: %v, plan id: %v, error: %v", str, v, err) } - p.fields = append(p.fields, PhysicalIDToTypeString(planID)+idSeparator+ids[1]) + if len(ids) == 1 { + p.fields = append(p.fields, PhysicalIDToTypeString(planID)) + } else { + p.fields = append(p.fields, PhysicalIDToTypeString(planID)+idSeparator+ids[1]) + } // task type case 2: task, err := decodeTaskType(v) @@ -334,10 +338,11 @@ func EncodePlanNode(depth, pid int, planType string, rowCount float64, } // NormalizePlanNode is used to normalize the plan to a string. -func NormalizePlanNode(depth, pid int, planType string, isRoot bool, explainInfo string, buf *bytes.Buffer) { +func NormalizePlanNode(depth int, planType string, isRoot bool, explainInfo string, buf *bytes.Buffer) { buf.WriteString(strconv.Itoa(depth)) buf.WriteByte(separator) - buf.WriteString(encodeID(planType, pid)) + planID := TypeStringToPhysicalID(planType) + buf.WriteString(strconv.Itoa(planID)) buf.WriteByte(separator) if isRoot { buf.WriteString(rootTaskType)