Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
W
wendelin.core
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Labels
Merge Requests
0
Merge Requests
0
Analytics
Analytics
Repository
Value Stream
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Commits
Open sidebar
Kirill Smelkov
wendelin.core
Commits
33e0dfce
Commit
33e0dfce
authored
Nov 27, 2018
by
Kirill Smelkov
4
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
X ΔTail draftly done
parent
6e679a97
Changes
5
Hide whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
146 additions
and
52 deletions
+146
-52
wcfs/todo.dot
wcfs/todo.dot
+1
-0
wcfs/todo.svg
wcfs/todo.svg
+1
-1
wcfs/wcfs.go
wcfs/wcfs.go
+5
-7
wcfs/δtail.go
wcfs/δtail.go
+71
-27
wcfs/δtail_test.go
wcfs/δtail_test.go
+68
-17
No files found.
wcfs/todo.dot
View file @
33e0dfce
...
@@ -77,6 +77,7 @@ digraph {
...
@@ -77,6 +77,7 @@ digraph {
wcfsRead
[
label
=
"read(#blk)"
]
wcfsRead
[
label
=
"read(#blk)"
]
blktabGet
[
label
=
"blktab.Get(#blk):\nmanually + → ⌈rev(#blk)⌉"
]
blktabGet
[
label
=
"blktab.Get(#blk):\nmanually + → ⌈rev(#blk)⌉"
]
δ
Ftail
[
style
=
filled
fillcolor
=
grey95
]
mappingRegister
[
label
=
"mmappings:\nregister/maint"
]
mappingRegister
[
label
=
"mmappings:\nregister/maint"
]
clientInvHandle
[
label
=
"process\n#blk invalidations"
]
clientInvHandle
[
label
=
"process\n#blk invalidations"
]
...
...
wcfs/todo.svg
View file @
33e0dfce
...
@@ -162,7 +162,7 @@
...
@@ -162,7 +162,7 @@
<!-- δFtail -->
<!-- δFtail -->
<g
id=
"node10"
class=
"node"
>
<g
id=
"node10"
class=
"node"
>
<title>
δFtail
</title>
<title>
δFtail
</title>
<ellipse
fill=
"
none
"
stroke=
"#000000"
cx=
"797.8112"
cy=
"-188.6102"
rx=
"31.6951"
ry=
"18"
/>
<ellipse
fill=
"
#f2f2f2
"
stroke=
"#000000"
cx=
"797.8112"
cy=
"-188.6102"
rx=
"31.6951"
ry=
"18"
/>
<text
text-anchor=
"middle"
x=
"797.8112"
y=
"-184.9102"
font-family=
"Times,serif"
font-size=
"14.00"
fill=
"#000000"
>
δFtail
</text>
<text
text-anchor=
"middle"
x=
"797.8112"
y=
"-184.9102"
font-family=
"Times,serif"
font-size=
"14.00"
fill=
"#000000"
>
δFtail
</text>
</g>
</g>
<!-- wcfsInvProcess->δFtail -->
<!-- wcfsInvProcess->δFtail -->
...
...
wcfs/wcfs.go
View file @
33e0dfce
...
@@ -268,7 +268,7 @@ package main
...
@@ -268,7 +268,7 @@ package main
// - if retrieved successfully -> store retrieved data back into OS file
// - if retrieved successfully -> store retrieved data back into OS file
// cache for file/@<rev>/data[blk], where
// cache for file/@<rev>/data[blk], where
//
//
// rev = max(δFtail.by(#blk)) || zconn.at ; see below about δFtail
// rev = max(δFtail.by(#blk)) ||
min(rev ∈ δFtail) ||
zconn.at ; see below about δFtail
//
//
// - invalidate file/head/data[blk] in OS file cache.
// - invalidate file/head/data[blk] in OS file cache.
//
//
...
@@ -279,16 +279,14 @@ package main
...
@@ -279,16 +279,14 @@ package main
//
//
// 5) for every file δFtail invalidation info about head/data is maintained:
// 5) for every file δFtail invalidation info about head/data is maintained:
//
//
// - tail
: of
[](rev↑, []#blk)
// - tail
v:
[](rev↑, []#blk)
// - by: {} #blk -> []rev↑ in tail
// - by:
{} #blk -> []rev↑ in tail
//
//
// δFtail.tail describes invalidations to file we learned from ZODB invalidation.
// δFtail.tail describes invalidations to file we learned from ZODB invalidation.
// δFtail.by allows to quickly lookup information by #blk.
// δFtail.by allows to quickly lookup information by #blk.
//
//
// min(rev) in δFtail is min(@at) at which head/data is currently mmapped (see below).
// min(rev) in δFtail is min(@at) at which head/data is currently mmapped (see below).
//
//
// TODO invariant -> δFtail - which entries are present?
//
// 6) when we receive a FUSE read(#blk) request to a file/head/data we process it as follows:
// 6) when we receive a FUSE read(#blk) request to a file/head/data we process it as follows:
//
//
// 6.1) load blkdata for head/data[blk] @zconn.at .
// 6.1) load blkdata for head/data[blk] @zconn.at .
...
@@ -307,7 +305,7 @@ package main
...
@@ -307,7 +305,7 @@ package main
//
//
// or another upper bound if #blk ∉ δFtail:
// or another upper bound if #blk ∉ δFtail:
//
//
// rev(blk) ≤ min(rev
) in δFtail
; #blk ∉ δFtail
// rev(blk) ≤ min(rev
∈ δFtail)
; #blk ∉ δFtail
//
//
//
//
// below rev'(blk) is min(of the estimates found):
// below rev'(blk) is min(of the estimates found):
...
@@ -320,7 +318,7 @@ package main
...
@@ -320,7 +318,7 @@ package main
// - rev'(blk) ≤ at: -> do nothing XXX || blk ∉ mapping
// - rev'(blk) ≤ at: -> do nothing XXX || blk ∉ mapping
// - rev'(blk) > at:
// - rev'(blk) > at:
// - if blk ∈ mmapping.pinned -> do nothing
// - if blk ∈ mmapping.pinned -> do nothing
// - rev = max(δFtail.by(#blk) : _ ≤ at) || at
// - rev = max(δFtail.by(#blk) : _ ≤ at) ||
min(rev ∈ δFtail : rev ≤ at) ||
at
// - client.remmap(addr[blk], file/@rev/data)
// - client.remmap(addr[blk], file/@rev/data)
// - mmapping.pinned += blk
// - mmapping.pinned += blk
//
//
...
...
wcfs/δtail.go
View file @
33e0dfce
...
@@ -20,19 +20,21 @@
...
@@ -20,19 +20,21 @@
// XXX -> internal/δtail/ ?
// XXX -> internal/δtail/ ?
package
main
package
main
// δtail maintenance
XXX
// δtail maintenance
import
(
import
(
"fmt"
"lab.nexedi.com/kirr/neo/go/zodb"
"lab.nexedi.com/kirr/neo/go/zodb"
)
)
type
ID
int64
// XXX -> template
type
ID
int64
// XXX -> template
?
// ΔTail represents tail of revisional changes.
// ΔTail represents tail of revisional changes.
//
//
// It semantically consists of
// It semantically consists of
//
//
// []
of
(rev↑, []id)
// [](rev↑, []id)
//
//
// where
// where
//
//
...
@@ -46,24 +48,24 @@ type ID int64 // XXX -> template
...
@@ -46,24 +48,24 @@ type ID int64 // XXX -> template
// - query the tail about what is last revision that changed an id.
// - query the tail about what is last revision that changed an id.
//
//
// It is generally not safe to use ΔTail from multiple goroutines simultaneously.
// It is generally not safe to use ΔTail from multiple goroutines simultaneously.
// It is safe to perform multiple simultaneous
querie
s.
// It is safe to perform multiple simultaneous
read-kind operation
s.
//
//
// (*) examples of id:
// (*) examples of id:
//
//
// oid - ZODB object identifier, when ΔTail represents changes to ZODB objects,
// oid - ZODB object identifier, when ΔTail represents changes to ZODB objects,
// #blk - file block number, when ΔTail represents changes to a file.
// #blk - file block number, when ΔTail represents changes to a file.
type
ΔTail
struct
{
type
ΔTail
struct
{
tailv
[]
δRevEntry
tailv
[]
δRevEntry
lastRevOf
map
[
ID
]
zodb
.
Tid
lastRevOf
map
[
ID
]
zodb
.
Tid
// index for LastRevOf queries
// TODO also add either tailv idx <-> rev index, or lastRevOf -> tailv idx
// TODO also add either tailv idx <-> rev index, or lastRevOf -> tailv idx
// (if linear back-scan of δRevEntry starts eat cpu).
// (if linear back-scan of δRevEntry starts eat cpu).
}
}
// δRevEntry represents information of what have been changed in one revision.
// δRevEntry represents information of what have been changed in one revision.
// XXX naming
type
δRevEntry
struct
{
type
δRevEntry
struct
{
rev
zodb
.
Tid
rev
zodb
.
Tid
δv
[]
ID
changev
[]
ID
}
}
// NewΔTail creates new ΔTail object.
// NewΔTail creates new ΔTail object.
...
@@ -75,10 +77,17 @@ func NewΔTail() *ΔTail {
...
@@ -75,10 +77,17 @@ func NewΔTail() *ΔTail {
// Append appends to δtail information about what have been changed in next revision.
// Append appends to δtail information about what have been changed in next revision.
//
//
// rev must be ↑.
// rev must be ↑.
func
(
δtail
*
ΔTail
)
Append
(
rev
zodb
.
Tid
,
δv
[]
ID
)
{
func
(
δtail
*
ΔTail
)
Append
(
rev
zodb
.
Tid
,
changev
[]
ID
)
{
// XXX check rev↑
// check rev↑
δtail
.
tailv
=
append
(
δtail
.
tailv
,
δRevEntry
{
rev
,
δv
})
// XXX better also check even when δtail is ø (after forget)
for
_
,
id
:=
range
δv
{
if
l
:=
len
(
δtail
.
tailv
);
l
>
0
{
if
revPrev
:=
δtail
.
tailv
[
l
-
1
]
.
rev
;
revPrev
>=
rev
{
panic
(
fmt
.
Sprintf
(
"δtail.Append: rev not ↑: %s -> %s"
,
revPrev
,
rev
))
}
}
δtail
.
tailv
=
append
(
δtail
.
tailv
,
δRevEntry
{
rev
,
changev
})
for
_
,
id
:=
range
changev
{
δtail
.
lastRevOf
[
id
]
=
rev
δtail
.
lastRevOf
[
id
]
=
rev
}
}
}
}
...
@@ -93,51 +102,86 @@ func (δtail *ΔTail) ForgetBefore(revCut zodb.Tid) {
...
@@ -93,51 +102,86 @@ func (δtail *ΔTail) ForgetBefore(revCut zodb.Tid) {
}
}
icut
=
i
+
1
icut
=
i
+
1
// if forgotten
t
revision was last for id, we have to update lastRevOf index
// if forgotten revision was last for id, we have to update lastRevOf index
for
_
,
id
:=
range
δ
.
δ
v
{
for
_
,
id
:=
range
δ
.
change
v
{
if
δtail
.
lastRevOf
[
id
]
==
rev
{
if
δtail
.
lastRevOf
[
id
]
==
rev
{
delete
(
δtail
.
lastRevOf
,
id
)
delete
(
δtail
.
lastRevOf
,
id
)
}
}
}
}
}
}
// tailv = tailv[icut:] but without growing underlying storage array indefinetely
// tailv = tailv[icut:] but without
copy
(
δtail
.
tailv
,
δtail
.
tailv
[
icut
:
])
// 1) growing underlying storage array indefinitely
δtail
.
tailv
=
δtail
.
tailv
[
:
len
(
δtail
.
tailv
)
-
icut
]
// 2) keeping underlying storage after forget
l
:=
len
(
δtail
.
tailv
)
-
icut
tailv
:=
make
([]
δRevEntry
,
l
)
copy
(
tailv
,
δtail
.
tailv
[
icut
:
])
δtail
.
tailv
=
tailv
}
}
// LastRevOf returns what was the last revision that changed id as of at database state.
// LastRevOf tries to return what was the last revision that changed id as of at database state.
//
// Depending on current information in δtail it returns either exact result, or
// an upper-bound estimate for the last id revision, assuming id was changed ≤ at:
//
// 1) if δtail does not cover at, at is returned:
//
//
// in other words
// # at ∉ [min(rev ∈ δtail), max(rev ∈ δtail)]
// LastRevOf(id, at) = at
//
//
// 2) if δtail has an entry corresponding to id change, it gives exactly the
// last revision that changed id:
//
// # at ∈ [min(rev ∈ δtail), max(rev ∈ δtail)]
// # ∃ rev ∈ δtail: rev changed id && rev ≤ at
// LastRevOf(id, at) = max(rev: rev changed id && rev ≤ at)
// LastRevOf(id, at) = max(rev: rev changed id && rev ≤ at)
//
//
// XXX if δtail does not contain records with id -> what is returned?
// 3) if δtail does not contain appropriate record with id - it returns δtail's
func
(
δtail
*
ΔTail
)
LastRevOf
(
id
ID
,
at
zodb
.
Tid
)
zodb
.
Tid
{
// lower bound as the estimate for the upper bound of the last id revision:
//
// # at ∈ [min(rev ∈ δtail), max(rev ∈ δtail)]
// # ∄ rev ∈ δtail: rev changed id && rev ≤ at
// LastRevOf(id, at) = min(rev ∈ δtail)
//
// On return exact indicates whether returned revision is exactly the last
// revision of id, or only an upper-bound estimate of it.
func
(
δtail
*
ΔTail
)
LastRevOf
(
id
ID
,
at
zodb
.
Tid
)
(
_
zodb
.
Tid
,
exact
bool
)
{
// check if we have no coverage at all
l
:=
len
(
δtail
.
tailv
)
if
l
==
0
{
return
at
,
false
}
revMin
:=
δtail
.
tailv
[
0
]
.
rev
revMax
:=
δtail
.
tailv
[
l
-
1
]
.
rev
if
!
(
revMin
<=
at
&&
at
<=
revMax
)
{
return
at
,
false
}
// we have the coverage
rev
,
ok
:=
δtail
.
lastRevOf
[
id
]
rev
,
ok
:=
δtail
.
lastRevOf
[
id
]
if
!
ok
{
if
!
ok
{
panic
(
"TODO"
)
// XXX
return
δtail
.
tailv
[
0
]
.
rev
,
false
}
}
if
rev
<=
at
{
if
rev
<=
at
{
return
rev
return
rev
,
true
}
}
// what's in index is after at - scan tailv back to find appropriate entry
// what's in index is after at - scan tailv back to find appropriate entry
// XXX linear scan
// XXX linear scan
for
i
:=
l
en
(
δtail
.
tailv
)
-
1
;
i
>=
0
;
i
--
{
for
i
:=
l
-
1
;
i
>=
0
;
i
--
{
δ
:=
δtail
.
tailv
[
i
]
δ
:=
δtail
.
tailv
[
i
]
if
δ
.
rev
>
at
{
if
δ
.
rev
>
at
{
continue
continue
}
}
for
_
,
δid
:=
range
δ
.
δ
v
{
for
_
,
δid
:=
range
δ
.
change
v
{
if
id
==
δid
{
if
id
==
δid
{
return
δ
.
rev
return
δ
.
rev
,
true
}
}
}
}
}
}
// nothing found
// nothing found
panic
(
"TODO"
)
// XXX
return
δtail
.
tailv
[
0
]
.
rev
,
false
}
}
wcfs/δtail_test.go
View file @
33e0dfce
...
@@ -20,6 +20,7 @@
...
@@ -20,6 +20,7 @@
package
main
package
main
import
(
import
(
"fmt"
"reflect"
"reflect"
"testing"
"testing"
...
@@ -33,14 +34,14 @@ func TestΔTail(t *testing.T) {
...
@@ -33,14 +34,14 @@ func TestΔTail(t *testing.T) {
δtail
:=
NewΔTail
()
δtail
:=
NewΔTail
()
// R is syntic sugar to create 1 δRevEntry
// R is synt
act
ic sugar to create 1 δRevEntry
R
:=
func
(
rev
zodb
.
Tid
,
δ
v
...
ID
)
δRevEntry
{
R
:=
func
(
rev
zodb
.
Tid
,
change
v
...
ID
)
δRevEntry
{
return
δRevEntry
{
rev
,
δ
v
}
return
δRevEntry
{
rev
,
change
v
}
}
}
// δAppend is syntatic sugar for δtail.Append
// δAppend is synta
c
tic sugar for δtail.Append
δAppend
:=
func
(
δ
δRevEntry
)
{
δAppend
:=
func
(
δ
δRevEntry
)
{
δtail
.
Append
(
δ
.
rev
,
δ
.
δ
v
)
δtail
.
Append
(
δ
.
rev
,
δ
.
change
v
)
}
}
// δCheck verifies that δtail state corresponds to provided tailv
// δCheck verifies that δtail state corresponds to provided tailv
...
@@ -60,10 +61,10 @@ func TestΔTail(t *testing.T) {
...
@@ -60,10 +61,10 @@ func TestΔTail(t *testing.T) {
// verify lastRevOf query / index
// verify lastRevOf query / index
lastRevOf
:=
make
(
map
[
ID
]
zodb
.
Tid
)
lastRevOf
:=
make
(
map
[
ID
]
zodb
.
Tid
)
for
_
,
δ
:=
range
tailv
{
for
_
,
δ
:=
range
tailv
{
for
_
,
id
:=
range
δ
.
δ
v
{
for
_
,
id
:=
range
δ
.
change
v
{
idRev
:=
δtail
.
LastRevOf
(
id
,
δ
.
rev
)
idRev
,
exact
:=
δtail
.
LastRevOf
(
id
,
δ
.
rev
)
if
idRev
!=
δ
.
rev
{
if
!
(
idRev
==
δ
.
rev
&&
exact
)
{
t
.
Fatalf
(
"LastRevOf(%v, at=%s) -> %s
; want %s"
,
id
,
δ
.
rev
,
idRev
,
δ
.
rev
)
t
.
Fatalf
(
"LastRevOf(%v, at=%s) -> %s
, %v ; want %s, %v"
,
id
,
δ
.
rev
,
idRev
,
exact
,
δ
.
rev
,
true
)
}
}
lastRevOf
[
id
]
=
δ
.
rev
lastRevOf
[
id
]
=
δ
.
rev
...
@@ -76,37 +77,87 @@ func TestΔTail(t *testing.T) {
...
@@ -76,37 +77,87 @@ func TestΔTail(t *testing.T) {
}
}
// δCheckLastUP verifies that δtail.LastRevOf(id, at) gives lastOk and exact=false.
// (we don't need to check for exact=true as those cases are covered in δCheck)
δCheckLastUP
:=
func
(
id
ID
,
at
,
lastOk
zodb
.
Tid
)
{
t
.
Helper
()
last
,
exact
:=
δtail
.
LastRevOf
(
id
,
at
)
if
!
(
last
==
lastOk
&&
exact
==
false
)
{
t
.
Fatalf
(
"LastRevOf(%v, at=%s) -> %s, %v ; want %s, %v"
,
id
,
at
,
last
,
exact
,
lastOk
,
false
)
}
}
δCheck
()
δCheck
()
δCheckLastUP
(
4
,
12
,
12
)
// δtail = ø
δAppend
(
R
(
10
,
3
,
5
))
δAppend
(
R
(
10
,
3
,
5
))
δCheck
(
R
(
10
,
3
,
5
))
δCheck
(
R
(
10
,
3
,
5
))
δCheckLastUP
(
3
,
9
,
9
)
// at < δtail
δCheckLastUP
(
3
,
12
,
12
)
// at > δtail
δCheckLastUP
(
4
,
10
,
10
)
// id ∉ δtail
δAppend
(
R
(
11
,
7
))
δAppend
(
R
(
11
,
7
))
δCheck
(
R
(
10
,
3
,
5
),
R
(
11
,
7
))
δCheck
(
R
(
10
,
3
,
5
),
R
(
11
,
7
))
δAppend
(
R
(
12
,
7
))
δAppend
(
R
(
12
,
7
))
δCheck
(
R
(
10
,
3
,
5
),
R
(
11
,
7
),
R
(
12
,
7
))
δCheck
(
R
(
10
,
3
,
5
),
R
(
11
,
7
),
R
(
12
,
7
))
δAppend
(
R
(
14
,
3
,
7
))
δAppend
(
R
(
14
,
3
,
8
))
δCheck
(
R
(
10
,
3
,
5
),
R
(
11
,
7
),
R
(
12
,
7
),
R
(
14
,
3
,
7
))
δCheck
(
R
(
10
,
3
,
5
),
R
(
11
,
7
),
R
(
12
,
7
),
R
(
14
,
3
,
8
))
δCheckLastUP
(
8
,
12
,
10
)
// id ∈ δtail, but has no entry with rev ≤ at
δtail
.
ForgetBefore
(
10
)
δtail
.
ForgetBefore
(
10
)
δCheck
(
R
(
10
,
3
,
5
),
R
(
11
,
7
),
R
(
12
,
7
),
R
(
14
,
3
,
7
))
δCheck
(
R
(
10
,
3
,
5
),
R
(
11
,
7
),
R
(
12
,
7
),
R
(
14
,
3
,
8
))
δtail
.
ForgetBefore
(
11
)
δtail
.
ForgetBefore
(
11
)
δCheck
(
R
(
11
,
7
),
R
(
12
,
7
),
R
(
14
,
3
,
7
))
δCheck
(
R
(
11
,
7
),
R
(
12
,
7
),
R
(
14
,
3
,
8
))
δtail
.
ForgetBefore
(
13
)
δtail
.
ForgetBefore
(
13
)
δCheck
(
R
(
14
,
3
,
7
))
δCheck
(
R
(
14
,
3
,
8
))
δtail
.
ForgetBefore
(
15
)
δtail
.
ForgetBefore
(
15
)
δCheck
()
δCheck
()
// Append panics on non-↑ rev
δAppend
(
R
(
15
,
1
))
func
()
{
defer
func
()
{
r
:=
recover
()
if
r
==
nil
{
t
.
Fatal
(
"append non-↑: not panicked"
)
}
rev
:=
zodb
.
Tid
(
15
)
want
:=
fmt
.
Sprintf
(
"δtail.Append: rev not ↑: %s -> %s"
,
rev
,
rev
)
if
r
!=
want
{
t
.
Fatalf
(
"append non-↑:
\n
have: %q
\n
want: %q"
,
r
,
want
)
}
}()
δAppend
(
R
(
15
,
1
))
}()
// .tailv underlying storage is not kept after forget
δtail
.
ForgetBefore
(
16
)
const
N
=
1E3
for
rev
,
i
:=
zodb
.
Tid
(
16
),
0
;
i
<
N
;
i
,
rev
=
i
+
1
,
rev
+
1
{
δAppend
(
R
(
rev
,
1
))
}
capN
:=
cap
(
δtail
.
tailv
)
δtail
.
ForgetBefore
(
N
)
if
c
:=
cap
(
δtail
.
tailv
);
!
(
c
<
capN
/
10
)
{
t
.
Fatalf
(
"forget: tailv storage did not shrink: cap%v: %d -> cap: %d"
,
N
,
capN
,
c
)
}
//
XXX .Append(rev not ↑ - panic)
//
.tailv underlying storage does not grow indefinitely
// XXX
edge cases
// XXX
cannot test as the growth here goes to left and we cannot get
//
XXX .tailv underlying storage does not grow indefinitely
//
access to whole underlying array from a slice.
}
}
func
tailvEqual
(
a
,
b
[]
δRevEntry
)
bool
{
func
tailvEqual
(
a
,
b
[]
δRevEntry
)
bool
{
...
...
Kirill Smelkov
@kirr
mentioned in commit
e16e029a
·
Oct 27, 2021
mentioned in commit
e16e029a
mentioned in commit e16e029a7de3fde8b4a5cd4fe432e106617d3e02
Toggle commit list
Kirill Smelkov
@kirr
mentioned in commit
4430de41
·
Oct 28, 2021
mentioned in commit
4430de41
mentioned in commit 4430de41bfb5702980935c46ea8fee2774aaea40
Toggle commit list
Kirill Smelkov
@kirr
mentioned in commit
6f0cdaff
·
Oct 28, 2021
mentioned in commit
6f0cdaff
mentioned in commit 6f0cdaff23d4f8c3384b74b02d669c7fcfc2d821
Toggle commit list
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment